From 5189d995f07caa877f59a1bf5093c2eb8f9480a9 Mon Sep 17 00:00:00 2001 From: CarmenDou <15951653662@163.com> Date: Thu, 15 Oct 2026 23:48:26 -0700 Subject: [PATCH 1/3] feat(compute): deployments and rollback commands --- src/commands/compute.ts | 174 +++++++++++++++++++++++++++++- src/index.ts | 8 +- src/util.ts | 11 ++ test/compute-deployments.test.ts | 75 +++++++++++++ test/compute-rollback.test.ts | 176 +++++++++++++++++++++++++++++++ 5 files changed, 442 insertions(+), 2 deletions(-) create mode 100644 test/compute-deployments.test.ts create mode 100644 test/compute-rollback.test.ts diff --git a/src/commands/compute.ts b/src/commands/compute.ts index e2b69e0..51522af 100644 --- a/src/commands/compute.ts +++ b/src/commands/compute.ts @@ -1,7 +1,9 @@ import { AgentApprovalRequired, ApiClient, ApiError, requireProject } from '../api.js' import { AgentSessionMissing } from '../agent.js' import { safeText } from '../config.js' -import { info, printJson, handleApproval, relayExitCode, writeFileAtomicSync, resolveThroughSymlink } from '../util.js' +import * as clack from '@clack/prompts' +import { info, printJson, handleApproval, relayExitCode, writeFileAtomicSync, resolveThroughSymlink, relativeTime, CliCancel } from '../util.js' +import { parseRunLimit } from './cron.js' import { resolveComputeServiceId, resolveSoleService, q, parseVolumeGib, parseCount } from './services.js' type Opts = { branch?: string; group?: string; json?: boolean } @@ -414,6 +416,176 @@ export async function computeStatus(serviceName: string | undefined, opts: LifeO info(`compute ${serviceName ?? id}: desired=${r.desiredState} live=${r.state}`) } +// ---- deployments / rollback ---- + +export type ServiceDeploymentView = { + id: string + status: 'succeeded' | 'failed' + trigger: string + image: string + pinnedImage: string | null + commitSha: string | null + buildId: string | null + buildSource: 'github' | 'archive' | null + port: number | null + startCommand: string | null + actor: { kind: string; userId?: string; email?: string } | null + rollbackOf: string | null + error: string | null + backfilled: boolean + createdAt: string + live: boolean + rollbackEligible: boolean + ineligibleReason?: 'live' | 'failed' | 'same_image' + variablesChangedSince?: number +} +type DeploymentsBody = { deployments: ServiceDeploymentView[]; next?: string; autoDeployPausedByRollback?: boolean } +type RollbackBody = { deployment: ServiceDeploymentView; service?: unknown; autoDeployPaused?: boolean } + +const PAUSED_LINE = 'auto-deploy is paused. Turn it back on in the console: Settings → Source' +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i +const INELIGIBLE: Record = { + live: 'that deployment is already live', + failed: 'that deployment failed, so there is nothing to roll back to', + same_image: 'that deployment runs the same image as the live one, use restart to redeploy it', +} + +const shortImage = (d: ServiceDeploymentView) => (d.pinnedImage ? `…@sha256:${d.pinnedImage.split('@sha256:')[1]?.slice(0, 8) ?? ''}` : d.image) + +export function deploymentLines(rows: ServiceDeploymentView[], now: Date): string[] { + if (!rows.length) return ['(no deployments yet)'] + const head = ['ID', 'STATUS', 'TRIGGER', 'COMMIT', 'IMAGE', 'WHEN'] + const body = rows.map((d) => [ + d.id.slice(0, 8), d.live ? 'live' : d.status, d.trigger, d.commitSha?.slice(0, 7) ?? '-', shortImage(d), + relativeTime(d.createdAt, now) + (d.status === 'failed' && d.error ? ` ${d.error}` : ''), + ]) + const widths = head.map((h, i) => Math.max(h.length, ...body.map((r) => r[i]!.length))) + return [head, ...body].map((r) => r.map((c, i) => (i === r.length - 1 ? c : c.padEnd(widths[i]!))).join(' ').trimEnd()) +} + +export function pickRollbackTarget(rows: ServiceDeploymentView[], to?: string, service = 'this service'): ServiceDeploymentView { + if (to === undefined) { + const liveAt = rows.findIndex((d) => d.live) + const found = rows.slice(liveAt + 1).find((d) => d.rollbackEligible) + if (!found) throw new Error(`no earlier deployment on ${service} is eligible, so there is nothing to roll back to`) + return found + } + const want = to.toLowerCase() + const exact = rows.find((d) => d.id.toLowerCase() === want) + const matches = exact ? [exact] : want.length >= 8 ? rows.filter((d) => d.id.toLowerCase().startsWith(want)) : [] + if (matches.length === 0) throw new Error(`no deployment ${to} on ${service}`) + if (matches.length > 1) throw new Error(`deployment id prefix ${to} matches ${matches.length} deployments`) + const t = matches[0]! + if (!t.rollbackEligible) throw new Error(INELIGIBLE[t.ineligibleReason ?? ''] ?? 'that deployment cannot be rolled back to') + return t +} + +export function rollbackDisclosure( + target: ServiceDeploymentView, + opts: { port: number | null; autoDeployBranch: string | null; service: string; now: Date }, +): string[] { + const commit = target.commitSha ? `commit ${target.commitSha.slice(0, 7)}, ` : '' + const changed = target.variablesChangedSince ? ` (${target.variablesChangedSince} changed since)` : '' + return [ + `Rolling back ${opts.service} to ${target.id.slice(0, 8)} (${commit}${relativeTime(target.createdAt, opts.now)})`, + ` restores image, ${opts.port != null ? `port ${opts.port}, ` : ''}start command`, + ` keeps variables${changed}, scale, domains, volume, databases`, + ...(opts.autoDeployBranch ? [` pauses auto-deploy from ${opts.autoDeployBranch}`] : []), + ...(target.pinnedImage ? [] : [` warning this deployment used the tag ${target.image}, which may point to a different image now`]), + ] +} + +const noRoute = (e: unknown) => e instanceof ApiError && e.status === 404 && /Route (GET|POST):/.test(e.message) +const NO_ROUTE = 'this platform does not support deployment history yet' + +async function deploymentsTarget(opts: { branch?: string }, deps?: DomainDeps) { + const { api, project: p } = await domainDeps(deps) + return { api, p, branch: opts.branch ?? p.branch } +} + +export async function computeDeployments( + serviceName: string | undefined, opts: { json?: boolean; branch?: string; limit?: string }, deps?: DomainDeps, +): Promise { + const limit = opts.limit === undefined ? undefined : parseRunLimit(opts.limit) + const { api, p, branch } = await deploymentsTarget(opts, deps) + const { services } = await api.request('GET', `/projects/${p.projectId}/services${q(branch)}`) + const id = resolveComputeServiceId(services, serviceName) + let body: DeploymentsBody + try { + body = await api.request('GET', `/projects/${p.projectId}/services/${id}/deployments${limit === undefined ? '' : `?limit=${limit}`}`) + } catch (e) { + if (noRoute(e)) throw new Error(NO_ROUTE) + throw e + } + if (opts.json) return printJson(body) + for (const line of deploymentLines(body.deployments, new Date())) info(line) + if (body.autoDeployPausedByRollback) info(`\n${PAUSED_LINE}`) +} + +// The branch auto-deploy follows, or null when a rollback would pause nothing (no repo, or it is off). +async function autoDeployBranch(api: DomainApi, projectId: string, id: string, branch: string | undefined): Promise { + try { + const { source } = await api.request('GET', `/projects/${projectId}/services/${id}/source${q(branch)}`) + return source?.type === 'github' && source.auto_deploy ? source.branch : null + } catch (e) { + if (e instanceof ApiError && e.status === 404) return null + throw e + } +} + +export async function computeRollback( + serviceName: string | undefined, opts: { to?: string; yes?: boolean; json?: boolean; branch?: string }, deps?: DomainDeps, +): Promise { + // Same rule as `template rollback`: ask on a terminal, anywhere else (a pipe, --json) say --yes. + const tty = !opts.json && !opts.yes && !!process.stdin.isTTY && !!process.stdout.isTTY + if (!opts.yes && !tty) throw new Error('rollback needs --yes without a terminal to confirm on, so nothing was rolled back') + const { api, p, branch } = await deploymentsTarget(opts, deps) + const { services } = await api.request('GET', `/projects/${p.projectId}/services${q(branch)}`) + const svc = resolveSoleService(services as Array<{ id: string; type: string; name: string }>, 'compute', serviceName) + const base = `/projects/${p.projectId}/services/${svc.id}` + + // A full id goes to the server as is (it validates), no page lookup. + let target: ServiceDeploymentView | null = null + let targetId: string + if (opts.to && UUID_RE.test(opts.to)) targetId = opts.to + else { + let page: DeploymentsBody + try { page = await api.request('GET', `${base}/deployments?limit=100`) } catch (e) { + if (noRoute(e)) throw new Error(NO_ROUTE) + throw e + } + target = pickRollbackTarget(page.deployments, opts.to, svc.name) + targetId = target.id + } + + if (!opts.json) { + const auto = await autoDeployBranch(api, p.projectId, svc.id, branch) + if (target) for (const line of rollbackDisclosure(target, { port: target.port, autoDeployBranch: auto, service: svc.name, now: new Date() })) info(line) + else { + info(`Rolling back ${svc.name} to ${targetId.slice(0, 8)}`) + info(' restores image, port, start command') + info(' keeps variables, scale, domains, volume, databases') + if (auto) info(` pauses auto-deploy from ${auto}`) + } + if (tty) { + const go = await clack.confirm({ message: 'Continue?' }) + if (clack.isCancel(go)) throw new CliCancel() + if (!go) return + } + } + + let res + try { res = await api.rawRequest('POST', `${base}/rollback`, { deploymentId: targetId }) } catch (e) { + if (noRoute(e)) throw new Error(NO_ROUTE) + throw e + } + if (handleApproval(res, opts.json)) return + if (opts.json) return printJson(res.body) + const body = res.body as RollbackBody + info(`rolled back ${svc.name} to ${targetId.slice(0, 8)}. ${body.deployment.id.slice(0, 8)} is live.`) + if (body.autoDeployPaused) info(PAUSED_LINE) +} + // ---- exec (one-shot command; no interactive shell/PTY) ---- /** The options `insta compute exec` declares — the one source of truth. index.ts builds the diff --git a/src/index.ts b/src/index.ts index b602aeb..bf303ef 100644 --- a/src/index.ts +++ b/src/index.ts @@ -386,7 +386,7 @@ const { } = computeCmd.splitExecArgs(process.argv) // ---- compute ---- -const compute = program.command('compute').description('Compute services: lifecycle (start/stop/suspend/restart/status), scale, limits, volume, public access, always-on, exec, ssh, GitHub source, logs, metrics') +const compute = program.command('compute').description('Compute services: lifecycle (start/stop/suspend/restart/status), deployments and rollback, scale, limits, volume, public access, always-on, exec, ssh, GitHub source, logs, metrics') compute.command('start [service]').description('Bring a compute service online (persistent — re-enables auto-wake)') .option('--json').option('--branch ', 'branch (default: current)').action(guard((service, o) => computeCmd.computeStart(service, o))) compute.command('stop [service]').description('Take a compute service offline; traffic will NOT wake it until `start`') @@ -447,6 +447,12 @@ compute.command('start-command [service]').description('Show or stage a compute .option('--clear', 'use the image default command on the next deploy') .option('--json').option('--branch ', 'branch (default: current)') .action(guard((service, o) => computeCmd.computeStartCommand(service, o))) +compute.command('deployments [service]').description('List a compute service\'s deployment history, newest first: which one is live, what started each, and why a failed one failed') + .option('--limit ', 'rows to show (1..100, default 20)').option('--json').option('--branch ', 'branch (default: current)') + .action(guard((service, o) => computeCmd.computeDeployments(service, o))) +compute.command('rollback [service]').description("Roll a compute service back to an earlier deployment: restores that deployment's image, port and start command, keeps variables, scale, domains, volume and databases, and pauses GitHub auto-deploy until it is turned back on in the console (Settings → Source). With no --to, the target is the newest successful deployment before the live one that runs a different image. Gated: deploy. Asks on a terminal, anywhere else needs --yes") + .option('--to ', 'deployment id (full, or a unique prefix of at least 8 characters)').option('--yes', 'skip the confirmation').option('--json').option('--branch ', 'branch (default: current)') + .action(guard((service, o) => computeCmd.computeRollback(service, o))) compute.command('volume [service]').description("Show, attach, grow, remount, or delete a compute service's persistent volume. --mount-path alone stages an existing volume path change, pending until deploy or restart; an unchanged normalized path is a no-op readback. No flag: print size, mount path, and the plan cap (any plan). --size on a volumeless service ATTACHES one (any plan up to its own plan cap — 10Gi free, 50Gi paid by default — which is also what a disk with no size named is born at; a size above the free cap is paid; the disk mounts at --mount-path (default /data) on the next deploy); on a volume-bearing one it grows (paid plans; grow-only — a provisioned disk cannot shrink). --delete DESTROYS the disk and ALL its data immediately (no detach, no undo; billing stops now, and suspend fast-wake + scale-out return). Billing is actual data stored — the size is a cap, not a price") .option('--mount-path ', 'configure mount path; existing volume changes apply on the next deploy (restarts the service)') .option('--size ', 'new size in whole Gi, e.g. 10 (must be ≥ the current size)') diff --git a/src/util.ts b/src/util.ts index 045ca95..e809fe1 100644 --- a/src/util.ts +++ b/src/util.ts @@ -306,3 +306,14 @@ export function promptPasswordFrom( export function promptPassword(label = 'Password: '): Promise { return promptPasswordFrom(process.stdin, process.stdout, label) } + +/** "5 min ago", "2 hours ago", "3 days ago", then the plain date. */ +export function relativeTime(iso: string, now: Date): string { + const secs = Math.max(0, Math.floor((now.getTime() - new Date(iso).getTime()) / 1000)) + const plural = (n: number, unit: string) => `${n} ${unit}${n === 1 ? '' : 's'} ago` + if (secs < 60) return 'just now' + if (secs < 3600) return `${Math.floor(secs / 60)} min ago` + if (secs < 86400) return plural(Math.floor(secs / 3600), 'hour') + if (secs < 30 * 86400) return plural(Math.floor(secs / 86400), 'day') + return iso.slice(0, 10) +} diff --git a/test/compute-deployments.test.ts b/test/compute-deployments.test.ts new file mode 100644 index 0000000..6d03962 --- /dev/null +++ b/test/compute-deployments.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it, vi, afterEach, afterAll } from 'vitest' +import { relativeTime } from '../src/util.js' +import { deploymentLines, computeDeployments, type ServiceDeploymentView } from '../src/commands/compute.js' + +const NOW = new Date('2026-10-15T12:00:00Z') +const row = (over: Partial = {}): ServiceDeploymentView => ({ + id: '3f2a9c1e-0000-4000-8000-000000000001', status: 'succeeded', trigger: 'github', + image: 'registry/web:abc', pinnedImage: 'registry/web@sha256:9c1e04abdeadbeef', commitSha: 'a1b2c3d4e5f6', + buildId: null, buildSource: 'github', port: 8080, startCommand: null, actor: null, rollbackOf: null, error: null, + backfilled: false, createdAt: '2026-10-15T11:55:00Z', live: false, rollbackEligible: true, ...over, +}) + +describe('relativeTime', () => { + it('buckets', () => { + const at = (ms: number) => new Date(NOW.getTime() - ms).toISOString() + expect(relativeTime(at(10_000), NOW)).toBe('just now') + expect(relativeTime(at(5 * 60_000), NOW)).toBe('5 min ago') + expect(relativeTime(at(2 * 3600_000), NOW)).toBe('2 hours ago') + expect(relativeTime(at(3 * 86400_000), NOW)).toBe('3 days ago') + expect(relativeTime('2026-08-01T10:00:00Z', NOW)).toBe('2026-08-01') + }) +}) + +describe('deploymentLines', () => { + const rows = [ + row({ live: true, rollbackEligible: false, ineligibleReason: 'live' }), + row({ id: '7d41e0b2-0000-4000-8000-000000000002', commitSha: '9f8e7d6aaaa', pinnedImage: 'registry/web@sha256:51aa7c90ffff', createdAt: '2026-10-15T10:00:00Z' }), + row({ id: 'c09b11f4-0000-4000-8000-000000000003', status: 'failed', commitSha: null, pinnedImage: null, image: 'web:v2', error: 'app did not answer on port 8080', createdAt: '2026-10-15T09:00:00Z' }), + ] + it('lays out columns, live status, and the failed error', () => { + const lines = deploymentLines(rows, NOW) + expect(lines[0]).toMatch(/^ID\s+STATUS\s+TRIGGER\s+COMMIT\s+IMAGE\s+WHEN$/) + expect(lines[1]).toMatch(/^3f2a9c1e\s+live\s+github\s+a1b2c3d\s+…@sha256:9c1e04ab\s+5 min ago$/) + expect(lines[2]).toMatch(/^7d41e0b2\s+succeeded\s+github\s+9f8e7d6\s+…@sha256:51aa7c90\s+2 hours ago$/) + expect(lines[3]).toMatch(/^c09b11f4\s+failed\s+github\s+-\s+web:v2\s+3 hours ago\s+app did not answer on port 8080$/) + }) + it('says so when empty', () => { + expect(deploymentLines([], NOW)).toEqual(['(no deployments yet)']) + }) +}) + +describe('computeDeployments', () => { + const stdout: string[] = [] + const outSpy = vi.spyOn(process.stdout, 'write').mockImplementation((c: any) => { stdout.push(String(c)); return true }) + afterEach(() => { stdout.length = 0 }) + afterAll(() => outSpy.mockRestore()) + const PROJECT = { projectId: 'proj_1', branch: 'main' } + const fake = () => { + const gets: string[] = [] + const api: any = { + request: async (_m: string, path: string) => { + gets.push(path) + if (path.includes('/deployments')) return { deployments: [row({ live: true })], autoDeployPausedByRollback: false } + return { services: [{ id: 'svc_1', type: 'compute', name: 'web' }] } + }, + } + return { api, gets } + } + it('passes --limit and prints the table', async () => { + const { api, gets } = fake() + await computeDeployments('web', { limit: '5' }, { api, project: PROJECT }) + expect(gets).toContain('/projects/proj_1/services/svc_1/deployments?limit=5') + expect(stdout.join('')).toContain('3f2a9c1e') + }) + it('--json prints the API body', async () => { + const { api } = fake() + await computeDeployments('web', { json: true }, { api, project: PROJECT }) + expect(JSON.parse(stdout.join('')).deployments).toHaveLength(1) + }) + it('rejects a bad --limit before any request', async () => { + const { api, gets } = fake() + await expect(computeDeployments('web', { limit: '0' }, { api, project: PROJECT })).rejects.toThrow(/--limit/) + expect(gets).toEqual([]) + }) +}) diff --git a/test/compute-rollback.test.ts b/test/compute-rollback.test.ts new file mode 100644 index 0000000..236158e --- /dev/null +++ b/test/compute-rollback.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, it, vi, afterEach, afterAll } from 'vitest' +import * as clack from '@clack/prompts' +import { ApiError } from '../src/api.js' +import { pickRollbackTarget, rollbackDisclosure, computeRollback, type ServiceDeploymentView } from '../src/commands/compute.js' + +vi.mock('@clack/prompts', async (orig) => ({ ...(await orig()), confirm: vi.fn(async () => true) })) + +const LIVE = '3f2a9c1e-0000-4000-8000-000000000001' +const OLD = '7d41e0b2-0000-4000-8000-000000000002' +const OLD2 = '7d41e0b2-1111-4000-8000-000000000004' +const FAILED = 'c09b11f4-0000-4000-8000-000000000003' +const row = (over: Partial = {}): ServiceDeploymentView => ({ + id: OLD, status: 'succeeded', trigger: 'github', image: 'registry/web:abc', pinnedImage: 'registry/web@sha256:51aa7c90ffff', + commitSha: '9f8e7d6aaaa', buildId: null, buildSource: 'github', port: 8080, startCommand: null, actor: null, rollbackOf: null, + error: null, backfilled: false, createdAt: new Date(Date.now() - 2 * 3600_000).toISOString(), live: false, rollbackEligible: true, + variablesChangedSince: 2, ...over, +}) +const live = row({ id: LIVE, live: true, rollbackEligible: false, ineligibleReason: 'live' }) +const failed = row({ id: FAILED, status: 'failed', rollbackEligible: false, ineligibleReason: 'failed' }) +const same = row({ id: 'aaaaaaaa-0000-4000-8000-000000000009', rollbackEligible: false, ineligibleReason: 'same_image' }) + +describe('pickRollbackTarget', () => { + it('defaults to the first eligible row after the live one', () => { + expect(pickRollbackTarget([live, failed, same, row()], undefined).id).toBe(OLD) + }) + it('takes an exact id or a unique prefix of 8+ chars', () => { + expect(pickRollbackTarget([live, row()], OLD).id).toBe(OLD) + expect(pickRollbackTarget([live, row()], OLD.slice(0, 8)).id).toBe(OLD) + }) + it('refuses an ambiguous prefix', () => { + expect(() => pickRollbackTarget([live, row(), row({ id: OLD2 })], '7d41e0b2')).toThrow('deployment id prefix 7d41e0b2 matches 2 deployments') + }) + it('refuses an unknown id', () => { + expect(() => pickRollbackTarget([live, row()], 'deadbeef', 'web')).toThrow('no deployment deadbeef on web') + }) + it('refuses the live, failed and same-image rows with the server messages', () => { + expect(() => pickRollbackTarget([live, row()], LIVE)).toThrow('that deployment is already live') + expect(() => pickRollbackTarget([live, failed], FAILED)).toThrow('that deployment failed, so there is nothing to roll back to') + expect(() => pickRollbackTarget([live, same], same.id)).toThrow('that deployment runs the same image as the live one, use restart to redeploy it') + }) + it('says so when there is no default target', () => { + expect(() => pickRollbackTarget([live, failed], undefined)).toThrow(/nothing to roll back to/) + }) +}) + +describe('rollbackDisclosure', () => { + it('prints the four lines and omits the pause line without auto-deploy', () => { + const lines = rollbackDisclosure(row(), { port: 8080, autoDeployBranch: 'main', service: 'web', now: new Date() }) + expect(lines).toHaveLength(4) + expect(lines[0]).toMatch(/^Rolling back web to 7d41e0b2 \(commit 9f8e7d6, 2 hours ago\)$/) + expect(lines[1]).toBe(' restores image, port 8080, start command') + expect(lines[2]).toBe(' keeps variables (2 changed since), scale, domains, volume, databases') + expect(lines[3]).toBe(' pauses auto-deploy from main') + expect(rollbackDisclosure(row(), { port: 8080, autoDeployBranch: null, service: 'web', now: new Date() })).toHaveLength(3) + }) + it('warns when the image was never pinned', () => { + const lines = rollbackDisclosure(row({ pinnedImage: null }), { port: null, autoDeployBranch: null, service: 'web', now: new Date() }) + expect(lines).toContain(' warning this deployment used the tag registry/web:abc, which may point to a different image now') + }) +}) + +describe('computeRollback', () => { + const stdout: string[] = [] + const stderr: string[] = [] + const outSpy = vi.spyOn(process.stdout, 'write').mockImplementation((c: any) => { stdout.push(String(c)); return true }) + const errSpy = vi.spyOn(process.stderr, 'write').mockImplementation((c: any) => { stderr.push(String(c)); return true }) + afterEach(() => { stdout.length = 0; stderr.length = 0; process.exitCode = undefined; vi.mocked(clack.confirm).mockClear() }) + afterAll(() => { outSpy.mockRestore(); errSpy.mockRestore() }) + const PROJECT = { projectId: 'proj_1', branch: 'main' } + const fake = (o: { status?: number; paused?: boolean; source?: any; sourceErr?: number } = {}) => { + const posts: { path: string; body: any }[] = [] + const gets: string[] = [] + const api: any = { + request: async (_m: string, path: string) => { + gets.push(path) + if (path.endsWith('/source') || path.includes('/source?')) { + if (o.sourceErr) throw new ApiError(o.sourceErr, 'no source') + return { source: o.source ?? { type: 'github', owner: 'a', repo: 'b', branch: 'main', root_dir: null, auto_deploy: true, public: false } } + } + if (path.includes('/deployments')) return { deployments: [live, row()], autoDeployPausedByRollback: false } + return { services: [{ id: 'svc_1', type: 'compute', name: 'web' }] } + }, + rawRequest: async (_m: string, path: string, body?: unknown) => { + posts.push({ path, body }) + if (o.status === 202) return { status: 202, body: { status: 'approval_required', action: 'deploy', approvalId: 'ap_1' } } + return { status: 200, body: { deployment: row({ id: 'beefbeef-0000-4000-8000-0000000000aa', live: true, trigger: 'rollback' }), service: { id: 'svc_1' }, autoDeployPaused: o.paused ?? true } } + }, + } + return { api, posts, gets } + } + const withTty = async (fn: () => Promise) => { + const i = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY'), o = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY') + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }) + Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true }) + try { await fn() } finally { + for (const [s, d] of [[process.stdin, i], [process.stdout, o]] as const) { if (d) Object.defineProperty(s, 'isTTY', d); else delete (s as any).isTTY } + } + } + + it('--json without --yes throws and makes no request', async () => { + const { api, posts, gets } = fake() + await expect(computeRollback('web', { json: true }, { api, project: PROJECT })).rejects.toThrow(/--yes/) + expect(posts).toEqual([]) + expect(gets).toEqual([]) + expect(stdout.join('')).toBe('') + }) + + it('--json --yes prints exactly one JSON document', async () => { + const { api, posts } = fake() + await computeRollback('web', { json: true, yes: true }, { api, project: PROJECT }) + expect(posts).toEqual([{ path: '/projects/proj_1/services/svc_1/rollback', body: { deploymentId: OLD } }]) + expect(JSON.parse(stdout.join('')).autoDeployPaused).toBe(true) + }) + + it('prints the disclosure before it confirms, then the success and paused lines', async () => { + const { api } = fake() + let seen = '' + vi.mocked(clack.confirm).mockImplementationOnce((async () => { seen = stdout.join(''); return true }) as any) + await withTty(() => computeRollback('web', {}, { api, project: PROJECT })) + expect(seen).toContain('Rolling back web to 7d41e0b2') + expect(seen).toContain('pauses auto-deploy from main') + const out = stdout.join('') + expect(out).toContain('rolled back web to 7d41e0b2. beefbeef is live.') + expect(out).toContain('auto-deploy is paused. Turn it back on in the console: Settings → Source') + }) + + it('no pauses line without a github source, and no paused line when not paused', async () => { + const { api } = fake({ sourceErr: 404, paused: false }) + await computeRollback('web', { yes: true }, { api, project: PROJECT }) + const out = stdout.join('') + expect(out).not.toContain('pauses') + expect(out).not.toContain('auto-deploy is paused') + expect(out).toContain('rolled back web to 7d41e0b2. beefbeef is live.') + }) + + it('auto_deploy false means no pauses line', async () => { + const { api } = fake({ source: { type: 'github', owner: 'a', repo: 'b', branch: 'main', root_dir: null, auto_deploy: false, public: false } }) + await computeRollback('web', { yes: true }, { api, project: PROJECT }) + expect(stdout.join('')).not.toContain('pauses') + }) + + it('a full uuid skips the deployments lookup', async () => { + const { api, posts, gets } = fake() + await computeRollback('web', { to: OLD, yes: true }, { api, project: PROJECT }) + expect(gets.some((g) => g.includes('/deployments'))).toBe(false) + expect(posts[0]!.body).toEqual({ deploymentId: OLD }) + }) + + it('a prefix resolves through the first page at limit=100', async () => { + const { api, posts, gets } = fake() + await computeRollback('web', { to: OLD.slice(0, 8), yes: true }, { api, project: PROJECT }) + expect(gets).toContain('/projects/proj_1/services/svc_1/deployments?limit=100') + expect(posts[0]!.body).toEqual({ deploymentId: OLD }) + }) + + it('declining the confirm posts nothing', async () => { + const { api, posts } = fake() + vi.mocked(clack.confirm).mockImplementationOnce((async () => false) as any) + await withTty(() => computeRollback('web', {}, { api, project: PROJECT })) + expect(posts).toEqual([]) + }) + + it('a 202 goes through handleApproval', async () => { + const { api } = fake({ status: 202 }) + await computeRollback('web', { yes: true }, { api, project: PROJECT }) + expect(process.exitCode).toBe(2) + expect(stderr.join('')).toContain('approval required') + expect(stdout.join('')).not.toContain('rolled back') + }) + + it('refuses without a terminal and without --yes', async () => { + const { api, posts } = fake() + await expect(computeRollback('web', {}, { api, project: PROJECT })).rejects.toThrow(/--yes/) + expect(posts).toEqual([]) + }) +}) From 10b17e3dae9149fca98af1fb9e9dabbb90d662aa Mon Sep 17 00:00:00 2001 From: CarmenDou <15951653662@163.com> Date: Thu, 15 Oct 2026 23:51:46 -0700 Subject: [PATCH 2/3] fix(compute): detect the framework 404 from the response body --- src/commands/compute.ts | 4 +++- test/compute-deployments.test.ts | 19 +++++++++++++++++++ test/compute-rollback.test.ts | 21 +++++++++++++++++++++ 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/commands/compute.ts b/src/commands/compute.ts index 51522af..dff7aee 100644 --- a/src/commands/compute.ts +++ b/src/commands/compute.ts @@ -495,7 +495,9 @@ export function rollbackDisclosure( ] } -const noRoute = (e: unknown) => e instanceof ApiError && e.status === 404 && /Route (GET|POST):/.test(e.message) +// Fastify's default 404 carries the "Route GET:... not found" text in body.message, while ApiError.message is body.error ("Not Found"). +const noRoute = (e: unknown) => + e instanceof ApiError && e.status === 404 && /^Route (GET|POST):/.test(String(e.body?.message ?? e.message)) const NO_ROUTE = 'this platform does not support deployment history yet' async function deploymentsTarget(opts: { branch?: string }, deps?: DomainDeps) { diff --git a/test/compute-deployments.test.ts b/test/compute-deployments.test.ts index 6d03962..e578ced 100644 --- a/test/compute-deployments.test.ts +++ b/test/compute-deployments.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi, afterEach, afterAll } from 'vitest' +import { ApiError } from '../src/api.js' import { relativeTime } from '../src/util.js' import { deploymentLines, computeDeployments, type ServiceDeploymentView } from '../src/commands/compute.js' @@ -73,3 +74,21 @@ describe('computeDeployments', () => { expect(gets).toEqual([]) }) }) + +describe('computeDeployments on a platform without the route', () => { + const frameworkBody = { message: 'Route GET:/projects/p/services/s/deployments not found', error: 'Not Found', statusCode: 404 } + const mk = (err: ApiError) => ({ + request: async (_m: string, path: string) => { + if (path.includes('/deployments')) throw err + return { services: [{ id: 'svc_1', type: 'compute', name: 'web' }] } + }, + }) as any + it('maps the framework 404 to the friendly message', async () => { + await expect(computeDeployments('web', {}, { api: mk(new ApiError(404, 'Not Found', frameworkBody)), project: { projectId: 'p' } })) + .rejects.toThrow('this platform does not support deployment history yet') + }) + it('leaves a non-framework 404 alone', async () => { + await expect(computeDeployments('web', {}, { api: mk(new ApiError(404, 'no such service', { error: 'no such service' })), project: { projectId: 'p' } })) + .rejects.toThrow('no such service') + }) +}) diff --git a/test/compute-rollback.test.ts b/test/compute-rollback.test.ts index 236158e..90cefb3 100644 --- a/test/compute-rollback.test.ts +++ b/test/compute-rollback.test.ts @@ -174,3 +174,24 @@ describe('computeRollback', () => { expect(posts).toEqual([]) }) }) + +describe('computeRollback on a platform without the routes', () => { + const body = (m: string) => ({ message: `Route ${m}:/projects/p/services/svc_1/x not found`, error: 'Not Found', statusCode: 404 }) + const base = { request: async (_m: string, path: string): Promise => ({ services: [{ id: 'svc_1', type: 'compute', name: 'web' }] }) } + const dep = { project: { projectId: 'p' } } + it('list GET framework 404 becomes the friendly message', async () => { + const api: any = { ...base, request: async (_m: string, path: string) => { + if (path.includes('/deployments')) throw new ApiError(404, 'Not Found', body('GET')) + return base.request(_m, path) + } } + await expect(computeRollback('web', { yes: true }, { api, ...dep })).rejects.toThrow('this platform does not support deployment history yet') + }) + it('POST framework 404 becomes the friendly message', async () => { + const api: any = { ...base, rawRequest: async () => { throw new ApiError(404, 'Not Found', body('POST')) } } + await expect(computeRollback('web', { yes: true, to: OLD }, { api, ...dep })).rejects.toThrow('this platform does not support deployment history yet') + }) + it('a real 404 for the deployment is not rewritten', async () => { + const api: any = { ...base, rawRequest: async () => { throw new ApiError(404, 'no such deployment for this service', { error: 'no such deployment for this service' }) } } + await expect(computeRollback('web', { yes: true, to: OLD }, { api, ...dep })).rejects.toThrow('no such deployment for this service') + }) +}) From 21baacc103aaac9e260bc23e36d77ee79d256cab Mon Sep 17 00:00:00 2001 From: CarmenDou <15951653662@163.com> Date: Fri, 16 Oct 2026 03:27:21 -0700 Subject: [PATCH 3/3] fix(compute): say a backfilled rollback target restores only the image --- src/commands/compute.ts | 25 ++++++++++++++-------- src/index.ts | 2 +- test/compute-rollback.test.ts | 40 ++++++++++++++++++++++++++++++----- 3 files changed, 52 insertions(+), 15 deletions(-) diff --git a/src/commands/compute.ts b/src/commands/compute.ts index dff7aee..25bbdda 100644 --- a/src/commands/compute.ts +++ b/src/commands/compute.ts @@ -486,9 +486,11 @@ export function rollbackDisclosure( ): string[] { const commit = target.commitSha ? `commit ${target.commitSha.slice(0, 7)}, ` : '' const changed = target.variablesChangedSince ? ` (${target.variablesChangedSince} changed since)` : '' + // A backfilled row recorded no port or start command, so the platform keeps the current ones. return [ `Rolling back ${opts.service} to ${target.id.slice(0, 8)} (${commit}${relativeTime(target.createdAt, opts.now)})`, - ` restores image, ${opts.port != null ? `port ${opts.port}, ` : ''}start command`, + target.backfilled ? ' restores image' : ` restores image, ${opts.port != null ? `port ${opts.port}, ` : ''}start command`, + ...(target.backfilled ? [' keeps port and start command (not recorded for this deployment)'] : []), ` keeps variables${changed}, scale, domains, volume, databases`, ...(opts.autoDeployBranch ? [` pauses auto-deploy from ${opts.autoDeployBranch}`] : []), ...(target.pinnedImage ? [] : [` warning this deployment used the tag ${target.image}, which may point to a different image now`]), @@ -546,17 +548,22 @@ export async function computeRollback( const svc = resolveSoleService(services as Array<{ id: string; type: string; name: string }>, 'compute', serviceName) const base = `/projects/${p.projectId}/services/${svc.id}` - // A full id goes to the server as is (it validates), no page lookup. - let target: ServiceDeploymentView | null = null - let targetId: string - if (opts.to && UUID_RE.test(opts.to)) targetId = opts.to - else { - let page: DeploymentsBody - try { page = await api.request('GET', `${base}/deployments?limit=100`) } catch (e) { + const firstPage = async (): Promise => { + try { return await api.request('GET', `${base}/deployments?limit=100`) } catch (e) { if (noRoute(e)) throw new Error(NO_ROUTE) throw e } - target = pickRollbackTarget(page.deployments, opts.to, svc.name) + } + // A full id goes to the server as is (it validates). The first page only makes the disclosure + // specific, and an id not on it gets the generic one. + let target: ServiceDeploymentView | null = null + let targetId: string + if (opts.to && UUID_RE.test(opts.to)) { + const want = opts.to.toLowerCase() + targetId = opts.to + if (!opts.json) target = (await firstPage()).deployments.find((d) => d.id.toLowerCase() === want) ?? null + } else { + target = pickRollbackTarget((await firstPage()).deployments, opts.to, svc.name) targetId = target.id } diff --git a/src/index.ts b/src/index.ts index bf303ef..e91f9db 100644 --- a/src/index.ts +++ b/src/index.ts @@ -450,7 +450,7 @@ compute.command('start-command [service]').description('Show or stage a compute compute.command('deployments [service]').description('List a compute service\'s deployment history, newest first: which one is live, what started each, and why a failed one failed') .option('--limit ', 'rows to show (1..100, default 20)').option('--json').option('--branch ', 'branch (default: current)') .action(guard((service, o) => computeCmd.computeDeployments(service, o))) -compute.command('rollback [service]').description("Roll a compute service back to an earlier deployment: restores that deployment's image, port and start command, keeps variables, scale, domains, volume and databases, and pauses GitHub auto-deploy until it is turned back on in the console (Settings → Source). With no --to, the target is the newest successful deployment before the live one that runs a different image. Gated: deploy. Asks on a terminal, anywhere else needs --yes") +compute.command('rollback [service]').description("Roll a compute service back to an earlier deployment: restores that deployment's image, port and start command (only the image for a backfilled deployment, which recorded neither), keeps variables, scale, domains, volume and databases, and pauses GitHub auto-deploy if it was on, until it is turned back on in the console (Settings → Source). With no --to, the target is the newest successful deployment before the live one that runs a different image. Gated: deploy. Asks on a terminal, anywhere else needs --yes") .option('--to ', 'deployment id (full, or a unique prefix of at least 8 characters)').option('--yes', 'skip the confirmation').option('--json').option('--branch ', 'branch (default: current)') .action(guard((service, o) => computeCmd.computeRollback(service, o))) compute.command('volume [service]').description("Show, attach, grow, remount, or delete a compute service's persistent volume. --mount-path alone stages an existing volume path change, pending until deploy or restart; an unchanged normalized path is a no-op readback. No flag: print size, mount path, and the plan cap (any plan). --size on a volumeless service ATTACHES one (any plan up to its own plan cap — 10Gi free, 50Gi paid by default — which is also what a disk with no size named is born at; a size above the free cap is paid; the disk mounts at --mount-path (default /data) on the next deploy); on a volume-bearing one it grows (paid plans; grow-only — a provisioned disk cannot shrink). --delete DESTROYS the disk and ALL its data immediately (no detach, no undo; billing stops now, and suspend fast-wake + scale-out return). Billing is actual data stored — the size is a cap, not a price") diff --git a/test/compute-rollback.test.ts b/test/compute-rollback.test.ts index 90cefb3..e342308 100644 --- a/test/compute-rollback.test.ts +++ b/test/compute-rollback.test.ts @@ -53,6 +53,15 @@ describe('rollbackDisclosure', () => { expect(lines[3]).toBe(' pauses auto-deploy from main') expect(rollbackDisclosure(row(), { port: 8080, autoDeployBranch: null, service: 'web', now: new Date() })).toHaveLength(3) }) + it('a backfilled target restores only the image and says the port and start command stay', () => { + const lines = rollbackDisclosure(row({ backfilled: true, port: null, variablesChangedSince: undefined }), { port: null, autoDeployBranch: 'main', service: 'web', now: new Date() }) + expect(lines.slice(1)).toEqual([ + ' restores image', + ' keeps port and start command (not recorded for this deployment)', + ' keeps variables, scale, domains, volume, databases', + ' pauses auto-deploy from main', + ]) + }) it('warns when the image was never pinned', () => { const lines = rollbackDisclosure(row({ pinnedImage: null }), { port: null, autoDeployBranch: null, service: 'web', now: new Date() }) expect(lines).toContain(' warning this deployment used the tag registry/web:abc, which may point to a different image now') @@ -67,7 +76,7 @@ describe('computeRollback', () => { afterEach(() => { stdout.length = 0; stderr.length = 0; process.exitCode = undefined; vi.mocked(clack.confirm).mockClear() }) afterAll(() => { outSpy.mockRestore(); errSpy.mockRestore() }) const PROJECT = { projectId: 'proj_1', branch: 'main' } - const fake = (o: { status?: number; paused?: boolean; source?: any; sourceErr?: number } = {}) => { + const fake = (o: { status?: number; paused?: boolean; source?: any; sourceErr?: number; rows?: ServiceDeploymentView[] } = {}) => { const posts: { path: string; body: any }[] = [] const gets: string[] = [] const api: any = { @@ -77,7 +86,7 @@ describe('computeRollback', () => { if (o.sourceErr) throw new ApiError(o.sourceErr, 'no source') return { source: o.source ?? { type: 'github', owner: 'a', repo: 'b', branch: 'main', root_dir: null, auto_deploy: true, public: false } } } - if (path.includes('/deployments')) return { deployments: [live, row()], autoDeployPausedByRollback: false } + if (path.includes('/deployments')) return { deployments: o.rows ?? [live, row()], autoDeployPausedByRollback: false } return { services: [{ id: 'svc_1', type: 'compute', name: 'web' }] } }, rawRequest: async (_m: string, path: string, body?: unknown) => { @@ -139,9 +148,29 @@ describe('computeRollback', () => { expect(stdout.join('')).not.toContain('pauses') }) - it('a full uuid skips the deployments lookup', async () => { - const { api, posts, gets } = fake() + it('a full uuid finds its row on the first page for a specific disclosure', async () => { + const { api, posts, gets } = fake({ rows: [live, row({ backfilled: true, port: null, variablesChangedSince: undefined })] }) await computeRollback('web', { to: OLD, yes: true }, { api, project: PROJECT }) + expect(gets).toContain('/projects/proj_1/services/svc_1/deployments?limit=100') + const out = stdout.join('') + expect(out).toContain('Rolling back web to 7d41e0b2 (commit 9f8e7d6, 2 hours ago)') + expect(out).toContain(' restores image\n') + expect(out).toContain(' keeps port and start command (not recorded for this deployment)') + expect(posts[0]!.body).toEqual({ deploymentId: OLD }) + }) + + it('a full uuid not on the first page gets the generic disclosure and still posts', async () => { + const { api, posts } = fake({ rows: [live] }) + await computeRollback('web', { to: OLD, yes: true }, { api, project: PROJECT }) + const out = stdout.join('') + expect(out).toContain('Rolling back web to 7d41e0b2\n') + expect(out).toContain(' restores image, port, start command') + expect(posts[0]!.body).toEqual({ deploymentId: OLD }) + }) + + it('a full uuid with --json skips the deployments lookup', async () => { + const { api, posts, gets } = fake() + await computeRollback('web', { to: OLD, yes: true, json: true }, { api, project: PROJECT }) expect(gets.some((g) => g.includes('/deployments'))).toBe(false) expect(posts[0]!.body).toEqual({ deploymentId: OLD }) }) @@ -177,7 +206,8 @@ describe('computeRollback', () => { describe('computeRollback on a platform without the routes', () => { const body = (m: string) => ({ message: `Route ${m}:/projects/p/services/svc_1/x not found`, error: 'Not Found', statusCode: 404 }) - const base = { request: async (_m: string, path: string): Promise => ({ services: [{ id: 'svc_1', type: 'compute', name: 'web' }] }) } + const base = { request: async (_m: string, path: string): Promise => + (path.includes('/deployments') ? { deployments: [] } : { services: [{ id: 'svc_1', type: 'compute', name: 'web' }] }) } const dep = { project: { projectId: 'p' } } it('list GET framework 404 becomes the friendly message', async () => { const api: any = { ...base, request: async (_m: string, path: string) => {