diff --git a/apps/temps-cli/README.md b/apps/temps-cli/README.md
index 1b8066ee..123dadef 100644
--- a/apps/temps-cli/README.md
+++ b/apps/temps-cli/README.md
@@ -1,15 +1,340 @@
-# temps-cli
+
+
+
-To install dependencies:
+@temps-sdk/cli
+
+
+
+
+
+
+
+
+ Command-line interface for the Temps deployment platform. Deploy, manage, and monitor your applications from the terminal -- no dashboard required.
+
+
+---
+
+```bash
+# npm
+npm install -g @temps-sdk/cli
+
+# bun
+bun add -g @temps-sdk/cli
+
+# pnpm
+pnpm add -g @temps-sdk/cli
+
+# Or run without installing
+npx @temps-sdk/cli
+bunx @temps-sdk/cli
+```
+
+## Quick Start
+
+```bash
+# Authenticate with your Temps instance
+bunx @temps-sdk/cli login
+
+# Initialize a project in the current directory
+bunx @temps-sdk/cli init
+
+# Deploy
+bunx @temps-sdk/cli up
+
+# Check status
+bunx @temps-sdk/cli status
+```
+
+That's it. The CLI detects your framework, connects your git repo, and deploys -- all in one command.
+
+## Configuration
+
+### Interactive Setup
+
+```bash
+bunx @temps-sdk/cli configure
+```
+
+Walks you through setting your Temps API URL and authentication token, stored in `~/.temps/config.json`.
+
+### Environment Variables
+
+```bash
+TEMPS_API_URL=https://your-instance.temps.dev # Your Temps API URL
+TEMPS_API_TOKEN=your-token # API key or deployment token
+TEMPS_PROJECT=my-app # Project slug (optional)
+```
+
+Environment variables take precedence over config files, making CI/CD integration straightforward.
+
+### Project-Level Config
+
+Running `bunx @temps-sdk/cli init` or `bunx @temps-sdk/cli link` creates a `.temps/config.json` in your project directory:
+
+```json
+{
+ "projectSlug": "my-app"
+}
+```
+
+The CLI walks upward from your working directory to find `.temps/config.json` (like `.git` discovery). When found, the `projectSlug` is used to auto-fetch the project ID and all configuration (git connection, preset, environments) from the API -- no need to pass `--project` on every command.
+
+**Resolution order:** `--project` flag > `.temps/config.json` > `TEMPS_PROJECT` env var > global default.
+
+## Developer Workflow
+
+### `init`
+
+Initialize a new Temps project in the current directory. Detects your framework, creates a project on the platform, and links it.
```bash
-bun install
+bunx @temps-sdk/cli init
```
-To run:
+### `link `
+
+Link the current directory to an existing Temps project.
```bash
-bun run index.ts
+bunx @temps-sdk/cli link my-app
+```
+
+### `up`
+
+One-command deploy. If the project is not yet linked, an interactive setup wizard walks you through framework detection, git connection, and service provisioning. If the project is already linked (via `link` or `init`), it fetches the project configuration -- including the git connection and preset -- shows a deployment preview, and triggers the pipeline with a live progress TUI.
+
+```bash
+# Deploy the current directory
+bunx @temps-sdk/cli up
+
+# Deploy a specific branch
+bunx @temps-sdk/cli up --branch main
+
+# Deploy with a specific preset (skip auto-detection)
+bunx @temps-sdk/cli up --preset nextjs
+
+# Manual deployment mode (no git, uploads a local Docker image)
+bunx @temps-sdk/cli up --manual
+```
+
+**What `up` shows for a linked project:**
+
```
+i Using project acme-api (from local-config)
+✔ Found project: acme-api
+i Repository: acme-org/acme-api
+i Preset: fastapi
+
+╭─ Deployment Preview ──────────────╮
+│ Project: acme-api │
+│ Environment: production │
+│ Branch: main │
+│ Preset: fastapi │
+│ Repository: acme-org/acme-api │
+╰────────────────────────────────────╯
+
+✔ Deployment started
+ 🚀 Deployment Progress
+ ...
+```
+
+If the project has no git provider connected, `up` warns you and suggests how to connect one or fall back to manual deployment.
+
+### `status`
+
+View the current project's deployment status, container health, and domain configuration.
+
+```bash
+bunx @temps-sdk/cli status
+```
+
+### `open`
+
+Open the project's live URL in your default browser.
+
+```bash
+bunx @temps-sdk/cli open
+```
+
+### `rollback`
+
+Rollback to the previous deployment.
+
+```bash
+bunx @temps-sdk/cli rollback
+```
+
+### `env:pull` / `env:push`
+
+Sync environment variables between your local `.env` file and the Temps project.
+
+```bash
+# Download env vars to .env
+bunx @temps-sdk/cli env:pull
+
+# Upload .env to the project
+bunx @temps-sdk/cli env:push
+```
+
+## Deployment Methods
+
+### Git-Based Deploy
+
+```bash
+# Deploy from a branch (default: current branch)
+bunx @temps-sdk/cli deploy
+
+# Deploy a specific branch
+bunx @temps-sdk/cli deploy --branch feature/new-ui
+
+# Deploy to a specific environment
+bunx @temps-sdk/cli deploy --branch main --environment production
+```
+
+### Local Docker Image
+
+Build a Docker image locally, export it, and upload it directly -- useful when your CI builds images or for air-gapped environments.
+
+```bash
+bunx @temps-sdk/cli deploy:local-image --tag my-app:latest
+```
+
+### List Deployments
+
+```bash
+bunx @temps-sdk/cli deployments
+```
+
+## Multi-Instance Management
+
+Manage multiple Temps server instances (self-hosted and cloud) from a single CLI.
+
+```bash
+# List configured instances
+bunx @temps-sdk/cli instances list
+
+# Add a new instance
+bunx @temps-sdk/cli instances add
+
+# Switch active instance
+bunx @temps-sdk/cli instances switch
+```
+
+## Temps Cloud
+
+Connect to Temps Cloud for managed hosting with automatic provisioning.
+
+```bash
+# Login via browser (device code flow)
+bunx @temps-sdk/cli cloud login
+
+# Check current user
+bunx @temps-sdk/cli cloud whoami
+
+# Manage VPS instances
+bunx @temps-sdk/cli cloud vps list
+bunx @temps-sdk/cli cloud vps create
+bunx @temps-sdk/cli cloud vps destroy
+
+# View billing and usage
+bunx @temps-sdk/cli cloud billing
+```
+
+## Platform Migration
+
+Migrate projects from other platforms with an interactive wizard that discovers your projects, snapshots configuration, and generates a step-by-step migration plan.
+
+```bash
+bunx @temps-sdk/cli migrate
+```
+
+**Supported platforms:**
+
+| Platform | What's migrated |
+|----------|-----------------|
+| Vercel | Projects, env vars, domains |
+| Coolify | Projects, services, env vars, domains |
+| Dokploy | Projects, services, env vars, domains |
+
+## Resource Management
+
+The CLI provides full CRUD access to every Temps resource:
+
+```bash
+# Projects
+bunx @temps-sdk/cli projects list
+bunx @temps-sdk/cli projects create
+bunx @temps-sdk/cli projects show
+
+# Domains & SSL
+bunx @temps-sdk/cli domains list
+bunx @temps-sdk/cli domains provision
+
+# Services (PostgreSQL, Redis, S3)
+bunx @temps-sdk/cli services list --project
+bunx @temps-sdk/cli services create --project
+
+# Monitoring
+bunx @temps-sdk/cli monitors list
+bunx @temps-sdk/cli monitors create
+
+# Environment variables
+bunx @temps-sdk/cli environments list --project
+
+# Git providers
+bunx @temps-sdk/cli providers list
+bunx @temps-sdk/cli providers sync
+
+# Backups
+bunx @temps-sdk/cli backups list --project
+bunx @temps-sdk/cli backups run
+
+# Container management
+bunx @temps-sdk/cli containers list --project
+
+# Runtime logs (live streaming)
+bunx @temps-sdk/cli runtime-logs --project
+```
+
+**Full resource list:** projects, deployments, environments, domains, custom-domains, DNS, DNS providers, git providers, services, backups, containers, monitors, incidents, webhooks, API keys, tokens, users, settings, audit logs, proxy logs, errors, DSN, KV, blob, scans, IP access, email domains, email providers, emails, load balancer, templates, presets, funnels, notifications, notification preferences, platform.
+
+## CI/CD Integration
+
+Use environment variables for non-interactive deployments:
+
+```bash
+# GitHub Actions example
+env:
+ TEMPS_API_URL: ${{ secrets.TEMPS_API_URL }}
+ TEMPS_API_TOKEN: ${{ secrets.TEMPS_API_TOKEN }}
+
+steps:
+ - run: bunx @temps-sdk/cli deploy --branch ${{ github.ref_name }} --project my-app
+```
+
+## Global Options
+
+| Option | Description |
+|--------|-------------|
+| `-v, --version` | Display version number |
+| `--no-color` | Disable colored output |
+| `--debug` | Enable debug output |
+| `-h, --help` | Display help |
+
+## Requirements
+
+- Node.js 18+ or Bun
+- A running Temps instance (self-hosted or Temps Cloud)
+
+## Related
+
+- [`@temps-sdk/kv`](https://www.npmjs.com/package/@temps-sdk/kv) -- Key-value store
+- [`@temps-sdk/blob`](https://www.npmjs.com/package/@temps-sdk/blob) -- File storage
+- [`@temps-sdk/react-analytics`](https://www.npmjs.com/package/@temps-sdk/react-analytics) -- React analytics, session replay, error tracking
+- [`@temps-sdk/node-sdk`](https://www.npmjs.com/package/@temps-sdk/node-sdk) -- Full platform API client and server-side error tracking
+
+## License
-This project was created using `bun init` in bun v1.3.1. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.
+MIT
diff --git a/apps/temps-cli/package.json b/apps/temps-cli/package.json
index 23057160..27a63e9f 100644
--- a/apps/temps-cli/package.json
+++ b/apps/temps-cli/package.json
@@ -1,6 +1,6 @@
{
"name": "@temps-sdk/cli",
- "version": "0.1.9",
+ "version": "0.1.12",
"description": "CLI for Temps deployment platform",
"type": "module",
"bin": {
diff --git a/apps/temps-cli/src/cli.ts b/apps/temps-cli/src/cli.ts
index 01f4475d..acf0d6bb 100644
--- a/apps/temps-cli/src/cli.ts
+++ b/apps/temps-cli/src/cli.ts
@@ -79,7 +79,7 @@ export function createProgram(): Command {
program
.name('temps')
.description('CLI for Temps deployment platform')
- .version(VERSION, '-v, --version', 'Display version number')
+ .version(VERSION, '-V, --version', 'Display version number')
.option('--no-color', 'Disable colored output')
.option('--debug', 'Enable debug output')
.hook('preAction', (thisCommand) => {
diff --git a/apps/temps-cli/src/commands/auth/login.ts b/apps/temps-cli/src/commands/auth/login.ts
index 055d4dd3..df12c09c 100644
--- a/apps/temps-cli/src/commands/auth/login.ts
+++ b/apps/temps-cli/src/commands/auth/login.ts
@@ -2,7 +2,7 @@ import { credentials, config } from '../../config/store.js'
import { promptPassword, promptText, promptSelect, promptEmail } from '../../ui/prompts.js'
import { withSpinner } from '../../ui/spinner.js'
import { info, icons, colors, newline, box, warning } from '../../ui/output.js'
-import { setupClient, client } from '../../lib/api-client.js'
+import { setupClient, client, normalizeApiUrl } from '../../lib/api-client.js'
import { getCurrentUser } from '../../api/sdk.gen.js'
import { AuthenticationError } from '../../utils/errors.js'
@@ -119,7 +119,7 @@ export async function loginWithEmail(emailArg?: string): Promise {
})
// Use raw fetch to capture the session cookie
- const apiUrl = config.get('apiUrl')
+ const apiUrl = normalizeApiUrl(config.get('apiUrl'))
const authResponse = await withSpinner('Logging in...', async () => {
const response = await fetch(`${apiUrl}/auth/login`, {
@@ -238,7 +238,7 @@ export async function loginWithEmail(emailArg?: string): Promise {
export async function loginWithMagicLink(emailArg?: string): Promise {
const email = emailArg ?? await promptEmail('Email')
- const apiUrl = config.get('apiUrl')
+ const apiUrl = normalizeApiUrl(config.get('apiUrl'))
await withSpinner('Sending magic link...', async () => {
const response = await fetch(`${apiUrl}/auth/magic-link/request`, {
diff --git a/apps/temps-cli/src/commands/configure.ts b/apps/temps-cli/src/commands/configure.ts
index 9e7185e3..d49c39e3 100644
--- a/apps/temps-cli/src/commands/configure.ts
+++ b/apps/temps-cli/src/commands/configure.ts
@@ -110,22 +110,49 @@ async function runConfigureWizard(options: ConfigureOptions & { disableColors?:
console.log(colors.muted('This wizard will help you configure the CLI.\n'))
// API URL (skip prompt if provided via flag)
- const apiUrl = options.apiUrl ?? await promptUrl(
- `API URL [${colors.muted(currentConfig.apiUrl)}]`,
- currentConfig.apiUrl
- )
+ // Use effective value (env var > stored config) as the default
+ const envApiUrl = process.env.TEMPS_API_URL
+ let apiUrl: string
+
+ if (options.apiUrl) {
+ apiUrl = options.apiUrl
+ } else if (envApiUrl) {
+ console.log(` API URL: ${colors.bold(envApiUrl)} ${colors.muted('(env: TEMPS_API_URL)')}`)
+ console.log(colors.muted(' To change, unset TEMPS_API_URL or use --api-url flag\n'))
+ apiUrl = envApiUrl
+ } else {
+ apiUrl = await promptUrl(
+ `API URL [${colors.muted(currentConfig.apiUrl)}]`,
+ currentConfig.apiUrl
+ )
+ }
// Save API URL first (needed for token validation)
- config.set('apiUrl', apiUrl)
+ // Don't overwrite stored config if env var is active (env var takes precedence at runtime)
+ if (!envApiUrl || options.apiUrl) {
+ config.set('apiUrl', apiUrl)
+ }
// API Token configuration
let authStatus = 'Not authenticated'
+ const envApiToken = process.env.TEMPS_TOKEN || process.env.TEMPS_API_TOKEN || process.env.TEMPS_API_KEY
+ const envApiTokenName = process.env.TEMPS_TOKEN ? 'TEMPS_TOKEN'
+ : process.env.TEMPS_API_TOKEN ? 'TEMPS_API_TOKEN'
+ : process.env.TEMPS_API_KEY ? 'TEMPS_API_KEY'
+ : null
+
if (options.apiToken) {
// Token provided via flag
const tokenValid = await validateAndSaveToken(options.apiToken, apiUrl)
if (tokenValid) {
authStatus = `Authenticated as ${await credentials.get('email') ?? 'unknown'}`
}
+ } else if (envApiToken) {
+ // Token from environment variable — skip prompt
+ const email = await credentials.get('email')
+ authStatus = `Authenticated as ${email ?? 'unknown'} ${colors.muted(`(env: ${envApiTokenName})`)}`
+ console.log(`\n API Token: ${colors.muted('***')} ${colors.muted(`(env: ${envApiTokenName})`)}`)
+ console.log(colors.muted(` To change, unset ${envApiTokenName} or use --api-token flag`))
} else if (isAuthenticated) {
// Already authenticated, ask if they want to update
console.log(colors.muted(`\nCurrently authenticated as: ${colors.bold(currentEmail ?? 'unknown')}`))
@@ -190,12 +217,12 @@ async function runConfigureWizard(options: ConfigureOptions & { disableColors?:
default: currentConfig.colorEnabled,
})
- // Save configuration
- config.setAll({
- apiUrl,
- outputFormat,
- colorEnabled,
- })
+ // Save configuration (don't overwrite apiUrl if env var is active)
+ const configToSave: Partial = { outputFormat, colorEnabled }
+ if (!envApiUrl || options.apiUrl) {
+ configToSave.apiUrl = apiUrl
+ }
+ config.setAll(configToSave)
newline()
box(
@@ -256,13 +283,22 @@ function setConfigValue(key: string, value: string): void {
}
function listConfig(): void {
- const allConfig = config.getAll()
+ const storedConfig = config.getAll()
newline()
header(`${icons.folder} Configuration`)
- for (const [key, value] of Object.entries(allConfig)) {
- keyValue(key, value)
+ // Show effective values with env var override annotations
+ for (const [key, storedValue] of Object.entries(storedConfig)) {
+ const effectiveValue = config.get(key as keyof TempsConfig)
+ const isOverridden = key === 'apiUrl' && process.env.TEMPS_API_URL
+
+ if (isOverridden) {
+ keyValue(key, `${effectiveValue} ${colors.muted('(env: TEMPS_API_URL)')}`)
+ keyValue(` ${colors.muted('stored')}`, colors.muted(String(storedValue)))
+ } else {
+ keyValue(key, effectiveValue)
+ }
}
newline()
@@ -279,9 +315,13 @@ async function showConfig(options: { json?: boolean }): Promise {
const apiUrlSource = envApiUrl ? 'env' : 'config'
const apiUrl = envApiUrl || allConfig.apiUrl
- // Check if API key is from environment variable
- const envApiKey = process.env.TEMPS_API_TOKEN || process.env.TEMPS_API_KEY
+ // Check if API key is from environment variable (must match getApiKey() priority)
+ const envApiKey = process.env.TEMPS_TOKEN || process.env.TEMPS_API_TOKEN || process.env.TEMPS_API_KEY
const apiKeySource = envApiKey ? 'env' : 'config'
+ const apiKeyEnvName = process.env.TEMPS_TOKEN ? 'TEMPS_TOKEN'
+ : process.env.TEMPS_API_TOKEN ? 'TEMPS_API_TOKEN'
+ : process.env.TEMPS_API_KEY ? 'TEMPS_API_KEY'
+ : null
// Mask API key - show first 8 characters
let maskedApiKey = 'Not configured'
@@ -315,7 +355,7 @@ async function showConfig(options: { json?: boolean }): Promise {
// API URL
if (apiUrlSource === 'env') {
- keyValue('API URL', `${apiUrl} ${colors.muted('(from TEMPS_API_URL)')}`)
+ keyValue('API URL', `${apiUrl} ${colors.muted('(env: TEMPS_API_URL)')}`)
} else {
keyValue('API URL', apiUrl)
}
@@ -323,8 +363,8 @@ async function showConfig(options: { json?: boolean }): Promise {
// API Key
if (apiKey) {
const sourceNote = apiKeySource === 'env'
- ? colors.muted('(from TEMPS_API_TOKEN)')
- : colors.muted('(from config)')
+ ? colors.muted(`(env: ${apiKeyEnvName})`)
+ : colors.muted('(config)')
keyValue('API Key', `${maskedApiKey} ${sourceNote}`)
} else {
keyValue('API Key', colors.warning('Not configured'))
diff --git a/apps/temps-cli/src/commands/deploy/deploy-image.ts b/apps/temps-cli/src/commands/deploy/deploy-image.ts
index eafbc3e9..5d476d94 100644
--- a/apps/temps-cli/src/commands/deploy/deploy-image.ts
+++ b/apps/temps-cli/src/commands/deploy/deploy-image.ts
@@ -1,5 +1,6 @@
import { requireAuth, config, credentials } from '../../config/store.js'
-import { setupClient, client } from '../../lib/api-client.js'
+import { setupClient, client, normalizeApiUrl } from '../../lib/api-client.js'
+import { resolveProjectSlug } from '../../config/resolve-project.js'
import { watchDeployment } from '../../lib/deployment-watcher.jsx'
import { getProjectBySlug, getProject, getEnvironments } from '../../api/sdk.gen.js'
import type { EnvironmentResponse } from '../../api/types.gen.js'
@@ -58,17 +59,22 @@ export async function deployImage(options: DeployImageOptions): Promise {
return
}
- // Get project name
- const projectName = options.project ?? config.get('defaultProject')
+ // Resolve project
+ const resolved = await resolveProjectSlug(options.project)
- if (!projectName) {
+ if (!resolved) {
warning('No project specified')
- info(
- 'Use: temps deploy image --project or set a default with temps configure'
- )
+ info('Use: bunx @temps-sdk/cli deploy:image --project ')
+ info('Or link this directory: bunx @temps-sdk/cli link ')
return
}
+ const projectName = resolved.slug
+
+ if (resolved.source !== 'flag') {
+ info(`Using project ${colors.bold(projectName)} (from ${resolved.source})`)
+ }
+
// Fetch project details
startSpinner('Fetching project details...')
@@ -215,7 +221,7 @@ export async function deployImage(options: DeployImageOptions): Promise {
// Trigger deployment
startSpinner('Starting deployment...')
- const apiUrl = config.get('apiUrl')
+ const apiUrl = normalizeApiUrl(config.get('apiUrl'))
const apiKey = await credentials.getApiKey()
try {
diff --git a/apps/temps-cli/src/commands/deploy/deploy-local-image.ts b/apps/temps-cli/src/commands/deploy/deploy-local-image.ts
index 9289e157..9ea4e6f1 100644
--- a/apps/temps-cli/src/commands/deploy/deploy-local-image.ts
+++ b/apps/temps-cli/src/commands/deploy/deploy-local-image.ts
@@ -1,5 +1,6 @@
import { requireAuth, config, credentials } from '../../config/store.js'
-import { setupClient, client } from '../../lib/api-client.js'
+import { setupClient, client, normalizeApiUrl } from '../../lib/api-client.js'
+import { resolveProjectSlug } from '../../config/resolve-project.js'
import { watchDeployment } from '../../lib/deployment-watcher.jsx'
import { getProjectBySlug, getProject, getEnvironments, generatePresetDockerfile } from '../../api/sdk.gen.js'
import type { EnvironmentResponse } from '../../api/types.gen.js'
@@ -48,16 +49,21 @@ export async function deployLocalImage(options: DeployLocalImageOptions): Promis
newline()
// ─── Step 1: Resolve project and environment ─────────────────────────────
- const projectName = options.project ?? config.get('defaultProject')
+ const resolved = await resolveProjectSlug(options.project)
- if (!projectName) {
+ if (!resolved) {
warning('No project specified')
- info(
- 'Use: temps deploy:local-image --project or set a default with temps configure'
- )
+ info('Use: bunx @temps-sdk/cli deploy:local-image --project ')
+ info('Or link this directory: bunx @temps-sdk/cli link ')
return
}
+ const projectName = resolved.slug
+
+ if (resolved.source !== 'flag') {
+ info(`Using project ${colors.bold(projectName)} (from ${resolved.source})`)
+ }
+
startSpinner('Fetching project details...')
let projectData: { id: number; name: string; slug: string }
@@ -317,7 +323,7 @@ export async function deployLocalImage(options: DeployLocalImageOptions): Promis
startSpinner('Uploading image to server...')
- const apiUrl = config.get('apiUrl')
+ const apiUrl = normalizeApiUrl(config.get('apiUrl'))
const apiKey = await credentials.getApiKey()
try {
@@ -606,7 +612,11 @@ interface GeneratedDockerfile {
async function tryGenerateDockerfile(
projectSlug?: string
): Promise {
- const slug = projectSlug ?? config.get('defaultProject')
+ let slug = projectSlug
+ if (!slug) {
+ const resolved = await resolveProjectSlug()
+ slug = resolved?.slug
+ }
if (!slug) return null
// Fetch the project to get its preset
diff --git a/apps/temps-cli/src/commands/deploy/deploy-static.ts b/apps/temps-cli/src/commands/deploy/deploy-static.ts
index 38b4cbcd..62f47df2 100644
--- a/apps/temps-cli/src/commands/deploy/deploy-static.ts
+++ b/apps/temps-cli/src/commands/deploy/deploy-static.ts
@@ -1,5 +1,6 @@
import { requireAuth, config, credentials } from '../../config/store.js'
-import { setupClient, client } from '../../lib/api-client.js'
+import { setupClient, client, normalizeApiUrl } from '../../lib/api-client.js'
+import { resolveProjectSlug } from '../../config/resolve-project.js'
import { watchDeployment } from '../../lib/deployment-watcher.jsx'
import { getProjectBySlug, getProject, getEnvironments } from '../../api/sdk.gen.js'
import type { EnvironmentResponse } from '../../api/types.gen.js'
@@ -62,17 +63,22 @@ export async function deployStatic(options: DeployStaticOptions): Promise
return
}
- // Get project name
- const projectName = options.project ?? config.get('defaultProject')
+ // Resolve project
+ const resolved = await resolveProjectSlug(options.project)
- if (!projectName) {
+ if (!resolved) {
warning('No project specified')
- info(
- 'Use: temps deploy static --project or set a default with temps configure'
- )
+ info('Use: bunx @temps-sdk/cli deploy:static --project ')
+ info('Or link this directory: bunx @temps-sdk/cli link ')
return
}
+ const projectName = resolved.slug
+
+ if (resolved.source !== 'flag') {
+ info(`Using project ${colors.bold(projectName)} (from ${resolved.source})`)
+ }
+
// Fetch project details
startSpinner('Fetching project details...')
@@ -253,7 +259,7 @@ export async function deployStatic(options: DeployStaticOptions): Promise
// Upload static bundle
startSpinner('Uploading static bundle...')
- const apiUrl = config.get('apiUrl')
+ const apiUrl = normalizeApiUrl(config.get('apiUrl'))
const apiKey = await credentials.getApiKey()
try {
diff --git a/apps/temps-cli/src/commands/deploy/deploy.ts b/apps/temps-cli/src/commands/deploy/deploy.ts
index d8219e19..cd25d813 100644
--- a/apps/temps-cli/src/commands/deploy/deploy.ts
+++ b/apps/temps-cli/src/commands/deploy/deploy.ts
@@ -1,21 +1,93 @@
import { requireAuth, config } from '../../config/store.js'
-import { setupClient, client, getErrorMessage } from '../../lib/api-client.js'
+import { setupClient, client, getErrorMessage, getWebUrl } from '../../lib/api-client.js'
+import { resolveProjectSlug } from '../../config/resolve-project.js'
import {
getProjectBySlug,
getEnvironments,
triggerProjectPipeline,
- getLastDeployment,
+ getProjectDeployments,
+ getRepositoryByName,
} from '../../api/sdk.gen.js'
-import type { EnvironmentResponse, DeploymentResponse } from '../../api/types.gen.js'
+import type { EnvironmentResponse, ProjectResponse } from '../../api/types.gen.js'
import { promptSelect, promptText } from '../../ui/prompts.js'
-import { startSpinner, succeedSpinner, failSpinner, updateSpinner } from '../../ui/spinner.js'
-import { success, info, warning, newline, icons, colors, header, keyValue, box } from '../../ui/output.js'
+import { startSpinner, succeedSpinner, failSpinner } from '../../ui/spinner.js'
+import { info, warning, newline, icons, colors, box } from '../../ui/output.js'
+import { watchDeployment } from '../../lib/deployment-watcher.jsx'
+import { deployLocalImage } from './deploy-local-image.js'
+import { deployStatic } from './deploy-static.js'
+
+// Types for the /repository/{id}/commits endpoint (not yet in generated SDK)
+interface CommitInfo {
+ sha: string
+ message: string
+ author: string
+ author_email: string
+ date: string
+}
+
+interface CommitListResponse {
+ commits: CommitInfo[]
+}
+
+/**
+ * Fetch recent commits for a repository branch from the remote git provider.
+ */
+async function fetchRemoteCommits(
+ repositoryId: number,
+ branch: string,
+ perPage = 20,
+): Promise {
+ try {
+ const response = await client.get({
+ security: [{ scheme: 'bearer', type: 'http' }],
+ url: '/repository/{repository_id}/commits',
+ path: { repository_id: repositoryId },
+ query: { branch, per_page: perPage },
+ })
+ const data = response.data as CommitListResponse | undefined
+ return data?.commits ?? []
+ } catch {
+ return []
+ }
+}
+
+/**
+ * Look up the repository ID for a project's repo_owner/repo_name.
+ */
+async function getRepositoryId(
+ repoOwner: string,
+ repoName: string,
+ connectionId?: number | null,
+): Promise {
+ try {
+ const { data } = await getRepositoryByName({
+ client,
+ path: { owner: repoOwner, name: repoName },
+ query: connectionId ? { connection_id: String(connectionId) } : undefined,
+ })
+ return data?.id ?? null
+ } catch {
+ return null
+ }
+}
+
+function getRelativeTime(date: Date): string {
+ const seconds = Math.floor((Date.now() - date.getTime()) / 1000)
+ if (seconds < 60) return 'just now'
+ const minutes = Math.floor(seconds / 60)
+ if (minutes < 60) return `${minutes}m ago`
+ const hours = Math.floor(minutes / 60)
+ if (hours < 24) return `${hours}h ago`
+ const days = Math.floor(hours / 24)
+ return `${days}d ago`
+}
interface DeployOptions {
project?: string
environment?: string
environmentId?: string
branch?: string
+ commit?: string
wait?: boolean
yes?: boolean
}
@@ -26,19 +98,26 @@ export async function deploy(options: DeployOptions): Promise {
newline()
- // Get project name
- const projectName = options.project ?? config.get('defaultProject')
+ // Resolve project: --project flag > .temps/config.json > TEMPS_PROJECT env > global default
+ const resolved = await resolveProjectSlug(options.project)
- if (!projectName) {
+ if (!resolved) {
warning('No project specified')
- info('Use: temps deploy --project or set a default with temps configure')
+ info('Use: bunx @temps-sdk/cli deploy --project ')
+ info('Or link this directory: bunx @temps-sdk/cli link ')
return
}
+ const projectName = resolved.slug
+
+ if (resolved.source !== 'flag') {
+ info(`Using project ${colors.bold(projectName)} (from ${resolved.source})`)
+ }
+
// Fetch project details
startSpinner('Fetching project details...')
- let projectData: { id: number; name: string }
+ let project: ProjectResponse
let environments: EnvironmentResponse[] = []
try {
@@ -48,17 +127,23 @@ export async function deploy(options: DeployOptions): Promise {
})
if (error || !data) {
+ const rawApiUrl = config.get('apiUrl')
+ const baseUrl = client.getConfig().baseUrl ?? rawApiUrl
failSpinner(`Project "${projectName}" not found`)
+ info(`API: ${colors.muted(`${baseUrl}/projects/by-slug/${projectName}`)}`)
+ if (error) {
+ info(`Error: ${getErrorMessage(error)}`)
+ }
return
}
- projectData = data
- succeedSpinner(`Found project: ${projectData.name}`)
+ project = data
+ succeedSpinner(`Found project: ${project.name}`)
// Fetch environments
const { data: envData } = await getEnvironments({
client,
- path: { project_id: projectData.id },
+ path: { project_id: project.id },
})
environments = envData ?? []
} catch (err) {
@@ -66,12 +151,114 @@ export async function deploy(options: DeployOptions): Promise {
throw err
}
+ // Check source type — delegate to appropriate deploy method
+ const sourceType = project.source_type
+ const isGitBased = sourceType === 'git'
+ const hasGitConnection = isGitBased && !!project.git_provider_connection_id
+
+ if (sourceType === 'static_files') {
+ info(`Project uses static files deployment`)
+ info(`Delegating to: ${colors.muted('deploy:static')}`)
+ newline()
+ await deployStatic({
+ path: '.',
+ project: projectName,
+ environment: options.environment,
+ environmentId: options.environmentId,
+ wait: options.wait,
+ yes: options.yes,
+ })
+ return
+ }
+
+ if (sourceType === 'docker_image' || sourceType === 'manual') {
+ info(`Project uses ${sourceType === 'docker_image' ? 'Docker image' : 'manual'} deployment`)
+ info(`Delegating to: ${colors.muted('deploy:local-image')}`)
+ newline()
+ await deployLocalImage({
+ project: projectName,
+ environment: options.environment,
+ environmentId: options.environmentId,
+ wait: options.wait,
+ yes: options.yes,
+ })
+ return
+ }
+
+ // Git-based project — check if git is actually connected
+ if (!hasGitConnection) {
+ warning('Project is git-based but no git provider is connected')
+ newline()
+ info('Options:')
+ info(` 1. Connect a git provider: ${colors.muted('bunx @temps-sdk/cli providers add')}`)
+ info(` 2. Deploy a local Docker image: ${colors.muted(`bunx @temps-sdk/cli deploy:local-image -p ${projectName}`)}`)
+ info(` 3. Deploy static files: ${colors.muted(`bunx @temps-sdk/cli deploy:static -p ${projectName} --path ./dist`)}`)
+ newline()
+
+ if (!options.yes) {
+ const choice = await promptSelect({
+ message: 'How would you like to deploy?',
+ choices: [
+ { name: 'Build & deploy local Docker image', value: 'local-image' },
+ { name: 'Deploy static files', value: 'static' },
+ { name: 'Cancel', value: 'cancel' },
+ ],
+ })
+
+ if (choice === 'local-image') {
+ await deployLocalImage({
+ project: projectName,
+ environment: options.environment,
+ environmentId: options.environmentId,
+ wait: options.wait,
+ yes: options.yes,
+ })
+ return
+ }
+
+ if (choice === 'static') {
+ const staticPath = await promptText({
+ message: 'Path to static files',
+ default: './dist',
+ })
+ await deployStatic({
+ path: staticPath,
+ project: projectName,
+ environment: options.environment,
+ environmentId: options.environmentId,
+ wait: options.wait,
+ yes: options.yes,
+ })
+ return
+ }
+
+ // Cancel
+ return
+ }
+
+ // Non-interactive with --yes: fall back to local-image
+ info('Falling back to local Docker image deployment (--yes mode)')
+ await deployLocalImage({
+ project: projectName,
+ environment: options.environment,
+ environmentId: options.environmentId,
+ wait: options.wait,
+ yes: options.yes,
+ })
+ return
+ }
+
+ // ─── Git-based deployment with connected provider ───────────────────────
+
+ if (project.repo_owner && project.repo_name) {
+ info(`Repository: ${colors.muted(`${project.repo_owner}/${project.repo_name}`)}`)
+ }
+
// Get environment
let environmentId: number | undefined
let environmentName = options.environment || 'production'
if (environments.length > 0) {
- // If environment ID is specified directly, use it
if (options.environmentId) {
environmentId = parseInt(options.environmentId, 10)
const env = environments.find(e => e.id === environmentId)
@@ -79,14 +266,12 @@ export async function deploy(options: DeployOptions): Promise {
environmentName = env.name
}
} else if (options.environment) {
- // Find by name
const env = environments.find(e => e.name === options.environment)
if (env) {
environmentId = env.id
environmentName = env.name
}
} else if (!options.yes) {
- // Interactive: prompt for environment selection
const selectedEnv = await promptSelect({
message: 'Select environment',
choices: environments.map((env) => ({
@@ -99,7 +284,6 @@ export async function deploy(options: DeployOptions): Promise {
environmentId = parseInt(selectedEnv, 10)
environmentName = environments.find(e => e.id === environmentId)?.name ?? 'production'
} else {
- // Non-interactive: use production or first environment
const prodEnv = environments.find(e => e.name === 'production')
if (prodEnv) {
environmentId = prodEnv.id
@@ -111,134 +295,160 @@ export async function deploy(options: DeployOptions): Promise {
}
}
- // Get branch - use flag value, or prompt if interactive mode
+ // Get branch
let branch = options.branch
if (!branch) {
if (options.yes) {
- branch = 'main' // Default for automation
+ branch = project.main_branch || 'main'
} else {
branch = await promptText({
message: 'Branch to deploy',
- default: 'main',
+ default: project.main_branch || 'main',
})
}
}
- // Confirm deployment (skip if --yes flag)
+ // Select commit — resolve from flag, interactive picker, or skip (deploy HEAD)
+ let commit = options.commit
+ if (!commit && !options.yes && project.repo_owner && project.repo_name) {
+ // Try to fetch recent commits so the user can pick one
+ const repositoryId = await getRepositoryId(
+ project.repo_owner,
+ project.repo_name,
+ project.git_provider_connection_id,
+ )
+
+ if (repositoryId) {
+ startSpinner('Fetching recent commits...')
+ const commits = await fetchRemoteCommits(repositoryId, branch)
+ if (commits.length > 0) {
+ succeedSpinner(`Found ${commits.length} recent commits`)
+
+ const HEAD_VALUE = '__HEAD__'
+ const selected = await promptSelect({
+ message: 'Select commit to deploy',
+ choices: [
+ { name: `${colors.bold('HEAD')} ${colors.muted('(latest on branch)')}`, value: HEAD_VALUE },
+ ...commits.map(c => {
+ const sha = colors.muted(c.sha.substring(0, 7))
+ const msg = c.message.split('\n')[0].substring(0, 60)
+ const ago = getRelativeTime(new Date(c.date))
+ return {
+ name: `${sha} ${msg} ${colors.muted(`(${c.author}, ${ago})`)}`,
+ value: c.sha,
+ }
+ }),
+ ],
+ })
+ if (selected !== HEAD_VALUE) {
+ commit = selected
+ }
+ } else {
+ succeedSpinner('No commits found, deploying HEAD')
+ }
+ }
+ }
+
+ // Deployment preview
newline()
box(
- `Project: ${colors.bold(projectName)}\n` +
- `Environment: ${colors.bold(environmentName)}\n` +
- `Branch: ${colors.bold(branch)}`,
+ [
+ `Project: ${colors.bold(projectName)}`,
+ `Environment: ${colors.bold(environmentName)}`,
+ `Branch: ${colors.bold(branch)}`,
+ commit ? `Commit: ${colors.bold(commit.substring(0, 7))}` : null,
+ project.preset ? `Preset: ${colors.bold(project.preset)}` : null,
+ project.repo_owner && project.repo_name
+ ? `Repository: ${colors.bold(`${project.repo_owner}/${project.repo_name}`)}`
+ : null,
+ ]
+ .filter(Boolean)
+ .join('\n'),
`${icons.rocket} Deployment Preview`
)
newline()
- // Start deployment
+ // Trigger git-based deployment
startSpinner('Starting deployment...')
try {
const { data, error } = await triggerProjectPipeline({
client,
- path: { id: projectData.id },
+ path: { id: project.id },
body: {
branch,
+ commit: commit ?? undefined,
environment_id: environmentId,
},
})
if (error || !data) {
failSpinner('Failed to start deployment')
+ const msg = getErrorMessage(error)
+ if (msg) {
+ info(`Reason: ${msg}`)
+ }
return
}
- succeedSpinner(`Deployment started`)
+ succeedSpinner('Deployment started')
info(data.message ?? 'Pipeline triggered successfully')
- if (options.wait !== false) {
- await waitForDeployment(projectData.id, environmentId)
- } else {
- newline()
- info('Deployment running in background')
- info(`Check status with: temps deployments list --project ${projectName}`)
- }
- } catch (err) {
- failSpinner('Deployment failed')
- throw err
- }
-}
-
-async function waitForDeployment(projectId: number, environmentId?: number): Promise {
- const statusMessages: Record = {
- pending: 'Waiting in queue...',
- building: 'Building application...',
- deploying: 'Deploying to servers...',
- running: 'Starting containers...',
- }
-
- startSpinner('Waiting for deployment...')
-
- let lastStatus = ''
- let attempts = 0
- const maxAttempts = 180 // 6 minutes with 2s intervals
-
- while (attempts < maxAttempts) {
- attempts++
+ const webUrl = getWebUrl()
+ info(`Dashboard: ${colors.primary(`${webUrl}/projects/${projectName}/deployments`)}`)
- const { data: deployment, error } = await getLastDeployment({
- client,
- path: { id: projectId },
- })
-
- if (error || !deployment) {
- await new Promise((resolve) => setTimeout(resolve, 2000))
- continue
- }
-
- // Check if this is the right deployment (for the selected environment)
- if (environmentId && deployment.environment_id !== environmentId) {
- await new Promise((resolve) => setTimeout(resolve, 2000))
- continue
+ if (options.wait === false) {
+ return
}
- if (deployment.status !== lastStatus) {
- lastStatus = deployment.status
- updateSpinner(statusMessages[deployment.status] ?? `Status: ${deployment.status}`)
- }
+ // Find the deployment ID so we can watch it with the rich TUI
+ startSpinner('Waiting for deployment to start...')
+ let deploymentId: number | null = null
+
+ for (let attempt = 0; attempt < 15; attempt++) {
+ const { data: deployList, error: deployError } = await getProjectDeployments({
+ client,
+ path: { id: project.id },
+ query: {
+ per_page: 1,
+ ...(environmentId ? { environment_id: environmentId } : {}),
+ },
+ })
- if (deployment.status === 'success' || deployment.status === 'completed' || deployment.status === 'deployed') {
- succeedSpinner(`${icons.rocket} Deployment successful!`)
- newline()
- header(`${icons.check} Deployment Complete`)
- keyValue('Deployment ID', deployment.id)
- keyValue('Commit', deployment.commit_hash?.substring(0, 7) ?? '-')
- if (deployment.url) {
- keyValue('URL', colors.primary(deployment.url))
+ if (deployError) {
+ failSpinner('Failed to fetch deployment status')
+ info(`Error: ${getErrorMessage(deployError)}`)
+ info(`Dashboard: ${colors.primary(`${webUrl}/projects/${projectName}/deployments`)}`)
+ return
}
- const envDomains = deployment.environment?.domains || []
- const firstDomain = envDomains[0]
- if (firstDomain) {
- const envUrl = firstDomain.startsWith('http') ? firstDomain : `https://${firstDomain}`
- keyValue('Environment', colors.primary(envUrl))
+
+ const latest = deployList?.deployments?.[0]
+ if (latest?.id) {
+ deploymentId = latest.id
+ break
}
- newline()
- return
+
+ await new Promise((r) => setTimeout(r, 2000))
}
- if (deployment.status === 'failed' || deployment.status === 'error' || deployment.status === 'cancelled') {
- failSpinner('Deployment failed')
- newline()
- if (deployment.cancelled_reason) {
- info(`Reason: ${deployment.cancelled_reason}`)
+ if (deploymentId) {
+ succeedSpinner(`Deployment #${deploymentId} found`)
+ const result = await watchDeployment({
+ projectId: project.id,
+ deploymentId,
+ timeoutSecs: 600,
+ projectName,
+ })
+
+ if (!result.success) {
+ process.exitCode = 1
}
- info(`View logs with: temps logs ${projectId}`)
- return
+ } else {
+ failSpinner('Could not locate the deployment to track')
+ info(`Dashboard: ${colors.primary(`${webUrl}/projects/${projectName}/deployments`)}`)
}
-
- // Wait before checking again
- await new Promise((resolve) => setTimeout(resolve, 2000))
+ } catch (err) {
+ failSpinner('Deployment failed')
+ throw err
}
-
- failSpinner('Deployment timed out')
- info('Deployment is still running. Check status with: temps deployments list')
}
diff --git a/apps/temps-cli/src/commands/deploy/index.ts b/apps/temps-cli/src/commands/deploy/index.ts
index a84bb60e..84b59892 100644
--- a/apps/temps-cli/src/commands/deploy/index.ts
+++ b/apps/temps-cli/src/commands/deploy/index.ts
@@ -18,6 +18,7 @@ export function registerDeployCommands(program: Command): void {
.option('-e, --environment ', 'Target environment name')
.option('--environment-id ', 'Target environment ID')
.option('-b, --branch ', 'Git branch to deploy')
+ .option('-c, --commit ', 'Specific commit SHA to deploy')
.option('--no-wait', 'Do not wait for deployment to complete')
.option('-y, --yes', 'Skip confirmation prompts (for automation)')
.action((projectArg, options) => {
diff --git a/apps/temps-cli/src/commands/deploy/list.ts b/apps/temps-cli/src/commands/deploy/list.ts
index c05ebf23..5157491e 100644
--- a/apps/temps-cli/src/commands/deploy/list.ts
+++ b/apps/temps-cli/src/commands/deploy/list.ts
@@ -1,5 +1,6 @@
-import { requireAuth, config } from '../../config/store.js'
+import { requireAuth } from '../../config/store.js'
import { setupClient, client, getErrorMessage } from '../../lib/api-client.js'
+import { resolveProjectSlug } from '../../config/resolve-project.js'
import { getProjectDeployments, getProjectBySlug } from '../../api/sdk.gen.js'
import type { DeploymentResponse } from '../../api/types.gen.js'
import { withSpinner } from '../../ui/spinner.js'
@@ -20,12 +21,17 @@ export async function list(options: ListOptions): Promise {
await requireAuth()
await setupClient()
- const projectName = options.project ?? config.get('defaultProject')
+ const resolved = await resolveProjectSlug(options.project)
- if (!projectName) {
- throw new Error('No project specified. Use: temps deployments list --project ')
+ if (!resolved) {
+ throw new Error(
+ 'No project specified. Use: bunx @temps-sdk/cli deployments list --project \n' +
+ 'Or link this directory: bunx @temps-sdk/cli link '
+ )
}
+ const projectName = resolved.slug
+
const deployments = await withSpinner('Fetching deployments...', async () => {
// Get project ID from slug
const { data: projectData, error: projectError } = await getProjectBySlug({
diff --git a/apps/temps-cli/src/commands/deploy/rollback.ts b/apps/temps-cli/src/commands/deploy/rollback.ts
index 2c16dc68..6b9b50bc 100644
--- a/apps/temps-cli/src/commands/deploy/rollback.ts
+++ b/apps/temps-cli/src/commands/deploy/rollback.ts
@@ -1,5 +1,6 @@
import { requireAuth } from '../../config/store.js'
import { setupClient, client, getErrorMessage } from '../../lib/api-client.js'
+import { requireProjectSlug } from '../../config/resolve-project.js'
import {
getProjectBySlug,
getProjectDeployments,
@@ -19,22 +20,24 @@ export async function rollback(options: RollbackOptions): Promise {
await requireAuth()
await setupClient()
- if (!options.project) {
- throw new Error('Project is required. Use: temps deployments rollback --project ')
+ const resolved = await requireProjectSlug(options.project)
+
+ if (resolved.source !== 'flag') {
+ info(`Using project ${colors.bold(resolved.slug)} (from ${resolved.source})`)
}
newline()
- warning(`Rolling back ${colors.bold(options.project)} in ${colors.bold(options.environment)}`)
+ warning(`Rolling back ${colors.bold(resolved.slug)} in ${colors.bold(options.environment)}`)
newline()
// Get project ID
const { data: projectData, error: projectError } = await getProjectBySlug({
client,
- path: { slug: options.project },
+ path: { slug: resolved.slug },
})
if (projectError || !projectData) {
- throw new Error(`Project "${options.project}" not found`)
+ throw new Error(`Project "${resolved.slug}" not found`)
}
let targetDeploymentId = options.to ? parseInt(options.to, 10) : undefined
@@ -51,30 +54,36 @@ export async function rollback(options: RollbackOptions): Promise {
throw new Error(getErrorMessage(error))
}
- // Filter by environment and status
+ // Filter by environment and completed status
return data.deployments
.filter(d =>
d.environment?.name === options.environment &&
(d.status === 'success' || d.status === 'completed' || d.status === 'deployed')
)
- .slice(0, 5)
+ .slice(0, 10)
})
- if (deployments.length < 2) {
- warning('No previous deployments to rollback to')
+ if (deployments.length === 0) {
+ warning('No completed deployments found for this environment')
return
}
- // Skip current, show previous deployments
- const previousDeployments = deployments.slice(1)
-
+ // Show all deployments, mark which is current
const selectedId = await promptSelect({
message: 'Select deployment to rollback to',
- choices: previousDeployments.map((d) => ({
- name: `#${d.id} - ${d.branch ?? 'unknown'} (${d.commit_hash?.substring(0, 7) ?? 'unknown'})`,
- value: String(d.id),
- description: new Date(d.created_at * 1000).toLocaleString(),
- })),
+ choices: deployments.map((d) => {
+ const isRollback = d.metadata?.isRollback
+ const branch = d.branch ?? (isRollback ? 'rollback' : 'unknown')
+ const commit = d.commit_hash?.substring(0, 7) ?? (isRollback ? `from #${d.metadata?.rolledBackFromId ?? '?'}` : '-')
+ const currentTag = d.is_current ? ' (current)' : ''
+ const date = new Date(d.created_at * 1000).toLocaleString()
+
+ return {
+ name: `#${d.id} - ${branch} (${commit})${currentTag}`,
+ value: String(d.id),
+ description: date,
+ }
+ }),
})
targetDeploymentId = parseInt(selectedId, 10)
@@ -112,5 +121,5 @@ export async function rollback(options: RollbackOptions): Promise {
keyValue('Status', newDeployment.status)
newline()
- info(`Track progress with: temps deployments status --project ${options.project} --deployment-id ${newDeployment.id}`)
+ info(`Track progress with: temps deployments status --project ${resolved.slug} --deployment-id ${newDeployment.id}`)
}
diff --git a/apps/temps-cli/src/commands/deploy/status.ts b/apps/temps-cli/src/commands/deploy/status.ts
index 8bc9bb2d..4a17600d 100644
--- a/apps/temps-cli/src/commands/deploy/status.ts
+++ b/apps/temps-cli/src/commands/deploy/status.ts
@@ -1,8 +1,9 @@
import { requireAuth } from '../../config/store.js'
import { setupClient, client, getErrorMessage } from '../../lib/api-client.js'
+import { requireProjectSlug } from '../../config/resolve-project.js'
import { getDeployment, getProjectBySlug } from '../../api/sdk.gen.js'
import { withSpinner } from '../../ui/spinner.js'
-import { newline, header, icons, json, colors, formatDate } from '../../ui/output.js'
+import { newline, header, icons, json, colors, info, formatDate } from '../../ui/output.js'
import { detailsTable, statusBadge } from '../../ui/table.js'
interface StatusOptions {
@@ -15,22 +16,24 @@ export async function status(options: StatusOptions): Promise {
await requireAuth()
await setupClient()
- if (!options.project) {
- throw new Error('Project is required. Use: temps deployments status --project --deployment-id ')
+ const resolved = await requireProjectSlug(options.project)
+
+ if (resolved.source !== 'flag') {
+ info(`Using project ${colors.bold(resolved.slug)} (from ${resolved.source})`)
}
if (!options.deploymentId) {
- throw new Error('Deployment ID is required. Use: temps deployments status --project --deployment-id ')
+ throw new Error('Deployment ID is required. Use: temps deployments status --deployment-id ')
}
// Get project ID from slug
const { data: projectData, error: projectError } = await getProjectBySlug({
client,
- path: { slug: options.project },
+ path: { slug: resolved.slug },
})
if (projectError || !projectData) {
- throw new Error(`Project "${options.project}" not found`)
+ throw new Error(`Project "${resolved.slug}" not found`)
}
const projectId = projectData.id
diff --git a/apps/temps-cli/src/commands/domains/index.ts b/apps/temps-cli/src/commands/domains/index.ts
index 8ac329da..4098c5d5 100644
--- a/apps/temps-cli/src/commands/domains/index.ts
+++ b/apps/temps-cli/src/commands/domains/index.ts
@@ -37,6 +37,7 @@ async function findDomainIdByName(domainName: string): Promise {
interface AddOptions {
domain: string
challenge?: string
+ yes?: boolean
}
interface VerifyOptions {
@@ -105,6 +106,7 @@ export function registerDomainsCommands(program: Command): void {
.description('Add a custom domain')
.requiredOption('-d, --domain ', 'Domain name')
.option('-c, --challenge ', 'Challenge type (http-01 or dns-01)', 'http-01')
+ .option('-y, --yes', 'Skip confirmation prompts')
.action(addDomain)
domains
@@ -250,10 +252,11 @@ async function addDomain(options: AddOptions): Promise {
success(`Domain ${domain} added`)
if (result?.dns_challenge_token && result?.dns_challenge_value) {
+ const baseDomain = domain.startsWith('*.') ? domain.slice(2) : domain
newline()
box(
`Type: TXT\n` +
- `Name: ${result.dns_challenge_token}\n` +
+ `Name: _acme-challenge.${baseDomain}\n` +
`Value: ${result.dns_challenge_value}`,
'Add this DNS record to verify ownership'
)
diff --git a/apps/temps-cli/src/commands/environments/index.ts b/apps/temps-cli/src/commands/environments/index.ts
index e2e4d2fe..bb4b0c39 100644
--- a/apps/temps-cli/src/commands/environments/index.ts
+++ b/apps/temps-cli/src/commands/environments/index.ts
@@ -2,7 +2,9 @@ import type { Command } from 'commander'
import * as fs from 'node:fs'
import * as path from 'node:path'
import { requireAuth } from '../../config/store.js'
+import { requireProjectSlug } from '../../config/resolve-project.js'
import { setupClient, client, getErrorMessage } from '../../lib/api-client.js'
+import { parseEnvFile } from '../../lib/env-file.js'
import {
getEnvironments,
getEnvironment,
@@ -32,31 +34,35 @@ export function registerEnvironmentsCommands(program: Command): void {
.description('Manage environments and environment variables')
environments
- .command('list ')
+ .command('list')
.alias('ls')
.description('List environments for a project')
+ .option('-p, --project ', 'Project slug or ID')
.option('--json', 'Output in JSON format')
.action(listEnvironments)
environments
- .command('create ')
+ .command('create')
.description('Create a new environment')
+ .option('-p, --project ', 'Project slug or ID')
.option('-n, --name ', 'Environment name')
.option('-b, --branch ', 'Git branch')
.option('--preview', 'Set as preview environment')
.action(createEnvironmentCmd)
environments
- .command('delete ')
+ .command('delete ')
.alias('rm')
.description('Delete an environment')
+ .option('-p, --project ', 'Project slug or ID')
.option('-f, --force', 'Skip confirmation')
.action(deleteEnvironmentCmd)
// Environment variables subcommand
const vars = environments
- .command('vars ')
+ .command('vars')
.description('Manage environment variables')
+ .option('-p, --project ', 'Project slug or ID')
vars
.command('list')
@@ -65,18 +71,18 @@ export function registerEnvironmentsCommands(program: Command): void {
.option('-e, --environment ', 'Filter by environment name')
.option('--show-values', 'Show actual values (hidden by default)')
.option('--json', 'Output in JSON format')
- .action((options, cmd) => {
- const project = cmd.parent!.args[0]
- return listEnvVars(project, options)
+ .action(async (options, cmd) => {
+ const projectSlug = cmd.parent!.opts().project
+ return listEnvVars(projectSlug, options)
})
vars
.command('get ')
.description('Get a specific environment variable')
.option('-e, --environment ', 'Specify environment (if variable exists in multiple)')
- .action((key, options, cmd) => {
- const project = cmd.parent!.parent!.args[0]
- return getEnvVar(project, key, options)
+ .action(async (key, options, cmd) => {
+ const projectSlug = cmd.parent!.parent!.opts().project
+ return getEnvVar(projectSlug, key, options)
})
vars
@@ -85,9 +91,9 @@ export function registerEnvironmentsCommands(program: Command): void {
.option('-e, --environments ', 'Comma-separated environment names (interactive if not provided)')
.option('--no-preview', 'Exclude from preview environments')
.option('--update', 'Update existing variable instead of creating new')
- .action((key, value, options, cmd) => {
- const project = cmd.parent!.parent!.args[0]
- return setEnvVar(project, key, value, options)
+ .action(async (key, value, options, cmd) => {
+ const projectSlug = cmd.parent!.parent!.opts().project
+ return setEnvVar(projectSlug, key, value, options)
})
vars
@@ -97,9 +103,9 @@ export function registerEnvironmentsCommands(program: Command): void {
.description('Delete an environment variable')
.option('-e, --environment ', 'Delete only from specific environment')
.option('-f, --force', 'Skip confirmation')
- .action((key, options, cmd) => {
- const project = cmd.parent!.parent!.args[0]
- return deleteEnvVar(project, key, options)
+ .action(async (key, options, cmd) => {
+ const projectSlug = cmd.parent!.parent!.opts().project
+ return deleteEnvVar(projectSlug, key, options)
})
vars
@@ -107,9 +113,9 @@ export function registerEnvironmentsCommands(program: Command): void {
.description('Import environment variables from a .env file')
.option('-e, --environments ', 'Comma-separated environment names')
.option('--overwrite', 'Overwrite existing variables')
- .action((file, options, cmd) => {
- const project = cmd.parent!.parent!.args[0]
- return importEnvVars(project, file, options)
+ .action(async (file, options, cmd) => {
+ const projectSlug = cmd.parent!.parent!.opts().project
+ return importEnvVars(projectSlug, file, options)
})
vars
@@ -117,15 +123,16 @@ export function registerEnvironmentsCommands(program: Command): void {
.description('Export environment variables to .env format')
.option('-e, --environment ', 'Export from specific environment')
.option('-o, --output ', 'Write to file instead of stdout')
- .action((options, cmd) => {
- const project = cmd.parent!.parent!.args[0]
- return exportEnvVars(project, options)
+ .action(async (options, cmd) => {
+ const projectSlug = cmd.parent!.parent!.opts().project
+ return exportEnvVars(projectSlug, options)
})
// Resources subcommand
environments
- .command('resources ')
+ .command('resources ')
.description('View or set CPU/memory resources for an environment')
+ .option('-p, --project ', 'Project slug or ID')
.option('--cpu ', 'CPU limit in millicores (e.g., 500 = 0.5 CPU)')
.option('--memory ', 'Memory limit in MB (e.g., 512)')
.option('--cpu-request ', 'CPU request in millicores (guaranteed minimum)')
@@ -135,24 +142,29 @@ export function registerEnvironmentsCommands(program: Command): void {
// Scale subcommand
environments
- .command('scale [replicas]')
+ .command('scale')
.description('View or set the number of replicas for an environment')
+ .option('-p, --project ', 'Project slug or ID')
+ .option('-e, --environment ', 'Environment name or slug', 'production')
+ .option('-r, --replicas ', 'Number of replicas to set')
.option('--json', 'Output in JSON format')
.action(scaleCmd)
// Cron jobs subcommand
const crons = environments
- .command('crons ')
+ .command('crons')
.description('Manage cron jobs')
+ .option('-p, --project ', 'Project slug or ID')
+ .requiredOption('-e, --environment ', 'Environment name or slug')
crons
.command('list')
.alias('ls')
.description('List cron jobs for an environment')
.option('--json', 'Output in JSON format')
- .action((options, cmd) => {
- const [project, environment] = cmd.parent!.args
- return listCrons(project, environment, options)
+ .action(async (options, cmd) => {
+ const parentOpts = cmd.parent!.opts()
+ return listCrons(parentOpts.project, parentOpts.environment, options)
})
crons
@@ -160,9 +172,9 @@ export function registerEnvironmentsCommands(program: Command): void {
.description('Show cron job details')
.requiredOption('--id ', 'Cron job ID')
.option('--json', 'Output in JSON format')
- .action((options, cmd) => {
- const [project, environment] = cmd.parent!.args
- return showCron(project, environment, options)
+ .action(async (options, cmd) => {
+ const parentOpts = cmd.parent!.opts()
+ return showCron(parentOpts.project, parentOpts.environment, options)
})
crons
@@ -173,9 +185,9 @@ export function registerEnvironmentsCommands(program: Command): void {
.option('--page ', 'Page number', '1')
.option('--per-page ', 'Items per page', '20')
.option('--json', 'Output in JSON format')
- .action((options, cmd) => {
- const [project, environment] = cmd.parent!.args
- return listCronExecutions(project, environment, options)
+ .action(async (options, cmd) => {
+ const parentOpts = cmd.parent!.opts()
+ return listCronExecutions(parentOpts.project, parentOpts.environment, options)
})
}
@@ -190,10 +202,17 @@ async function getProjectId(projectSlug: string): Promise {
return data.id
}
-async function listEnvironments(project: string, options: { json?: boolean }): Promise {
+async function listEnvironments(options: { project?: string; json?: boolean }): Promise {
await requireAuth()
await setupClient()
+ const resolved = await requireProjectSlug(options.project)
+ const project = resolved.slug
+
+ if (resolved.source !== 'flag') {
+ info(`Using project ${colors.bold(project)} (from ${resolved.source})`)
+ }
+
const environments = await withSpinner('Fetching environments...', async () => {
const projectId = await getProjectId(project)
const { data, error } = await getEnvironments({
@@ -225,10 +244,15 @@ async function listEnvironments(project: string, options: { json?: boolean }): P
}
async function createEnvironmentCmd(
- project: string,
- options: { name?: string; branch?: string; preview?: boolean }
+ options: { project?: string; name?: string; branch?: string; preview?: boolean }
): Promise {
await requireAuth()
+ await setupClient()
+
+ const resolved = await requireProjectSlug(options.project)
+ if (resolved.source !== 'flag') {
+ info(`Using project ${colors.bold(resolved.slug)} (from ${resolved.source})`)
+ }
const name = options.name ?? await promptText({
message: 'Environment name',
@@ -241,10 +265,8 @@ async function createEnvironmentCmd(
default: name === 'production' ? 'main' : name,
})
- await setupClient()
-
const environment = await withSpinner('Creating environment...', async () => {
- const projectId = await getProjectId(project)
+ const projectId = await getProjectId(resolved.slug)
const { data, error } = await createEnvironment({
client,
path: { project_id: projectId },
@@ -266,11 +288,16 @@ async function createEnvironmentCmd(
}
async function deleteEnvironmentCmd(
- project: string,
environment: string,
- options: { force?: boolean }
+ options: { project?: string; force?: boolean }
): Promise {
await requireAuth()
+ await setupClient()
+
+ const resolved = await requireProjectSlug(options.project)
+ if (resolved.source !== 'flag') {
+ info(`Using project ${colors.bold(resolved.slug)} (from ${resolved.source})`)
+ }
if (environment === 'production') {
warning('Cannot delete production environment')
@@ -279,7 +306,7 @@ async function deleteEnvironmentCmd(
if (!options.force) {
const confirmed = await promptConfirm({
- message: `Delete environment "${environment}" from ${project}?`,
+ message: `Delete environment "${environment}" from ${resolved.slug}?`,
default: false,
})
if (!confirmed) {
@@ -288,10 +315,8 @@ async function deleteEnvironmentCmd(
}
}
- await setupClient()
-
await withSpinner('Deleting environment...', async () => {
- const projectId = await getProjectId(project)
+ const projectId = await getProjectId(resolved.slug)
const { error } = await deleteEnvironment({
client,
path: { project_id: projectId, env_id: environment as unknown as number },
@@ -305,12 +330,18 @@ async function deleteEnvironmentCmd(
// ============ Environment Variables Commands ============
async function listEnvVars(
- project: string,
+ projectFlag: string | undefined,
options: { environment?: string; showValues?: boolean; json?: boolean }
): Promise {
await requireAuth()
await setupClient()
+ const resolved = await requireProjectSlug(projectFlag)
+ const project = resolved.slug
+ if (resolved.source !== 'flag') {
+ info(`Using project ${colors.bold(project)} (from ${resolved.source})`)
+ }
+
const [vars, environments] = await withSpinner('Fetching environment variables...', async () => {
const projectId = await getProjectId(project)
@@ -396,13 +427,19 @@ async function listEnvVars(
}
async function getEnvVar(
- project: string,
+ projectFlag: string | undefined,
key: string,
options: { environment?: string }
): Promise {
await requireAuth()
await setupClient()
+ const resolved = await requireProjectSlug(projectFlag)
+ const project = resolved.slug
+ if (resolved.source !== 'flag') {
+ info(`Using project ${colors.bold(project)} (from ${resolved.source})`)
+ }
+
const [vars, environments] = await withSpinner(`Fetching ${key}...`, async () => {
const projectId = await getProjectId(project)
@@ -473,7 +510,7 @@ async function getEnvVar(
}
async function setEnvVar(
- project: string,
+ projectFlag: string | undefined,
key: string,
value: string | undefined,
options: { environments?: string; preview?: boolean; update?: boolean }
@@ -481,6 +518,12 @@ async function setEnvVar(
await requireAuth()
await setupClient()
+ const resolved = await requireProjectSlug(projectFlag)
+ const project = resolved.slug
+ if (resolved.source !== 'flag') {
+ info(`Using project ${colors.bold(project)} (from ${resolved.source})`)
+ }
+
// Get environments first
const [existingVars, envs] = await withSpinner('Fetching environments...', async () => {
const projectId = await getProjectId(project)
@@ -1087,15 +1130,19 @@ function displayResources(env: EnvironmentResponse | null | undefined): void {
// ============ Scale Command ============
async function scaleCmd(
- project: string,
- environment: string,
- replicas: string | undefined,
- options: { json?: boolean }
+ options: { project?: string; environment: string; replicas?: string; json?: boolean }
): Promise {
await requireAuth()
await setupClient()
- const projectId = await getProjectId(project)
+ const resolved = await requireProjectSlug(options.project)
+
+ if (resolved.source !== 'flag') {
+ info(`Using project ${colors.bold(resolved.slug)} (from ${resolved.source})`)
+ }
+
+ const environment = options.environment
+ const projectId = await getProjectId(resolved.slug)
// Find environment by slug
const envs = await withSpinner('Fetching environments...', async () => {
@@ -1117,9 +1164,9 @@ async function scaleCmd(
return
}
- if (replicas !== undefined) {
+ if (options.replicas !== undefined) {
// Set replicas
- const replicaCount = parseInt(replicas, 10)
+ const replicaCount = parseInt(options.replicas, 10)
if (isNaN(replicaCount) || replicaCount < 0) {
errorOutput('Replicas must be a non-negative number')
return
@@ -1148,7 +1195,7 @@ async function scaleCmd(
}
newline()
- success(`Scaled ${project}/${environment} to ${replicaCount} replica${replicaCount !== 1 ? 's' : ''}`)
+ success(`Scaled ${resolved.slug}/${environment} to ${replicaCount} replica${replicaCount !== 1 ? 's' : ''}`)
newline()
info(`Note: Scaling takes effect on the next deployment or restart`)
} else {
@@ -1164,12 +1211,12 @@ async function scaleCmd(
}
newline()
- header(`${icons.folder} Scale for ${project}/${environment}`)
+ header(`${icons.folder} Scale for ${resolved.slug}/${environment}`)
newline()
keyValue('Current Replicas', String(currentReplicas))
newline()
- info(`To scale: ${colors.muted(`temps env scale ${project} ${environment} `)}`)
- info(`Example: ${colors.muted(`temps env scale ${project} ${environment} 3`)}`)
+ info(`To scale: ${colors.muted(`bunx @temps-sdk/cli env scale -p ${resolved.slug} -e ${environment} --replicas `)}`)
+ info(`Example: ${colors.muted(`bunx @temps-sdk/cli env scale -p ${resolved.slug} -e ${environment} --replicas 3`)}`)
}
}
@@ -1366,35 +1413,4 @@ async function listCronExecutions(
}
// Helper function to parse .env file content
-function parseEnvFile(content: string): Record {
- const variables: Record = {}
-
- for (const line of content.split('\n')) {
- const trimmed = line.trim()
-
- // Skip empty lines and comments
- if (!trimmed || trimmed.startsWith('#')) continue
-
- // Parse KEY=VALUE
- const match = trimmed.match(/^([^=]+)=(.*)$/)
- if (!match) continue
-
- const [, key, rawValue] = match
- if (!key || rawValue === undefined) continue
-
- let value = rawValue.trim()
-
- // Handle quoted values
- if ((value.startsWith('"') && value.endsWith('"')) ||
- (value.startsWith("'") && value.endsWith("'"))) {
- value = value.slice(1, -1)
- .replace(/\\n/g, '\n')
- .replace(/\\"/g, '"')
- .replace(/\\'/g, "'")
- }
-
- variables[key.trim()] = value
- }
-
- return variables
-}
+// parseEnvFile is now imported from '../../lib/env-file.js'
diff --git a/apps/temps-cli/src/commands/open/index.ts b/apps/temps-cli/src/commands/open/index.ts
index dd670696..c8d031be 100644
--- a/apps/temps-cli/src/commands/open/index.ts
+++ b/apps/temps-cli/src/commands/open/index.ts
@@ -1,9 +1,8 @@
import type { Command } from 'commander'
import { execSync } from 'node:child_process'
import { requireAuth } from '../../config/store.js'
-import { setupClient, client, getErrorMessage } from '../../lib/api-client.js'
+import { setupClient, client, getWebUrl, getErrorMessage } from '../../lib/api-client.js'
import { requireProjectSlug } from '../../config/resolve-project.js'
-import { config } from '../../config/store.js'
import { getProjectBySlug, getEnvironments } from '../../api/sdk.gen.js'
import { withSpinner } from '../../ui/spinner.js'
import { promptSelect } from '../../ui/prompts.js'
@@ -50,8 +49,8 @@ async function open(projectArg: string | undefined, options: OpenOptions): Promi
// If --dashboard, open the web dashboard
if (options.dashboard) {
- const apiUrl = config.get('apiUrl')
- const dashboardUrl = `${apiUrl}/dashboard/projects/${resolved.slug}`
+ const webUrl = getWebUrl()
+ const dashboardUrl = `${webUrl}/projects/${resolved.slug}`
success(`Opening dashboard for ${resolved.slug}`)
openUrl(dashboardUrl)
return
diff --git a/apps/temps-cli/src/commands/projects/create.ts b/apps/temps-cli/src/commands/projects/create.ts
index bc896794..2b343fcf 100644
--- a/apps/temps-cli/src/commands/projects/create.ts
+++ b/apps/temps-cli/src/commands/projects/create.ts
@@ -1,5 +1,5 @@
import { requireAuth, config } from '../../config/store.js'
-import { promptText, promptConfirm, type SelectOption } from '../../ui/prompts.js'
+import { promptText, promptConfirm, promptSelect, type SelectOption } from '../../ui/prompts.js'
import { withSpinner } from '../../ui/spinner.js'
import {
success,
@@ -10,10 +10,12 @@ import {
keyValue,
header,
info,
+ warning,
} from '../../ui/output.js'
import { setupClient, client, getErrorMessage } from '../../lib/api-client.js'
import { createProject } from '../../api/sdk.gen.js'
import type { RepositoryResponse } from '../../api/types.gen.js'
+import { readEnvFile, findEnvFiles } from '../../lib/env-file.js'
// Shared utilities (extracted to avoid duplication with setup wizard)
import {
@@ -183,32 +185,123 @@ async function configureEnvironmentVariables(): Promise<[string, string][]> {
const envVars: [string, string][] = []
- let addMore = true
- while (addMore) {
- newline()
- const key = await promptText({
- message: 'Variable name (e.g., DATABASE_URL)',
- required: true,
- validate: (v) => {
- if (!v) return 'Variable name is required'
- if (!/^[A-Z_][A-Z0-9_]*$/i.test(v)) {
- return 'Variable name must start with a letter or underscore and contain only letters, numbers, and underscores'
- }
- return true
- },
- })
+ // Check for .env files in the current directory
+ const envFiles = findEnvFiles()
- const value = await promptText({
- message: `Value for ${key}`,
- required: true,
+ // Build method choices
+ const methodChoices: SelectOption[] = []
+
+ if (envFiles.length > 0) {
+ methodChoices.push({
+ name: `Import from file (${envFiles.join(', ')} found)`,
+ value: 'file',
+ description: 'Load variables from a .env file',
})
+ }
+
+ methodChoices.push(
+ {
+ name: 'Enter manually',
+ value: 'manual',
+ description: 'Type key-value pairs one by one',
+ },
+ {
+ name: 'Specify file path',
+ value: 'path',
+ description: 'Provide a custom path to a .env file',
+ },
+ )
+
+ const method = methodChoices.length === 1
+ ? 'manual'
+ : await promptSelect({ message: 'How to add variables?', choices: methodChoices })
+
+ if (method === 'file' || method === 'path') {
+ let filePath: string
+
+ if (method === 'file') {
+ if (envFiles.length === 1) {
+ filePath = envFiles[0]!
+ } else {
+ filePath = await promptSelect({
+ message: 'Select .env file',
+ choices: envFiles.map((f) => ({ name: f, value: f })),
+ })
+ }
+ } else {
+ filePath = await promptText({
+ message: 'Path to .env file',
+ default: '.env',
+ required: true,
+ })
+ }
+
+ const parsed = readEnvFile(filePath)
+
+ if (!parsed || Object.keys(parsed).length === 0) {
+ warning(`No variables found in ${filePath}`)
+ } else {
+ const entries = Object.entries(parsed)
+ newline()
+ info(`Found ${entries.length} variable(s) in ${colors.bold(filePath)}:`)
+ newline()
- envVars.push([key, value])
+ for (const [key, value] of entries) {
+ const masked = value.length > 30 ? `${value.substring(0, 30)}...` : value
+ keyValue(` ${key}`, colors.muted(masked))
+ }
+
+ newline()
+ const confirm = await promptConfirm({
+ message: `Import ${entries.length} variable(s)?`,
+ default: true,
+ })
- addMore = await promptConfirm({
- message: 'Add another environment variable?',
+ if (confirm) {
+ for (const [key, value] of entries) {
+ envVars.push([key, value])
+ }
+ success(`Imported ${entries.length} variable(s) from ${filePath}`)
+ }
+ }
+ }
+
+ // Manual entry (either as primary method or to add more after file import)
+ if (method === 'manual' || envVars.length > 0) {
+ const shouldAddManual = method === 'manual' || await promptConfirm({
+ message: 'Add more variables manually?',
default: false,
})
+
+ if (shouldAddManual) {
+ let addMore = true
+ while (addMore) {
+ newline()
+ const key = await promptText({
+ message: 'Variable name (e.g., DATABASE_URL)',
+ required: true,
+ validate: (v) => {
+ if (!v) return 'Variable name is required'
+ if (!/^[A-Z_][A-Z0-9_]*$/i.test(v)) {
+ return 'Variable name must start with a letter or underscore and contain only letters, numbers, and underscores'
+ }
+ return true
+ },
+ })
+
+ const value = await promptText({
+ message: `Value for ${key}`,
+ required: true,
+ })
+
+ envVars.push([key, value])
+
+ addMore = await promptConfirm({
+ message: 'Add another variable?',
+ default: false,
+ })
+ }
+ }
}
return envVars
diff --git a/apps/temps-cli/src/commands/projects/delete.ts b/apps/temps-cli/src/commands/projects/delete.ts
index af1a0669..62329bb4 100644
--- a/apps/temps-cli/src/commands/projects/delete.ts
+++ b/apps/temps-cli/src/commands/projects/delete.ts
@@ -1,12 +1,13 @@
import { requireAuth, config } from '../../config/store.js'
+import { requireProjectSlug } from '../../config/resolve-project.js'
import { promptConfirm } from '../../ui/prompts.js'
import { withSpinner } from '../../ui/spinner.js'
-import { success, warning, newline, colors } from '../../ui/output.js'
+import { success, warning, newline, colors, info } from '../../ui/output.js'
import { setupClient, client, getErrorMessage } from '../../lib/api-client.js'
import { deleteProject, getProjectBySlug } from '../../api/sdk.gen.js'
interface DeleteOptions {
- project: string
+ project?: string
force?: boolean
yes?: boolean
}
@@ -15,7 +16,13 @@ export async function remove(options: DeleteOptions): Promise {
await requireAuth()
await setupClient()
- const projectIdOrName = options.project
+ const resolved = await requireProjectSlug(options.project)
+
+ if (resolved.source !== 'flag') {
+ info(`Using project ${colors.bold(resolved.slug)} (from ${resolved.source})`)
+ }
+
+ const projectIdOrName = resolved.slug
newline()
diff --git a/apps/temps-cli/src/commands/projects/index.ts b/apps/temps-cli/src/commands/projects/index.ts
index fe4a2303..6814b589 100644
--- a/apps/temps-cli/src/commands/projects/index.ts
+++ b/apps/temps-cli/src/commands/projects/index.ts
@@ -34,7 +34,7 @@ export function registerProjectsCommands(program: Command): void {
.command('show')
.alias('get')
.description('Show project details')
- .requiredOption('-p, --project ', 'Project slug or ID')
+ .option('-p, --project ', 'Project slug or ID')
.option('--json', 'Output in JSON format')
.action(show)
@@ -42,7 +42,7 @@ export function registerProjectsCommands(program: Command): void {
.command('update')
.alias('edit')
.description('Update project name and description')
- .requiredOption('-p, --project ', 'Project slug or ID')
+ .option('-p, --project ', 'Project slug or ID')
.option('-n, --name ', 'New project name')
.option('-d, --description ', 'New project description')
.option('--json', 'Output in JSON format')
@@ -52,7 +52,7 @@ export function registerProjectsCommands(program: Command): void {
projects
.command('settings')
.description('Update project settings (slug, attack mode, preview environments)')
- .requiredOption('-p, --project ', 'Project slug or ID')
+ .option('-p, --project ', 'Project slug or ID')
.option('--slug ', 'Project URL slug')
.option('--attack-mode', 'Enable attack mode (CAPTCHA protection)')
.option('--no-attack-mode', 'Disable attack mode')
@@ -65,7 +65,7 @@ export function registerProjectsCommands(program: Command): void {
projects
.command('git')
.description('Update git repository settings')
- .requiredOption('-p, --project ', 'Project slug or ID')
+ .option('-p, --project ', 'Project slug or ID')
.option('--owner ', 'Repository owner')
.option('--repo ', 'Repository name')
.option('--branch ', 'Main branch')
@@ -78,7 +78,7 @@ export function registerProjectsCommands(program: Command): void {
projects
.command('config')
.description('Update deployment configuration (resources, replicas)')
- .requiredOption('-p, --project ', 'Project slug or ID')
+ .option('-p, --project ', 'Project slug or ID')
.option('--replicas ', 'Number of container replicas')
.option('--cpu-limit ', 'CPU limit in cores (e.g., 0.5, 1, 2)')
.option('--memory-limit ', 'Memory limit in MB')
@@ -92,7 +92,7 @@ export function registerProjectsCommands(program: Command): void {
.command('delete')
.alias('rm')
.description('Delete a project')
- .requiredOption('-p, --project ', 'Project slug or ID')
+ .option('-p, --project ', 'Project slug or ID')
.option('-f, --force', 'Skip confirmation')
.option('-y, --yes', 'Skip confirmation (alias for --force)')
.action(remove)
diff --git a/apps/temps-cli/src/commands/projects/show.ts b/apps/temps-cli/src/commands/projects/show.ts
index e55931a5..27403ca6 100644
--- a/apps/temps-cli/src/commands/projects/show.ts
+++ b/apps/temps-cli/src/commands/projects/show.ts
@@ -1,12 +1,13 @@
import { requireAuth } from '../../config/store.js'
+import { requireProjectSlug } from '../../config/resolve-project.js'
import { withSpinner } from '../../ui/spinner.js'
-import { newline, header, icons, json, keyValue, formatDate } from '../../ui/output.js'
+import { newline, header, icons, json, keyValue, info, colors, formatDate } from '../../ui/output.js'
import { detailsTable, statusBadge } from '../../ui/table.js'
import { setupClient, client, getErrorMessage } from '../../lib/api-client.js'
import { getProject, getProjectBySlug } from '../../api/sdk.gen.js'
interface ShowOptions {
- project: string
+ project?: string
json?: boolean
}
@@ -14,7 +15,13 @@ export async function show(options: ShowOptions): Promise {
await requireAuth()
await setupClient()
- const projectIdOrName = options.project
+ const resolved = await requireProjectSlug(options.project)
+
+ if (resolved.source !== 'flag') {
+ info(`Using project ${colors.bold(resolved.slug)} (from ${resolved.source})`)
+ }
+
+ const projectIdOrName = resolved.slug
const project = await withSpinner('Fetching project...', async () => {
// Try to parse as ID first
diff --git a/apps/temps-cli/src/commands/projects/update.ts b/apps/temps-cli/src/commands/projects/update.ts
index c1fc6e72..358ad62c 100644
--- a/apps/temps-cli/src/commands/projects/update.ts
+++ b/apps/temps-cli/src/commands/projects/update.ts
@@ -1,4 +1,5 @@
import { requireAuth } from '../../config/store.js'
+import { requireProjectSlug } from '../../config/resolve-project.js'
import { setupClient, client, getErrorMessage } from '../../lib/api-client.js'
import {
getProject,
@@ -13,12 +14,18 @@ import { promptText, promptConfirm, promptSelect } from '../../ui/prompts.js'
import { newline, header, icons, json, colors, success, info, warning, keyValue } from '../../ui/output.js'
export async function updateProjectAction(
- options: { project: string; name?: string; json?: boolean; yes?: boolean }
+ options: { project?: string; name?: string; json?: boolean; yes?: boolean }
): Promise {
await requireAuth()
await setupClient()
- const projectIdOrSlug = options.project
+ const resolved = await requireProjectSlug(options.project)
+
+ if (resolved.source !== 'flag') {
+ info(`Using project ${colors.bold(resolved.slug)} (from ${resolved.source})`)
+ }
+
+ const projectIdOrSlug = resolved.slug
// Get project first
const project = await withSpinner('Fetching project...', async () => {
@@ -88,7 +95,7 @@ export async function updateProjectAction(
export async function updateSettingsAction(
options: {
- project: string
+ project?: string
slug?: string
attackMode?: boolean
previewEnvs?: boolean
@@ -99,7 +106,13 @@ export async function updateSettingsAction(
await requireAuth()
await setupClient()
- const projectIdOrSlug = options.project
+ const resolved = await requireProjectSlug(options.project)
+
+ if (resolved.source !== 'flag') {
+ info(`Using project ${colors.bold(resolved.slug)} (from ${resolved.source})`)
+ }
+
+ const projectIdOrSlug = resolved.slug
// Get project first
const project = await withSpinner('Fetching project...', async () => {
@@ -184,7 +197,7 @@ export async function updateSettingsAction(
export async function updateGitAction(
options: {
- project: string
+ project?: string
owner?: string
repo?: string
branch?: string
@@ -197,7 +210,13 @@ export async function updateGitAction(
await requireAuth()
await setupClient()
- const projectIdOrSlug = options.project
+ const resolved = await requireProjectSlug(options.project)
+
+ if (resolved.source !== 'flag') {
+ info(`Using project ${colors.bold(resolved.slug)} (from ${resolved.source})`)
+ }
+
+ const projectIdOrSlug = resolved.slug
// Get project first
const project = await withSpinner('Fetching project...', async () => {
@@ -320,7 +339,7 @@ export async function updateGitAction(
export async function updateConfigAction(
options: {
- project: string
+ project?: string
replicas?: string
cpuLimit?: string
memoryLimit?: string
@@ -332,7 +351,13 @@ export async function updateConfigAction(
await requireAuth()
await setupClient()
- const projectIdOrSlug = options.project
+ const resolved = await requireProjectSlug(options.project)
+
+ if (resolved.source !== 'flag') {
+ info(`Using project ${colors.bold(resolved.slug)} (from ${resolved.source})`)
+ }
+
+ const projectIdOrSlug = resolved.slug
// Get project first
const project = await withSpinner('Fetching project...', async () => {
diff --git a/apps/temps-cli/src/commands/runtime-logs.ts b/apps/temps-cli/src/commands/runtime-logs.ts
index ea327290..821312df 100644
--- a/apps/temps-cli/src/commands/runtime-logs.ts
+++ b/apps/temps-cli/src/commands/runtime-logs.ts
@@ -12,18 +12,20 @@ interface RuntimeLogsOptions {
container?: string
tail: string
timestamps?: boolean
+ follow?: boolean
}
export function registerRuntimeLogsCommand(program: Command): void {
program
.command('runtime-logs')
.alias('rlogs')
- .description('Stream runtime container logs (not build logs)')
+ .description('View runtime container logs (use -f to follow in real-time)')
.option('-p, --project ', 'Project slug or ID')
.option('-e, --environment ', 'Environment name', 'production')
.option('-c, --container ', 'Container ID (partial match supported)')
.option('-n, --tail ', 'Number of lines to tail', '1000')
.option('-t, --timestamps', 'Show timestamps')
+ .option('-f, --follow', 'Follow log output (stream in real-time)')
.action(runtimeLogs)
}
@@ -131,60 +133,71 @@ async function runtimeLogs(options: RuntimeLogsOptions): Promise {
// Remove protocol and any trailing slash, keep the path (e.g., /api)
const urlWithoutProtocol = apiUrl.replace(/^https?:\/\//, '').replace(/\/$/, '')
+ const follow = options.follow ?? false
const params = new URLSearchParams()
params.append('tail', options.tail)
params.append('timestamps', String(options.timestamps ?? false))
+ params.append('follow', String(follow))
const wsUrl = `${wsProtocol}://${urlWithoutProtocol}/projects/${projectData.id}/environments/${environment.id}/containers/${selectedContainer.container_id}/logs?${params.toString()}`
- info(`Connecting to WebSocket...`)
- info(`URL: ${colors.muted(wsUrl)}`)
+ if (follow) {
+ info(`Streaming logs (follow mode)...`)
+ } else {
+ info(`Fetching logs...`)
+ }
newline()
// Connect via WebSocket
- await connectWebSocket(wsUrl, apiKey)
+ await connectWebSocket(wsUrl, apiKey, follow)
}
-async function connectWebSocket(url: string, apiKey: string): Promise {
- return new Promise((resolve, reject) => {
+function formatLogMessage(raw: string): void {
+ // Docker log lines include trailing newlines; strip them so
+ // console.log doesn't produce double-spaced output.
+ const data = raw.replace(/\r?\n$/, '')
+
+ // Try to parse as JSON for structured logs
+ try {
+ const parsed = JSON.parse(data)
+ if (parsed.error) {
+ console.log(colors.error(`ERROR: ${parsed.error}`))
+ if (parsed.detail) {
+ console.log(colors.muted(` ${parsed.detail}`))
+ }
+ } else if (parsed.message) {
+ console.log(parsed.message.replace(/\r?\n$/, ''))
+ } else {
+ console.log(data)
+ }
+ } catch {
+ // Plain text log line
+ console.log(data)
+ }
+}
+
+async function connectWebSocket(url: string, apiKey: string, follow: boolean): Promise {
+ return new Promise((resolve) => {
const ws = new WebSocket(url, {
headers: {
'Authorization': `Bearer ${apiKey}`,
},
} as any)
+ let sigintHandler: (() => void) | null = null
+
ws.onopen = () => {
- console.log(colors.success('✓ Connected to log stream'))
- console.log(colors.muted('─'.repeat(60)))
- console.log(colors.muted('Press Ctrl+C to stop'))
- console.log(colors.muted('─'.repeat(60)))
- console.log()
+ if (follow) {
+ console.log(colors.success('✓ Connected to log stream'))
+ console.log(colors.muted('─'.repeat(60)))
+ console.log(colors.muted('Press Ctrl+C to stop'))
+ console.log(colors.muted('─'.repeat(60)))
+ console.log()
+ }
}
ws.onmessage = (event) => {
- const raw = event.data.toString()
-
- // Docker log lines include trailing newlines; strip them so
- // console.log doesn't produce double-spaced output.
- const data = raw.replace(/\r?\n$/, '')
-
- // Try to parse as JSON for structured logs
- try {
- const parsed = JSON.parse(data)
- if (parsed.error) {
- console.log(colors.error(`ERROR: ${parsed.error}`))
- if (parsed.detail) {
- console.log(colors.muted(` ${parsed.detail}`))
- }
- } else if (parsed.message) {
- console.log(parsed.message.replace(/\r?\n$/, ''))
- } else {
- console.log(data)
- }
- } catch {
- // Plain text log line
- console.log(data)
- }
+ formatLogMessage(event.data.toString())
}
ws.onerror = (error) => {
@@ -192,24 +205,40 @@ async function connectWebSocket(url: string, apiKey: string): Promise {
}
ws.onclose = (event) => {
- console.log()
- console.log(colors.muted('─'.repeat(60)))
- if (event.code === 1000) {
- console.log(colors.info('Connection closed normally'))
- } else {
- console.log(colors.warning(`Connection closed (code: ${event.code})`))
- if (event.reason) {
- console.log(colors.muted(`Reason: ${event.reason}`))
+ // Clean up the SIGINT handler
+ if (sigintHandler) {
+ process.removeListener('SIGINT', sigintHandler)
+ }
+
+ if (follow) {
+ console.log()
+ console.log(colors.muted('─'.repeat(60)))
+ if (event.code === 1000) {
+ console.log(colors.info('Connection closed normally'))
+ } else {
+ console.log(colors.warning(`Connection closed (code: ${event.code})`))
+ if (event.reason) {
+ console.log(colors.muted(`Reason: ${event.reason}`))
+ }
}
}
resolve()
}
- // Handle Ctrl+C gracefully
- process.on('SIGINT', () => {
+ // Handle Ctrl+C gracefully (only relevant for follow mode, but register always)
+ sigintHandler = () => {
console.log()
console.log(colors.muted('Closing connection...'))
- ws.close(1000, 'User requested close')
- })
+ try {
+ ws.close(1000, 'User requested close')
+ } catch {
+ // WebSocket may already be closed
+ }
+ // Force exit after a short delay in case ws.close doesn't trigger onclose
+ setTimeout(() => {
+ process.exit(0)
+ }, 500)
+ }
+ process.on('SIGINT', sigintHandler)
})
}
diff --git a/apps/temps-cli/src/commands/services/index.ts b/apps/temps-cli/src/commands/services/index.ts
index cbc06868..de8f28f2 100644
--- a/apps/temps-cli/src/commands/services/index.ts
+++ b/apps/temps-cli/src/commands/services/index.ts
@@ -18,8 +18,10 @@ import {
unlinkServiceFromProject,
getServiceEnvironmentVariables,
getServiceEnvironmentVariable,
+ getProjectBySlug,
} from '../../api/sdk.gen.js'
import type { ExternalServiceInfo, ServiceTypeRoute } from '../../api/types.gen.js'
+import { requireProjectSlug } from '../../config/resolve-project.js'
import { withSpinner } from '../../ui/spinner.js'
import { printTable, statusBadge, type TableColumn } from '../../ui/table.js'
import { promptText, promptSelect, promptConfirm } from '../../ui/prompts.js'
@@ -32,10 +34,94 @@ const SERVICE_TYPE_LABELS: Record = {
s3: 'MinIO (S3)',
}
+// Default parameters for each service type when using automation mode (-y)
+// These match the backend's required fields + sensible defaults
+const SERVICE_TYPE_DEFAULTS: Record> = {
+ postgres: { database: 'myapp', username: 'postgres' },
+ mongodb: { database: 'myapp', username: 'mongoadmin' },
+ redis: {},
+ s3: {},
+}
+
+// JSON Schema → interactive prompt parameters
+interface SchemaProperty {
+ type?: string
+ description?: string
+ default?: unknown
+ example?: unknown
+ enum?: string[]
+}
+
+interface JsonSchema {
+ type?: string
+ title?: string
+ properties?: Record
+ required?: string[]
+ readonly?: string[]
+}
+
+interface PromptParam {
+ name: string
+ label: string
+ description?: string
+ default_value?: unknown
+ required: boolean
+ readonly: boolean
+ enum_values?: string[]
+ param_type: string
+}
+
+function schemaToPromptParams(schema: JsonSchema): PromptParam[] {
+ if (!schema?.properties) return []
+ const required = new Set(schema.required ?? [])
+ const readonly = new Set(schema.readonly ?? [])
+ return Object.entries(schema.properties).map(([name, prop]) => ({
+ name,
+ label: name.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()),
+ description: prop.description,
+ default_value: prop.default ?? prop.example,
+ required: required.has(name),
+ readonly: readonly.has(name),
+ enum_values: prop.enum,
+ param_type: prop.type ?? 'string',
+ }))
+}
+
+/**
+ * Parse repeatable --set key=value pairs into a Record.
+ * Supports type coercion: numbers → number, true/false → boolean, rest → string.
+ */
+function parseSetPairs(pairs: string[]): Record {
+ const result: Record = {}
+ for (const pair of pairs) {
+ const eqIdx = pair.indexOf('=')
+ if (eqIdx === -1) {
+ throw new Error(`Invalid parameter "${pair}". Expected format: key=value`)
+ }
+ const key = pair.slice(0, eqIdx).trim()
+ const raw = pair.slice(eqIdx + 1)
+ if (!key) {
+ throw new Error(`Invalid parameter "${pair}". Key cannot be empty`)
+ }
+ // Type coercion
+ if (raw === 'true') result[key] = true
+ else if (raw === 'false') result[key] = false
+ else if (raw === '0') result[key] = 0
+ else if (raw !== '' && !isNaN(Number(raw)) && !raw.startsWith('0')) result[key] = Number(raw)
+ else result[key] = raw
+ }
+ return result
+}
+
+/** Collect repeatable --set values into an array */
+function collectSet(value: string, previous: string[]): string[] {
+ return previous.concat([value])
+}
+
interface CreateOptions {
type?: string
name?: string
- parameters?: string
+ set?: string[]
yes?: boolean
}
@@ -62,7 +148,7 @@ interface ProjectsOptions {
interface UpdateOptions {
id: string
name?: string
- parameters?: string
+ set?: string[]
}
interface UpgradeOptions {
@@ -74,36 +160,52 @@ interface ImportOptions {
type?: string
name?: string
containerId?: string
- parameters?: string
+ set?: string[]
version?: string
yes?: boolean
}
interface LinkOptions {
id: string
- projectId: string
+ project?: string
}
interface UnlinkOptions {
id: string
- projectId: string
+ project?: string
force?: boolean
yes?: boolean
}
interface EnvOptions {
id: string
- projectId: string
+ project?: string
json?: boolean
}
interface EnvVarOptions {
id: string
- projectId: string
+ project?: string
var: string
json?: boolean
}
+/** Resolve project slug (from flag, .temps/config.json, env, global) → project ID */
+async function resolveProjectId(flagValue?: string): Promise<{ id: number; slug: string }> {
+ const resolved = await requireProjectSlug(flagValue)
+ if (resolved.source !== 'flag') {
+ info(`Using project ${colors.bold(resolved.slug)} (from ${resolved.source})`)
+ }
+ const { data, error } = await getProjectBySlug({
+ client,
+ path: { slug: resolved.slug },
+ })
+ if (error || !data) {
+ throw new Error(`Project "${resolved.slug}" not found`)
+ }
+ return { id: data.id, slug: resolved.slug }
+}
+
export function registerServicesCommands(program: Command): void {
const services = program
.command('services')
@@ -123,7 +225,7 @@ export function registerServicesCommands(program: Command): void {
.description('Create a new external service')
.option('-t, --type ', 'Service type (postgres, mongodb, redis, s3)')
.option('-n, --name ', 'Service name')
- .option('--parameters ', 'Service parameters as JSON string')
+ .option('-s, --set ', 'Set a parameter (repeatable)', collectSet, [])
.option('-y, --yes', 'Skip confirmation prompts (for automation)')
.action(createServiceAction)
@@ -155,12 +257,18 @@ export function registerServicesCommands(program: Command): void {
.requiredOption('--id ', 'Service ID')
.action(stopServiceAction)
- services
+ const typesCmd = services
.command('types')
.description('List available service types')
.option('--json', 'Output in JSON format')
.action(listServiceTypes)
+ typesCmd
+ .command('info ')
+ .description('Show parameters schema for a service type (useful for automation)')
+ .option('--json', 'Output as raw JSON schema (default)')
+ .action(showServiceTypeInfo)
+
services
.command('projects')
.description('List projects linked to a service')
@@ -173,7 +281,7 @@ export function registerServicesCommands(program: Command): void {
.description('Update a service')
.requiredOption('--id ', 'Service ID')
.option('-n, --name ', 'Docker image name (e.g., postgres:18-alpine)')
- .option('--parameters ', 'Service parameters as JSON string')
+ .option('-s, --set ', 'Set a parameter (repeatable)', collectSet, [])
.action(updateServiceAction)
services
@@ -189,7 +297,7 @@ export function registerServicesCommands(program: Command): void {
.option('-t, --type ', 'Service type (postgres, mongodb, redis, s3)')
.option('-n, --name ', 'Service name')
.option('--container-id ', 'Container ID or name to import')
- .option('--parameters ', 'Service parameters as JSON string')
+ .option('-s, --set ', 'Set a parameter (repeatable)', collectSet, [])
.option('--version ', 'Optional version override')
.option('-y, --yes', 'Skip confirmation prompts (for automation)')
.action(importServiceAction)
@@ -198,14 +306,14 @@ export function registerServicesCommands(program: Command): void {
.command('link')
.description('Link a service to a project')
.requiredOption('--id ', 'Service ID')
- .requiredOption('--project-id ', 'Project ID')
+ .option('-p, --project ', 'Project slug (auto-detected from .temps/config.json)')
.action(linkServiceAction)
services
.command('unlink')
.description('Unlink a service from a project')
.requiredOption('--id ', 'Service ID')
- .requiredOption('--project-id ', 'Project ID')
+ .option('-p, --project ', 'Project slug (auto-detected from .temps/config.json)')
.option('-f, --force', 'Skip confirmation')
.option('-y, --yes', 'Skip confirmation prompts (alias for --force)')
.action(unlinkServiceAction)
@@ -214,7 +322,7 @@ export function registerServicesCommands(program: Command): void {
.command('env')
.description('Show environment variables for a linked service')
.requiredOption('--id ', 'Service ID')
- .requiredOption('--project-id ', 'Project ID')
+ .option('-p, --project ', 'Project slug (auto-detected from .temps/config.json)')
.option('--json', 'Output in JSON format')
.action(envAction)
@@ -222,7 +330,7 @@ export function registerServicesCommands(program: Command): void {
.command('env-var')
.description('Get a specific environment variable')
.requiredOption('--id ', 'Service ID')
- .requiredOption('--project-id ', 'Project ID')
+ .option('-p, --project ', 'Project slug (auto-detected from .temps/config.json)')
.requiredOption('--var ', 'Environment variable name')
.option('--json', 'Output in JSON format')
.action(envVarAction)
@@ -289,8 +397,11 @@ async function createServiceAction(options: CreateOptions): Promise {
let name: string
let parameters: Record = {}
- // Check if automation mode (all required params provided)
- const isAutomation = options.yes && options.type && options.name
+ const hasSetParams = options.set && options.set.length > 0
+
+ // Automation mode: -y with type+name, OR type+name+set (explicit params = no need for -y)
+ const isAutomation = (options.yes && options.type && options.name) ||
+ (options.type && options.name && hasSetParams)
if (isAutomation) {
// Validate service type
@@ -301,60 +412,67 @@ async function createServiceAction(options: CreateOptions): Promise {
serviceType = options.type as ServiceTypeRoute
name = options.name!
- // Parse parameters if provided
- if (options.parameters) {
+ // Parse --set key=value pairs if provided, otherwise use smart defaults
+ if (hasSetParams) {
try {
- parameters = JSON.parse(options.parameters)
- } catch {
- warning('Invalid JSON in --parameters')
+ parameters = parseSetPairs(options.set!)
+ } catch (e) {
+ warning((e as Error).message)
return
}
+ } else {
+ // Apply default parameters for this service type (e.g., database/username for postgres)
+ parameters = { ...(SERVICE_TYPE_DEFAULTS[serviceType] ?? {}) }
}
} else {
- // Interactive mode
- serviceType = await promptSelect({
- message: 'Service type',
- choices: types.map(t => ({
- name: SERVICE_TYPE_LABELS[t] || t,
- value: t,
- })),
- }) as ServiceTypeRoute
+ // Interactive mode — use --type and --name if provided, prompt for the rest
+ if (options.type) {
+ if (!types.includes(options.type as ServiceTypeRoute)) {
+ warning(`Invalid service type: ${options.type}. Available: ${types.join(', ')}`)
+ return
+ }
+ serviceType = options.type as ServiceTypeRoute
+ info(`Service type: ${colors.bold(SERVICE_TYPE_LABELS[serviceType] || serviceType)}`)
+ } else {
+ serviceType = await promptSelect({
+ message: 'Service type',
+ choices: types.map(t => ({
+ name: SERVICE_TYPE_LABELS[t] || t,
+ value: t,
+ })),
+ }) as ServiceTypeRoute
+ }
- name = await promptText({
- message: 'Service name',
- default: `my-${serviceType}`,
- required: true,
- })
+ if (options.name) {
+ name = options.name
+ } else {
+ name = await promptText({
+ message: 'Service name',
+ default: `my-${serviceType}`,
+ required: true,
+ })
+ }
- // Get parameters schema for the service type
+ // Get parameters schema for the service type (returns JSON Schema)
const { data: typeInfo } = await getServiceTypeParameters({
client,
path: { service_type: serviceType },
})
- // Type guard for parameters response
- interface ServiceTypeParameter {
- name: string
- label?: string
- default_value?: unknown
- required?: boolean
- enum_values?: string[]
- param_type?: string
- }
- interface ServiceTypeParametersResponse {
- parameters?: ServiceTypeParameter[]
- }
- const paramResponse = typeInfo as ServiceTypeParametersResponse | undefined
+ const schema = typeInfo as JsonSchema | undefined
+ const promptParams = schemaToPromptParams(schema ?? {})
+ // Only show user-configurable params (skip readonly ones the backend auto-generates)
+ const configurableParams = promptParams.filter(p => !p.readonly || p.required)
- if (paramResponse?.parameters && paramResponse.parameters.length > 0) {
+ if (configurableParams.length > 0) {
info(`\nConfigure ${SERVICE_TYPE_LABELS[serviceType] || serviceType} parameters:`)
newline()
- for (const param of paramResponse.parameters) {
- // Skip parameters that have defaults and aren't required
+ for (const param of configurableParams) {
+ // Skip non-required params that have defaults — use the default automatically
if (param.default_value !== undefined && !param.required) {
const useDefault = await promptConfirm({
- message: `${param.label || param.name}: Use default "${param.default_value}"?`,
+ message: `${param.label}${param.description ? ` (${param.description})` : ''}: Use default "${param.default_value}"?`,
default: true,
})
if (useDefault) {
@@ -367,19 +485,18 @@ async function createServiceAction(options: CreateOptions): Promise {
if (param.enum_values && param.enum_values.length > 0) {
value = await promptSelect({
- message: param.label || param.name,
+ message: param.label,
choices: param.enum_values.map((v: string) => ({ name: v, value: v })),
})
} else {
value = await promptText({
- message: param.label || param.name,
- default: param.default_value?.toString() || '',
- required: param.required || false,
+ message: `${param.label}${param.description ? ` (${param.description})` : ''}`,
+ default: param.default_value?.toString() ?? '',
+ required: param.required,
})
}
if (value) {
- // Try to parse as number if the param type suggests it
if (param.param_type === 'integer' || param.param_type === 'number') {
parameters[param.name] = parseInt(value, 10)
} else if (param.param_type === 'boolean') {
@@ -580,6 +697,76 @@ async function listServiceTypes(options: { json?: boolean }): Promise {
console.log(` ${colors.bold(SERVICE_TYPE_LABELS[t] || t)} ${colors.muted(`(${t})`)}`)
}
newline()
+ info(`Run ${colors.bold('services types info ')} to see parameters for a specific type`)
+}
+
+/** Build an example `services create` command using --set flags with all schema defaults */
+function buildExampleCommand(type: string, schema?: JsonSchema): string {
+ const setParts: string[] = []
+ if (schema?.properties) {
+ for (const [key, prop] of Object.entries(schema.properties)) {
+ // Skip params with null defaults (auto-generated like password, port)
+ if (prop.default === null || prop.default === undefined) continue
+ setParts.push(`--set ${key}=${prop.default}`)
+ }
+ }
+ const setsStr = setParts.length > 0 ? ` ${setParts.join(' ')}` : ''
+ return `bunx @temps-sdk/cli services create -t ${type} -n my-${type}${setsStr}`
+}
+
+async function showServiceTypeInfo(type: string): Promise {
+ await requireAuth()
+ await setupClient()
+
+ const { data, error } = await getServiceTypeParameters({
+ client,
+ path: { service_type: type as ServiceTypeRoute },
+ })
+
+ if (error) {
+ warning(`Failed to get parameters for "${type}": ${getErrorMessage(error)}`)
+ return
+ }
+
+ const schema = data as JsonSchema | undefined
+ if (!schema?.properties) {
+ json({ type, parameters: {}, defaults: SERVICE_TYPE_DEFAULTS[type] ?? {} })
+ return
+ }
+
+ // Build a clean output for agents: each parameter with its metadata
+ const params: Record = {}
+
+ const requiredKeys = new Set(schema.required ?? [])
+ const readonlyKeys = new Set(schema.readonly ?? [])
+
+ for (const [name, prop] of Object.entries(schema.properties)) {
+ params[name] = {
+ type: prop.type ?? 'string',
+ ...(prop.description ? { description: prop.description } : {}),
+ required: requiredKeys.has(name),
+ readonly: readonlyKeys.has(name),
+ ...(prop.default !== undefined ? { default: prop.default } : {}),
+ ...(prop.example !== undefined ? { example: prop.example } : {}),
+ }
+ }
+
+ const output = {
+ service_type: type,
+ label: SERVICE_TYPE_LABELS[type as ServiceTypeRoute] || type,
+ parameters: params,
+ defaults: SERVICE_TYPE_DEFAULTS[type] ?? {},
+ example_create: buildExampleCommand(type, schema),
+ }
+
+ json(output)
}
async function listLinkedProjects(options: ProjectsOptions): Promise {
@@ -634,11 +821,11 @@ async function updateServiceAction(options: UpdateOptions): Promise {
}
let parameters: Record = {}
- if (options.parameters) {
+ if (options.set && options.set.length > 0) {
try {
- parameters = JSON.parse(options.parameters)
- } catch {
- warning('Invalid JSON in --parameters')
+ parameters = parseSetPairs(options.set)
+ } catch (e) {
+ warning((e as Error).message)
return
}
}
@@ -728,11 +915,11 @@ async function importServiceAction(options: ImportOptions): Promise {
name = options.name!
containerId = options.containerId!
- if (options.parameters) {
+ if (options.set && options.set.length > 0) {
try {
- parameters = JSON.parse(options.parameters)
- } catch {
- warning('Invalid JSON in --parameters')
+ parameters = parseSetPairs(options.set)
+ } catch (e) {
+ warning((e as Error).message)
return
}
}
@@ -770,11 +957,11 @@ async function importServiceAction(options: ImportOptions): Promise {
required: true,
})
- if (options.parameters) {
+ if (options.set && options.set.length > 0) {
try {
- parameters = JSON.parse(options.parameters)
- } catch {
- warning('Invalid JSON in --parameters')
+ parameters = parseSetPairs(options.set)
+ } catch (e) {
+ warning((e as Error).message)
return
}
}
@@ -810,18 +997,14 @@ async function linkServiceAction(options: LinkOptions): Promise {
return
}
- const projectId = parseInt(options.projectId, 10)
- if (isNaN(projectId)) {
- warning('Invalid project ID')
- return
- }
+ const project = await resolveProjectId(options.project)
- await withSpinner('Linking service to project...', async () => {
+ await withSpinner(`Linking service to project ${colors.bold(project.slug)}...`, async () => {
const { error } = await linkServiceToProject({
client,
path: { id },
body: {
- project_id: projectId,
+ project_id: project.id,
},
})
if (error) {
@@ -829,7 +1012,7 @@ async function linkServiceAction(options: LinkOptions): Promise {
}
})
- success(`Service ${options.id} linked to project ${options.projectId}`)
+ success(`Service ${options.id} linked to project ${project.slug}`)
}
async function unlinkServiceAction(options: UnlinkOptions): Promise {
@@ -842,17 +1025,13 @@ async function unlinkServiceAction(options: UnlinkOptions): Promise {
return
}
- const projectId = parseInt(options.projectId, 10)
- if (isNaN(projectId)) {
- warning('Invalid project ID')
- return
- }
+ const project = await resolveProjectId(options.project)
const skipConfirmation = options.force || options.yes
if (!skipConfirmation) {
const confirmed = await promptConfirm({
- message: `Unlink service ${options.id} from project ${options.projectId}?`,
+ message: `Unlink service ${options.id} from project ${project.slug}?`,
default: false,
})
if (!confirmed) {
@@ -861,17 +1040,17 @@ async function unlinkServiceAction(options: UnlinkOptions): Promise {
}
}
- await withSpinner('Unlinking service from project...', async () => {
+ await withSpinner(`Unlinking service from project ${colors.bold(project.slug)}...`, async () => {
const { error } = await unlinkServiceFromProject({
client,
- path: { id, project_id: projectId },
+ path: { id, project_id: project.id },
})
if (error) {
throw new Error(getErrorMessage(error))
}
})
- success(`Service ${options.id} unlinked from project ${options.projectId}`)
+ success(`Service ${options.id} unlinked from project ${project.slug}`)
}
async function envAction(options: EnvOptions): Promise {
@@ -884,16 +1063,12 @@ async function envAction(options: EnvOptions): Promise {
return
}
- const projectId = parseInt(options.projectId, 10)
- if (isNaN(projectId)) {
- warning('Invalid project ID')
- return
- }
+ const project = await resolveProjectId(options.project)
const envVars = await withSpinner('Fetching environment variables...', async () => {
const { data, error } = await getServiceEnvironmentVariables({
client,
- path: { id, project_id: projectId },
+ path: { id, project_id: project.id },
})
if (error) {
throw new Error(getErrorMessage(error))
@@ -932,16 +1107,12 @@ async function envVarAction(options: EnvVarOptions): Promise {
return
}
- const projectId = parseInt(options.projectId, 10)
- if (isNaN(projectId)) {
- warning('Invalid project ID')
- return
- }
+ const project = await resolveProjectId(options.project)
const envVar = await withSpinner('Fetching environment variable...', async () => {
const { data, error } = await getServiceEnvironmentVariable({
client,
- path: { id, project_id: projectId, var_name: options.var },
+ path: { id, project_id: project.id, var_name: options.var },
})
if (error) {
throw new Error(getErrorMessage(error))
diff --git a/apps/temps-cli/src/commands/tokens/index.ts b/apps/temps-cli/src/commands/tokens/index.ts
index ba499ec8..13d7bdcc 100644
--- a/apps/temps-cli/src/commands/tokens/index.ts
+++ b/apps/temps-cli/src/commands/tokens/index.ts
@@ -1,6 +1,7 @@
import type { Command } from 'commander'
import { requireAuth, config, credentials } from '../../config/store.js'
-import { setupClient, getErrorMessage } from '../../lib/api-client.js'
+import { setupClient, normalizeApiUrl, getWebUrl, getErrorMessage } from '../../lib/api-client.js'
+import { requireProjectSlug } from '../../config/resolve-project.js'
import { colors, header, icons, info, json, keyValue, newline, success, warning, error as errorOutput } from '../../ui/output.js'
import { promptConfirm, promptSelect, promptText } from '../../ui/prompts.js'
import { withSpinner } from '../../ui/spinner.js'
@@ -48,7 +49,7 @@ const PERMISSIONS = [
]
interface CreateOptions {
- project: string
+ project?: string
name?: string
permissions?: string
expiresIn?: string
@@ -56,18 +57,18 @@ interface CreateOptions {
}
interface ListOptions {
- project: string
+ project?: string
json?: boolean
}
interface ShowOptions {
- project: string
+ project?: string
id: string
json?: boolean
}
interface RemoveOptions {
- project: string
+ project?: string
id: string
force?: boolean
yes?: boolean
@@ -78,7 +79,7 @@ async function makeRequest(
path: string,
body?: unknown
): Promise {
- const apiUrl = config.get('apiUrl')
+ const apiUrl = normalizeApiUrl(config.get('apiUrl'))
const apiKey = await credentials.getApiKey()
const response = await fetch(`${apiUrl}${path}`, {
@@ -102,34 +103,25 @@ async function makeRequest(
return response.json() as Promise
}
-async function resolveProjectId(projectIdentifier: string): Promise {
+async function resolveProjectId(projectSlug: string): Promise {
// Try to parse as number first
- const numId = parseInt(projectIdentifier, 10)
+ const numId = parseInt(projectSlug, 10)
if (!isNaN(numId)) {
return numId
}
// Otherwise, look up by slug
- const apiUrl = config.get('apiUrl')
- const apiKey = await credentials.getApiKey()
-
- const response = await fetch(`${apiUrl}/api/projects?page_size=100`, {
- headers: {
- 'Authorization': `Bearer ${apiKey}`,
- },
- })
-
- if (!response.ok) {
- throw new Error('Failed to fetch projects')
- }
+ const projects = await makeRequest<{ projects?: Array<{ slug: string; id: number }> }>(
+ 'GET',
+ `/projects?page_size=100`
+ )
- const data = await response.json() as { projects?: Array<{ slug: string; id: number }> }
- const project = data.projects?.find((p) =>
- p.slug === projectIdentifier || p.slug.toLowerCase() === projectIdentifier.toLowerCase()
+ const project = projects.projects?.find((p) =>
+ p.slug === projectSlug || p.slug.toLowerCase() === projectSlug.toLowerCase()
)
if (!project) {
- throw new Error(`Project "${projectIdentifier}" not found`)
+ throw new Error(`Project "${projectSlug}" not found`)
}
return project.id
@@ -145,7 +137,7 @@ export function registerTokensCommands(program: Command): void {
.command('list')
.alias('ls')
.description('List deployment tokens for a project')
- .requiredOption('-p, --project ', 'Project slug or ID')
+ .option('-p, --project ', 'Project slug or ID')
.option('--json', 'Output in JSON format')
.action(listTokensAction)
@@ -153,7 +145,7 @@ export function registerTokensCommands(program: Command): void {
.command('create')
.alias('add')
.description('Create a new deployment token')
- .requiredOption('-p, --project ', 'Project slug or ID')
+ .option('-p, --project ', 'Project slug or ID')
.option('-n, --name ', 'Token name')
.option('--permissions ', 'Comma-separated permissions (e.g., "visitors:enrich,emails:send" or "*" for full access)')
.option('-e, --expires-in ', 'Expires in N days (7, 30, 90, 365, or "never")')
@@ -164,7 +156,7 @@ export function registerTokensCommands(program: Command): void {
.command('show')
.alias('get')
.description('Show deployment token details')
- .requiredOption('-p, --project ', 'Project slug or ID')
+ .option('-p, --project ', 'Project slug or ID')
.requiredOption('--id ', 'Token ID')
.option('--json', 'Output in JSON format')
.action(showTokenAction)
@@ -173,7 +165,7 @@ export function registerTokensCommands(program: Command): void {
.command('delete')
.alias('rm')
.description('Delete a deployment token')
- .requiredOption('-p, --project ', 'Project slug or ID')
+ .option('-p, --project ', 'Project slug or ID')
.requiredOption('--id ', 'Token ID')
.option('-f, --force', 'Skip confirmation')
.option('-y, --yes', 'Skip confirmation (alias for --force)')
@@ -190,14 +182,20 @@ async function listTokensAction(options: ListOptions): Promise {
await requireAuth()
await setupClient()
+ const resolved = await requireProjectSlug(options.project)
+
+ if (resolved.source !== 'flag') {
+ info(`Using project ${colors.bold(resolved.slug)} (from ${resolved.source})`)
+ }
+
const projectId = await withSpinner('Resolving project...', async () => {
- return resolveProjectId(options.project)
+ return resolveProjectId(resolved.slug)
})
const response = await withSpinner('Fetching deployment tokens...', async () => {
return makeRequest(
'GET',
- `/api/projects/${projectId}/deployment-tokens`
+ `/projects/${projectId}/deployment-tokens`
)
})
@@ -213,7 +211,7 @@ async function listTokensAction(options: ListOptions): Promise {
if (tokensList.length === 0) {
info('No deployment tokens found')
- info(`Run: temps tokens create -p ${options.project} --name my-token -y`)
+ info(`Run: temps tokens create -p ${resolved.slug} --name my-token -y`)
newline()
return
}
@@ -236,8 +234,14 @@ async function createTokenAction(options: CreateOptions): Promise {
await requireAuth()
await setupClient()
+ const resolved = await requireProjectSlug(options.project)
+
+ if (resolved.source !== 'flag') {
+ info(`Using project ${colors.bold(resolved.slug)} (from ${resolved.source})`)
+ }
+
const projectId = await withSpinner('Resolving project...', async () => {
- return resolveProjectId(options.project)
+ return resolveProjectId(resolved.slug)
})
let name: string
@@ -317,7 +321,7 @@ async function createTokenAction(options: CreateOptions): Promise {
const result = await withSpinner('Creating deployment token...', async () => {
return makeRequest(
'POST',
- `/api/projects/${projectId}/deployment-tokens`,
+ `/projects/${projectId}/deployment-tokens`,
{
name,
permissions,
@@ -354,8 +358,14 @@ async function showTokenAction(options: ShowOptions): Promise {
await requireAuth()
await setupClient()
+ const resolved = await requireProjectSlug(options.project)
+
+ if (resolved.source !== 'flag') {
+ info(`Using project ${colors.bold(resolved.slug)} (from ${resolved.source})`)
+ }
+
const projectId = await withSpinner('Resolving project...', async () => {
- return resolveProjectId(options.project)
+ return resolveProjectId(resolved.slug)
})
const tokenId = parseInt(options.id, 10)
@@ -367,7 +377,7 @@ async function showTokenAction(options: ShowOptions): Promise {
const token = await withSpinner('Fetching token...', async () => {
return makeRequest(
'GET',
- `/api/projects/${projectId}/deployment-tokens/${tokenId}`
+ `/projects/${projectId}/deployment-tokens/${tokenId}`
)
})
@@ -392,8 +402,14 @@ async function deleteTokenAction(options: RemoveOptions): Promise {
await requireAuth()
await setupClient()
+ const resolved = await requireProjectSlug(options.project)
+
+ if (resolved.source !== 'flag') {
+ info(`Using project ${colors.bold(resolved.slug)} (from ${resolved.source})`)
+ }
+
const projectId = await withSpinner('Resolving project...', async () => {
- return resolveProjectId(options.project)
+ return resolveProjectId(resolved.slug)
})
const tokenId = parseInt(options.id, 10)
@@ -406,7 +422,7 @@ async function deleteTokenAction(options: RemoveOptions): Promise {
const token = await withSpinner('Fetching token...', async () => {
return makeRequest(
'GET',
- `/api/projects/${projectId}/deployment-tokens/${tokenId}`
+ `/projects/${projectId}/deployment-tokens/${tokenId}`
)
})
@@ -427,7 +443,7 @@ async function deleteTokenAction(options: RemoveOptions): Promise {
await withSpinner('Deleting token...', async () => {
return makeRequest(
'DELETE',
- `/api/projects/${projectId}/deployment-tokens/${tokenId}`
+ `/projects/${projectId}/deployment-tokens/${tokenId}`
)
})
diff --git a/apps/temps-cli/src/commands/up/index.ts b/apps/temps-cli/src/commands/up/index.ts
index 9983ab15..a0dc7e74 100644
--- a/apps/temps-cli/src/commands/up/index.ts
+++ b/apps/temps-cli/src/commands/up/index.ts
@@ -1,15 +1,22 @@
import type { Command } from 'commander'
-import { requireAuth } from '../../config/store.js'
-import { setupClient, client } from '../../lib/api-client.js'
+import { requireAuth, config } from '../../config/store.js'
+import { setupClient, client, getErrorMessage } from '../../lib/api-client.js'
import { resolveProjectSlug } from '../../config/resolve-project.js'
import { hasProjectConfig, writeProjectConfig } from '../../config/project-config.js'
-import { deploy } from '../deploy/deploy.js'
import { deployLocalImage } from '../deploy/deploy-local-image.js'
import { runSetupWizard } from './setup-wizard.js'
import { detectGitBranch } from '../../lib/detect-project.js'
-import { promptConfirm } from '../../ui/prompts.js'
-import { info, warning, newline, colors } from '../../ui/output.js'
-import { getProjectBySlug } from '../../api/sdk.gen.js'
+import { promptConfirm, promptSelect, promptText } from '../../ui/prompts.js'
+import { startSpinner, succeedSpinner, failSpinner } from '../../ui/spinner.js'
+import { info, warning, newline, colors, icons, box } from '../../ui/output.js'
+import {
+ getProjectBySlug,
+ getEnvironments,
+ triggerProjectPipeline,
+ getProjectDeployments,
+} from '../../api/sdk.gen.js'
+import type { ProjectResponse, EnvironmentResponse } from '../../api/types.gen.js'
+import { watchDeployment } from '../../lib/deployment-watcher.jsx'
interface UpOptions {
project?: string
@@ -50,22 +57,122 @@ async function up(projectArg: string | undefined, options: UpOptions): Promise 0) {
+ if (options.environment) {
+ const env = environments.find(e => e.name === options.environment)
+ if (env) {
+ environmentId = env.id
+ environmentName = env.name
+ }
+ } else if (!options.yes) {
+ const selectedEnv = await promptSelect({
+ message: 'Select environment',
+ choices: environments.map((env) => ({
+ name: env.name,
+ value: String(env.id),
+ description: env.is_preview ? 'Preview environment' : undefined,
+ })),
+ default: String(environments.find(e => e.name === 'production')?.id ?? environments[0]?.id ?? ''),
+ })
+ environmentId = parseInt(selectedEnv, 10)
+ environmentName = environments.find(e => e.id === environmentId)?.name ?? 'production'
+ } else {
+ const prodEnv = environments.find(e => e.name === 'production')
+ if (prodEnv) {
+ environmentId = prodEnv.id
+ environmentName = prodEnv.name
+ } else if (environments[0]) {
+ environmentId = environments[0].id
+ environmentName = environments[0].name
+ }
+ }
+ }
+
+ // ─── Deploy based on source type ────────────────────────────────────────
if (sourceType === 'manual' || sourceType === 'docker_image') {
- // Manual/docker_image projects deploy via local image build + upload
+ // Show deployment preview
+ newline()
+ box(
+ [
+ `Project: ${colors.bold(project.name)}`,
+ `Environment: ${colors.bold(environmentName)}`,
+ project.preset ? `Preset: ${colors.bold(project.preset)}` : null,
+ `Deploy: ${colors.bold('Manual (local image upload)')}`,
+ ]
+ .filter(Boolean)
+ .join('\n'),
+ `${icons.rocket} Deployment Preview`
+ )
+ newline()
+
await deployLocalImage({
project: resolved.slug,
environment: options.environment,
@@ -73,7 +180,7 @@ async function up(projectArg: string | undefined, options: UpOptions): Promise setTimeout(r, 2000))
+ }
+
+ if (deploymentId) {
+ succeedSpinner(`Deployment #${deploymentId} found`)
+ const result = await watchDeployment({
+ projectId: project.id,
+ deploymentId,
+ timeoutSecs: 600,
+ projectName: resolved.slug,
+ })
+
+ if (!result.success) {
+ process.exitCode = 1
+ }
+ } else {
+ failSpinner('Could not locate the deployment to track')
+ info(`Check status: ${colors.muted('bunx @temps-sdk/cli status')}`)
+ }
+ } catch (err) {
+ failSpinner('Deployment failed')
+ throw err
+ }
}
// Offer to save config if it doesn't exist
diff --git a/apps/temps-cli/src/lib/api-client.ts b/apps/temps-cli/src/lib/api-client.ts
index d42ff553..40fb8c49 100644
--- a/apps/temps-cli/src/lib/api-client.ts
+++ b/apps/temps-cli/src/lib/api-client.ts
@@ -4,7 +4,7 @@ import { config, credentials } from '../config/store.js'
/**
* Setup the API client with the correct base URL and auth headers
*/
-function normalizeApiUrl(url: string): string {
+export function normalizeApiUrl(url: string): string {
// Remove trailing slash
let normalized = url.replace(/\/+$/, '')
// Ensure /api suffix if not already present
@@ -31,6 +31,13 @@ export async function setupClient(): Promise {
})
}
+/**
+ * Get the web dashboard base URL (API URL without /api suffix)
+ */
+export function getWebUrl(): string {
+ return config.get('apiUrl').replace(/\/+$/, '').replace(/\/api$/, '')
+}
+
/**
* Extract error message from API error response
*/
diff --git a/apps/temps-cli/src/lib/deployment-watcher.tsx b/apps/temps-cli/src/lib/deployment-watcher.tsx
index 3aa0f1ca..cd0a4f09 100644
--- a/apps/temps-cli/src/lib/deployment-watcher.tsx
+++ b/apps/temps-cli/src/lib/deployment-watcher.tsx
@@ -1,7 +1,8 @@
-import { useState, useEffect } from 'react'
+import { useState, useEffect, useCallback } from 'react'
import { render, Box, Text, Newline } from 'ink'
import Spinner from 'ink-spinner'
import { config, credentials } from '../config/store.js'
+import { normalizeApiUrl, getWebUrl } from './api-client.js'
interface DeploymentEnvironment {
id: number
@@ -62,6 +63,10 @@ interface JobState {
lastLogLine: number
}
+const TERMINAL_STATUSES = ['success', 'completed', 'deployed', 'failed', 'error', 'cancelled']
+const SUCCESS_STATUSES = ['success', 'completed', 'deployed']
+const FAILURE_STATUSES = ['failed', 'error', 'cancelled']
+
// Convert API timestamp to milliseconds
function toMs(timestamp: number): number {
if (timestamp < 946684800000) {
@@ -94,7 +99,7 @@ function StatusIcon({ status }: { status: string }) {
case 'success':
case 'completed':
case 'deployed':
- return ●
+ return ✓
case 'failed':
case 'error':
return ✗
@@ -142,8 +147,8 @@ function LogEntryRow({ entry }: { entry: LogEntry }) {
break
}
- // Clean up the message
- const message = entry.message.replace(/^[✅❌⏳📦📋📂🚀📍🔄⬇️🐳🏷️]\s*/, '')
+ // Clean up the message — strip leading emoji
+ const message = entry.message.replace(/^[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]\s*/u, '')
return (
@@ -153,15 +158,15 @@ function LogEntryRow({ entry }: { entry: LogEntry }) {
}
// Job row component
-function JobRow({ jobState, showAllLogs }: { jobState: JobState; showAllLogs?: boolean }) {
+function JobRow({ jobState, isFinished }: { jobState: JobState; isFinished?: boolean }) {
const { job, logs } = jobState
const duration = job.started_at
? formatDuration(job.started_at, job.finished_at ?? undefined)
: ''
const statusColor = getStatusColor(job.status)
- // Show more logs for running jobs, fewer for completed
- const maxLogs = showAllLogs ? 20 : (job.status === 'running' ? 10 : 5)
+ // Show fewer logs when finished to keep output compact
+ const maxLogs = isFinished ? 3 : (job.status === 'running' ? 10 : 5)
const recentLogs = logs.slice(-maxLogs)
return (
@@ -170,19 +175,18 @@ function JobRow({ jobState, showAllLogs }: { jobState: JobState; showAllLogs?: b
{job.name}
{duration && ({duration})}
- {logs.length > 0 && [{logs.length} logs]}
{/* Error message */}
- {(job.status === 'failed' || job.status === 'error') && job.error_message && (
+ {FAILURE_STATUSES.includes(job.status) && job.error_message && (
Error: {job.error_message}
)}
- {/* Logs - always show if there are any */}
- {recentLogs.length > 0 && (
-
+ {/* Logs — show during progress, compact on finish */}
+ {recentLogs.length > 0 && !isFinished && (
+
{recentLogs.map((log, i) => (
))}
@@ -197,7 +201,7 @@ function DeploymentWatcher({
projectId,
deploymentId,
timeoutSecs,
- projectName: _projectName,
+ projectName,
apiUrl,
apiKey,
onComplete,
@@ -207,9 +211,11 @@ function DeploymentWatcher({
const [startTime] = useState(Date.now())
const [elapsed, setElapsed] = useState('0s')
const [error, setError] = useState(null)
+ const [result, setResult] = useState(null)
// Update elapsed time
useEffect(() => {
+ if (result) return // Stop updating once finished
const timer = setInterval(() => {
const seconds = Math.floor((Date.now() - startTime) / 1000)
if (seconds < 60) {
@@ -222,147 +228,148 @@ function DeploymentWatcher({
}, 1000)
return () => clearInterval(timer)
- }, [startTime])
+ }, [startTime, result])
+
+ // Signal completion after result is rendered
+ useEffect(() => {
+ if (!result) return
+ const timer = setTimeout(() => onComplete(result), 200)
+ return () => clearTimeout(timer)
+ }, [result, onComplete])
+
+ // Fetch jobs helper
+ const fetchJobs = useCallback(async (currentJobStates: Map): Promise
- )
-}
-
-// Result display component
-function DeploymentResult({
- result,
- projectName,
-}: {
- result: WatchDeploymentResult
- projectName?: string
-}) {
- if (result.success) {
- const deployment = result.deployment
- const envDomains = deployment?.environment?.domains || []
- // Domain might already include protocol, check before adding https://
- const firstDomain = envDomains[0]
- const envUrl = firstDomain
- ? (firstDomain.startsWith('http') ? firstDomain : `https://${firstDomain}`)
- : null
-
- return (
-
-
- {' '}✓ Deployment completed successfully!
-
-
-
- Deployment ID:
- {deployment?.id}
-
- {deployment?.url && (
-
- Deployment URL:
- {deployment.url}
-
- )}
- {envUrl && (
-
- Environment URL:
- {envUrl}
-
- )}
-
-
- )
- }
-
- return (
-
-
- {' '}✗ Deployment failed
-
- {result.error && (
-
- Reason: {result.error}
-
+ {/* Result summary */}
+ {result && (
+ <>
+
+ {result.success ? (
+
+
+ ✓ Deployment completed successfully!
+
+ {deployment?.url && (
+
+ URL:
+ {deployment.url}
+
+ )}
+ {deployment?.environment?.domains?.[0] && (
+
+ Domain:
+
+ {deployment.environment.domains[0].startsWith('http')
+ ? deployment.environment.domains[0]
+ : `https://${deployment.environment.domains[0]}`}
+
+
+ )}
+
+ ) : (
+
+
+ ✗ Deployment failed
+
+ {result.error && (
+
+ {result.error}
+
+ )}
+
+ )}
+ {projectName && (
+
+ Dashboard: {webUrl}/projects/{projectName}/deployments
+
+ )}
+
+ >
)}
- {projectName && (
-
- View full logs: temps logs {projectName}
-
- )}
-
)
}
@@ -480,7 +468,7 @@ export async function watchDeployment(
options: WatchDeploymentOptions
): Promise {
// Fetch credentials before rendering to avoid async issues in React
- const apiUrl = config.get('apiUrl')
+ const apiUrl = normalizeApiUrl(config.get('apiUrl'))
const apiKey = await credentials.getApiKey() || ''
if (!apiKey) {
@@ -488,32 +476,18 @@ export async function watchDeployment(
}
return new Promise((resolve) => {
- let instance: ReturnType | null = null
-
- const handleComplete = (res: WatchDeploymentResult) => {
- // Unmount the watcher and show the result
- if (instance) {
- instance.unmount()
- }
-
- // Render the result
- const resultInstance = render(
-
- )
-
- // Wait a bit then unmount and resolve
- setTimeout(() => {
- resultInstance.unmount()
- resolve(res)
- }, 100)
- }
-
- instance = render(
+ const instance = render(
{
+ // Give Ink time to render the final state, then unmount
+ setTimeout(() => {
+ instance.unmount()
+ resolve(res)
+ }, 300)
+ }}
/>
)
})
diff --git a/apps/temps-cli/src/lib/detect-project.ts b/apps/temps-cli/src/lib/detect-project.ts
index ed4db1db..ea641af9 100644
--- a/apps/temps-cli/src/lib/detect-project.ts
+++ b/apps/temps-cli/src/lib/detect-project.ts
@@ -273,6 +273,8 @@ export function isGitRepo(dir?: string): boolean {
}
}
+// ─── Git Commit Detection ────────────────────────────────────────────────────
+
// ─── Service Hints Detection ─────────────────────────────────────────────────
import type { ServiceTypeRoute } from '../api/types.gen.js'
diff --git a/apps/temps-cli/src/lib/env-file.ts b/apps/temps-cli/src/lib/env-file.ts
new file mode 100644
index 00000000..696475af
--- /dev/null
+++ b/apps/temps-cli/src/lib/env-file.ts
@@ -0,0 +1,79 @@
+/**
+ * Shared .env file parsing utility.
+ * Handles comments, empty lines, quoted values, and escape sequences.
+ */
+import { existsSync, readFileSync } from 'node:fs'
+import { resolve } from 'node:path'
+
+/**
+ * Parse a .env file content string into a key-value record.
+ * Supports: comments (#), empty lines, KEY=VALUE, single/double quoted values,
+ * escape sequences (\n, \", \').
+ */
+export function parseEnvFile(content: string): Record {
+ const variables: Record = {}
+
+ for (const line of content.split('\n')) {
+ const trimmed = line.trim()
+
+ // Skip empty lines and comments
+ if (!trimmed || trimmed.startsWith('#')) continue
+
+ // Parse KEY=VALUE
+ const match = trimmed.match(/^([^=]+)=(.*)$/)
+ if (!match) continue
+
+ const [, key, rawValue] = match
+ if (!key || rawValue === undefined) continue
+
+ let value = rawValue.trim()
+
+ // Handle quoted values
+ if (
+ (value.startsWith('"') && value.endsWith('"')) ||
+ (value.startsWith("'") && value.endsWith("'"))
+ ) {
+ value = value
+ .slice(1, -1)
+ .replace(/\\n/g, '\n')
+ .replace(/\\"/g, '"')
+ .replace(/\\'/g, "'")
+ }
+
+ variables[key.trim()] = value
+ }
+
+ return variables
+}
+
+/**
+ * Read and parse a .env file from disk.
+ * Returns null if the file doesn't exist.
+ */
+export function readEnvFile(filePath: string): Record | null {
+ const resolved = resolve(filePath)
+ if (!existsSync(resolved)) {
+ return null
+ }
+ const content = readFileSync(resolved, 'utf-8')
+ return parseEnvFile(content)
+}
+
+/**
+ * Look for common .env file names in a directory.
+ * Returns the paths of files that exist, ordered by priority.
+ */
+export function findEnvFiles(dir?: string): string[] {
+ const cwd = dir ?? process.cwd()
+ const candidates = ['.env', '.env.local', '.env.development', '.env.example']
+ const found: string[] = []
+
+ for (const name of candidates) {
+ const fullPath = resolve(cwd, name)
+ if (existsSync(fullPath)) {
+ found.push(name)
+ }
+ }
+
+ return found
+}
diff --git a/crates/temps-analytics-events/src/services/events_service.rs b/crates/temps-analytics-events/src/services/events_service.rs
index c742998f..9de420f0 100644
--- a/crates/temps-analytics-events/src/services/events_service.rs
+++ b/crates/temps-analytics-events/src/services/events_service.rs
@@ -497,6 +497,11 @@ impl AnalyticsEventsService {
"events e LEFT JOIN ip_geolocations ig ON e.ip_geolocation_id = ig.id",
format!("COALESCE(ig.{}, 'Unknown')", group_by_str),
)
+ } else if is_referrer_column {
+ (
+ "events e",
+ format!("COALESCE(e.{}, 'Direct')", group_by_str),
+ )
} else {
("events e", format!("e.{}", group_by_str))
};
@@ -650,11 +655,17 @@ impl AnalyticsEventsService {
// Check if we need to join with ip_geolocations
let is_geo_column = matches!(group_by_str, "country" | "region" | "city");
+ let is_referrer_column = group_by_str == "referrer_hostname";
let (from_clause, select_column) = if is_geo_column {
(
"events e LEFT JOIN ip_geolocations ig ON e.ip_geolocation_id = ig.id",
format!("COALESCE(ig.{}, 'Unknown')", group_by_str),
)
+ } else if is_referrer_column {
+ (
+ "events e",
+ format!("COALESCE(e.{}, 'Direct')", group_by_str),
+ )
} else {
("events e", format!("e.{}", group_by_str))
};
@@ -1047,6 +1058,10 @@ WHERE project_id = $1
.collect();
// Query 3: Hourly sparkline data per project (current period — raw events for accuracy)
+ // Uses generate_series to produce the full hour grid and LEFT JOINs actual data.
+ // This guarantees every project gets a row for every hour in the range, even when
+ // a project has events in only a single bucket (time_bucket_gapfill inside
+ // CROSS JOIN LATERAL fails to fill gaps in that edge case).
let gapfill_start_idx = project_ids.len() + 3;
let gapfill_end_idx = gapfill_start_idx + 1;
@@ -1054,21 +1069,27 @@ WHERE project_id = $1
r#"
SELECT
p.project_id,
- sub.bucket::timestamptz as bucket,
- COALESCE(sub.count, 0) as count
+ h.bucket,
+ COALESCE(d.count, 0) as count
FROM unnest(ARRAY[{in_clause}]) AS p(project_id)
- CROSS JOIN LATERAL (
+ CROSS JOIN generate_series(
+ date_trunc('hour', ${gapfill_start_idx}::timestamptz),
+ date_trunc('hour', ${gapfill_end_idx}::timestamptz),
+ '1 hour'::interval
+ ) AS h(bucket)
+ LEFT JOIN (
SELECT
- time_bucket_gapfill('1 hour', timestamp, ${gapfill_start_idx}::timestamptz, ${gapfill_end_idx}::timestamptz) as bucket,
- COALESCE(COUNT(DISTINCT visitor_id) FILTER (WHERE visitor_id IS NOT NULL), 0) as count
+ project_id,
+ date_trunc('hour', timestamp) as bucket,
+ COUNT(DISTINCT visitor_id) FILTER (WHERE visitor_id IS NOT NULL) as count
FROM events
- WHERE project_id = p.project_id
- AND timestamp >= $1
+ WHERE timestamp >= $1
AND timestamp <= $2
+ AND project_id IN ({in_clause})
AND event_type = 'page_view'
- GROUP BY bucket
- ) sub
- ORDER BY p.project_id, sub.bucket ASC
+ GROUP BY project_id, date_trunc('hour', timestamp)
+ ) d ON d.project_id = p.project_id AND d.bucket = h.bucket
+ ORDER BY p.project_id, h.bucket ASC
"#,
);
diff --git a/crates/temps-analytics/src/analytics.rs b/crates/temps-analytics/src/analytics.rs
index b5bd2386..6318796b 100644
--- a/crates/temps-analytics/src/analytics.rs
+++ b/crates/temps-analytics/src/analytics.rs
@@ -315,7 +315,10 @@ impl Analytics for AnalyticsService {
ig.country_code,
ig.timezone,
ig.is_eu,
- last_event.page_path as current_page
+ last_event.page_path as current_page,
+ v.first_referrer,
+ v.first_referrer_hostname,
+ v.first_channel
FROM visitor v
LEFT JOIN ip_geolocations ig ON v.ip_address_id = ig.id
LEFT JOIN LATERAL (
@@ -361,6 +364,9 @@ impl Analytics for AnalyticsService {
timezone: Option,
is_eu: Option,
current_page: Option,
+ first_referrer: Option,
+ first_referrer_hostname: Option