From a70bed34d1082ad60bc59fa8fe3720029508bd1f Mon Sep 17 00:00:00 2001 From: CarmenDou <15951653662@163.com> Date: Thu, 8 Oct 2026 20:17:23 -0700 Subject: [PATCH 1/9] feat(template): upgrade and roll back a deployed template instance --- src/commands/template.ts | 120 ++++++++++++++++++++++++++++++++++ src/index.ts | 11 ++++ test/help-surface.test.ts | 4 +- test/template-upgrade.test.ts | 43 ++++++++++++ 4 files changed, 176 insertions(+), 2 deletions(-) create mode 100644 test/template-upgrade.test.ts diff --git a/src/commands/template.ts b/src/commands/template.ts index b8bb9ae..0e331f7 100644 --- a/src/commands/template.ts +++ b/src/commands/template.ts @@ -458,3 +458,123 @@ export async function templateDeploy(target: string, opts: TemplateDeployOpts = info('next: `insta postgres url` prints the postgres DSN; bind service credentials into compute with `insta secrets bind`; `insta secrets` refreshes user-defined secrets in .env') renderNextActions(dep.nextActions) } + +// ---- upgrade / rollback ---- + +export type TemplateUpgradeOpts = { set?: string[]; branch?: string; yes?: boolean; json?: boolean } +export type TemplateRollbackOpts = { branch?: string; yes?: boolean; json?: boolean } + +type PlanField = { field: string; label: string; deployed: string | null; live: string | null; next: string | null; drifted: boolean; verdict: string } +type PlanService = { key: string; service_name: string | null; gone: boolean; added: boolean; fields: PlanField[]; missing_variables: string[] } +type UpgradePlanBody = { from_version: string; to_version: string; to_digest: string; services: PlanService[]; removed: string[]; refusals: string[] } + +/** The review, as lines. Current is the LIVE value: what the upgrade will overwrite. */ +export function upgradePlanLines(plan: UpgradePlanBody): string[] { + if (plan.refusals.length) { + return ['This upgrade cannot run:', ...plan.refusals.map((r) => ` ${r}`)] + } + const lines = [`${plan.from_version} → ${plan.to_version}`] + for (const s of plan.services) { + const who = s.service_name ?? s.key + if (s.gone) { lines.push(` ${who} no longer exists, skipped`); continue } + if (s.added) { lines.push(` ${who} added by ${plan.to_version}`); continue } + for (const f of s.fields) { + const drift = f.drifted ? ' (you changed this, the upgrade will overwrite it)' : '' + lines.push(` ${who} ${f.label} ${f.live ?? '(none)'} → ${f.next}${drift}`) + } + for (const name of s.missing_variables) { + lines.push(` ${who} ${name} is required by ${plan.to_version} and has no value, pass --set ${name}=...`) + } + } + for (const key of plan.removed) lines.push(` ${key} dropped by ${plan.to_version}, left running`) + return lines +} + +/** + * A service name is what a person has; a deployment uuid is not. The services list already carries + * the attribution, so the lookup lives here rather than in the user's head. + */ +async function deploymentOfService(api: TemplateApi, projectId: string, branch: string, service: string) { + const body = await api.request('GET', `/projects/${projectId}/services?branch=${encodeURIComponent(branch)}`) + const row = (body.services ?? []).find((s: any) => s.name === service) + if (!row) throw new Error(`no service named ${service} on branch ${branch}`) + if (!row.template_deployment_id) throw new Error(`${service} was not deployed from a template, so there is nothing to upgrade`) + return { deploymentId: row.template_deployment_id as string } +} + +export async function templateUpgrade(service: string, opts: TemplateUpgradeOpts = {}, deps: TemplateDeployDeps = {}): Promise { + const given = parseSetFlags(opts.set ?? []) // a typo'd --set fails before any network access + const api = deps.api ?? (await ApiClient.load()) + const p = deps.project ?? (await requireProject()) + const branchName = opts.branch ?? p.branch + const ask = deps.ask ?? promptVariable + + const target = await deploymentOfService(api, p.projectId, branchName, service) + // The poll and preview routes are keyed by deployment id, not project: name the project so agent + // mode signs with the project-bound session, exactly as templateDeploy does. + const { plan } = await api.request('GET', `/template-deployments/${target.deploymentId}/upgrade`, undefined, { projectId: p.projectId }) + if (opts.json) { + printJson(plan) + if (plan.refusals.length) process.exitCode = 1 + return + } + + for (const line of upgradePlanLines(plan)) info(line) + // A refusal is not a prompt to confirm past: there is nothing to confirm. + if (plan.refusals.length) { process.exitCode = 1; return } + + // Only what the new version ADDS is ever asked for: the platform recovers every value the + // instance already holds, so re-prompting would invite the user to overwrite a live credential. + const missing = plan.services.flatMap((s: PlanService) => s.missing_variables.map((name) => ({ name, required: true }) as TemplateVar)) + const tty = !opts.yes && !!process.stdin.isTTY && !!process.stdout.isTTY + const variables = missing.length ? await resolveVariables(missing, given, { tty, ask }) : given + + if (!opts.yes && tty) { + const go = await clack.confirm({ message: `Upgrade ${service} from ${plan.from_version} to ${plan.to_version}?` }) + if (clack.isCancel(go)) throw new CliCancel() + if (!go) return + } + + // The version and digest the user just read. A republish in between answers 409 rather than + // running something they never saw. + const res = await api.rawRequest('POST', `/projects/${p.projectId}/template-deployments/${target.deploymentId}/upgrade`, { + variables, expectedVersion: plan.to_version, expectedDigest: plan.to_digest, + }) + if (handleApproval(res, opts.json)) return + + const deploymentId = res.body.deploymentId ?? (res.body.deployment ?? res.body).id + info(`upgrading ${service} to ${plan.to_version} (${deploymentId})`) + const dep = await watchDeployment((id) => api.request('GET', `/template-deployments/${id}`, undefined, { projectId: p.projectId }), deploymentId, info, deps.wait) + info(`${service} is on ${plan.to_version}`) + info(`next: \`insta template rollback ${service}\` returns it to ${plan.from_version}`) + renderNextActions(dep.nextActions) +} + +export async function templateRollback(service: string, opts: TemplateRollbackOpts = {}, deps: TemplateDeployDeps = {}): Promise { + const quiet = !!opts.json + const out = quiet ? () => {} : info + const api = deps.api ?? (await ApiClient.load()) + const p = deps.project ?? (await requireProject()) + const branchName = opts.branch ?? p.branch + const target = await deploymentOfService(api, p.projectId, branchName, service) + + // Said BEFORE the confirmation, not after the fact: these two are the whole reason someone + // regrets a rollback, and they are not recoverable by running it again. + out('Going back restores the image, the start command, the port and the recorded variables.') + out('It does not restore data the app migrated under the newer version, and a volume only grows.') + const tty = !opts.yes && !!process.stdin.isTTY && !!process.stdout.isTTY + if (!opts.yes && tty) { + const go = await clack.confirm({ message: `Return ${service} to the version it was upgraded from?` }) + if (clack.isCancel(go)) throw new CliCancel() + if (!go) return + } + + const res = await api.rawRequest('POST', `/projects/${p.projectId}/template-deployments/${target.deploymentId}/rollback`, {}) + if (handleApproval(res, opts.json)) return + const deploymentId = res.body.deploymentId ?? (res.body.deployment ?? res.body).id + out(`rolling ${service} back (${deploymentId})`) + const dep = await watchDeployment((id) => api.request('GET', `/template-deployments/${id}`, undefined, { projectId: p.projectId }), deploymentId, out, deps.wait) + if (quiet) return printJson(dep) + info(`${service} is back on ${(dep.deployment ?? dep).template_version ?? 'its previous version'}`) + renderNextActions(dep.nextActions) +} diff --git a/src/index.ts b/src/index.ts index bb503e0..d82469b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -573,6 +573,17 @@ tpl.command('deploy ').description('Deploy a template onto a .option('-y, --yes', 'non-interactive: missing required variables fail with a --set list instead of prompting') .option('--json') .action(guard((target, o) => template.templateDeploy(target, o))) +tpl.command('upgrade ').description('Move a service deployed from a template to the version the registry serves now. Shows what will change and asks before it runs. The same services are reused: image, start command, port and variables are rewritten; always-on, public access and the volume are left alone') + .option('--branch ', 'branch the service is on (default: current)') + .option('--set ', 'set a template variable the new version requires (repeatable)', (v: string, prev: string[]) => [...prev, v], [] as string[]) + .option('-y, --yes', 'do not ask for confirmation') + .option('--json', 'print the plan and exit without upgrading') + .action(guard((service, o) => template.templateUpgrade(service, o))) +tpl.command('rollback ').description('Return a service to the template version it was upgraded from, one step. Restores the image, start command, port and recorded variables; does not restore data the app migrated, and a volume only grows') + .option('--branch ', 'branch the service is on (default: current)') + .option('-y, --yes', 'do not ask for confirmation') + .option('--json', 'print the finished deployment as JSON and suppress progress lines') + .action(guard((service, o) => template.templateRollback(service, o))) // Authoring: the org's community templates, the same drafts the console editor changes. tpl.command('create').description("Create a community template draft from the linked project (or --project), or an empty one with --blank. Prints its code, status and the console editor link") .option('--project ', 'project to generate the draft from (default: the linked project)') diff --git a/test/help-surface.test.ts b/test/help-surface.test.ts index 0bd304b..ce6444a 100644 --- a/test/help-surface.test.ts +++ b/test/help-surface.test.ts @@ -133,9 +133,9 @@ describe('group shapes', () => { expect(r.stdout).toContain('--json') }, 30_000) // Authoring follows browsing and deploying: the console editor's drafts, one verb each. - it('template keeps list, info and deploy, then the authoring verbs', () => { + it('template keeps list, info, deploy, upgrade and rollback, then the authoring verbs', () => { const help = run(['template', '--help']).stdout - expect(commandNames(help)).toEqual(['list', 'info', 'deploy', 'create', 'drafts', 'draft', 'edit', 'regenerate', 'publish', 'unpublish', 'delete']) + expect(commandNames(help)).toEqual(['list', 'info', 'deploy', 'upgrade', 'rollback', 'create', 'drafts', 'draft', 'edit', 'regenerate', 'publish', 'unpublish', 'delete']) expect(help.replace(/\s+/g, ' ')).toContain("and author your org's community templates") const create = run(['template', 'create', '--help']).stdout for (const flag of ['--project ', '--blank', '--name ', '--org ', '--json']) expect(create, flag).toContain(flag) diff --git a/test/template-upgrade.test.ts b/test/template-upgrade.test.ts new file mode 100644 index 0000000..9059ad3 --- /dev/null +++ b/test/template-upgrade.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { upgradePlanLines } from '../src/commands/template.js' + +const plan = (over: Record = {}) => ({ + from_version: '1.3.2', to_version: '1.4.0', to_digest: 'a'.repeat(64), removed: [], refusals: [], + services: [{ + key: 'app', service_id: 'svc_1', service_name: 'n8n', gone: false, added: false, missing_variables: [], + fields: [{ field: 'image', label: 'Image', deployed: 'n8nio/n8n:2.36.5', live: 'n8nio/n8n:2.36.5', next: 'n8nio/n8n:2.41.0', drifted: false, verdict: 'applied' }], + }], + ...over, +}) + +describe('upgradePlanLines', () => { + it('prints the version move and the applied change', () => { + const lines = upgradePlanLines(plan()) + expect(lines[0]).toContain('1.3.2 → 1.4.0') + expect(lines.join('\n')).toContain('n8n Image n8nio/n8n:2.36.5 → n8nio/n8n:2.41.0') + }) + + it('marks a field the user changed', () => { + const p = plan() + p.services[0].fields[0] = { ...p.services[0].fields[0], live: 'n8nio/n8n:custom', drifted: true } + expect(upgradePlanLines(p).join('\n')).toContain('you changed this') + }) + + it('prints a declared setting it will write', () => { + const p = plan() + p.services[0].fields.push({ field: 'always_on', label: 'Always on', deployed: null, live: 'off', next: 'on', drifted: false, verdict: 'applied' }) + expect(upgradePlanLines(p).join('\n')).toContain('Always on off → on') + }) + + it('leads with the refusals when there are any', () => { + const p = plan({ refusals: ['service app would change type from web to worker'] }) + expect(upgradePlanLines(p)[0]).toContain('cannot run') + expect(upgradePlanLines(p).join('\n')).toContain('would change type') + }) + + it('names required variables with no value', () => { + const p = plan() + p.services[0].missing_variables = ['N8N_ENCRYPTION_KEY'] + expect(upgradePlanLines(p).join('\n')).toContain('N8N_ENCRYPTION_KEY') + }) +}) From 8bd65a577397b3f4e6487bd2f4faed7d81f646eb Mon Sep 17 00:00:00 2001 From: CarmenDou <15951653662@163.com> Date: Thu, 8 Oct 2026 20:19:15 -0700 Subject: [PATCH 2/9] feat(template): --json --yes applies an upgrade; cover the upgrade and rollback flows --- src/commands/template.ts | 11 ++-- src/index.ts | 2 +- test/template-upgrade.test.ts | 100 +++++++++++++++++++++++++++++++++- 3 files changed, 106 insertions(+), 7 deletions(-) diff --git a/src/commands/template.ts b/src/commands/template.ts index 0e331f7..5f809bb 100644 --- a/src/commands/template.ts +++ b/src/commands/template.ts @@ -513,13 +513,15 @@ export async function templateUpgrade(service: string, opts: TemplateUpgradeOpts // The poll and preview routes are keyed by deployment id, not project: name the project so agent // mode signs with the project-bound session, exactly as templateDeploy does. const { plan } = await api.request('GET', `/template-deployments/${target.deploymentId}/upgrade`, undefined, { projectId: p.projectId }) - if (opts.json) { + // --json alone is "tell me" (the plan, no upgrade); --json --yes is "do it and tell me". + const quiet = !!opts.json + if (opts.json && (!opts.yes || plan.refusals.length)) { printJson(plan) if (plan.refusals.length) process.exitCode = 1 return } - for (const line of upgradePlanLines(plan)) info(line) + if (!quiet) for (const line of upgradePlanLines(plan)) info(line) // A refusal is not a prompt to confirm past: there is nothing to confirm. if (plan.refusals.length) { process.exitCode = 1; return } @@ -543,8 +545,9 @@ export async function templateUpgrade(service: string, opts: TemplateUpgradeOpts if (handleApproval(res, opts.json)) return const deploymentId = res.body.deploymentId ?? (res.body.deployment ?? res.body).id - info(`upgrading ${service} to ${plan.to_version} (${deploymentId})`) - const dep = await watchDeployment((id) => api.request('GET', `/template-deployments/${id}`, undefined, { projectId: p.projectId }), deploymentId, info, deps.wait) + if (!quiet) info(`upgrading ${service} to ${plan.to_version} (${deploymentId})`) + const dep = await watchDeployment((id) => api.request('GET', `/template-deployments/${id}`, undefined, { projectId: p.projectId }), deploymentId, quiet ? () => {} : info, deps.wait) + if (quiet) return printJson(dep) info(`${service} is on ${plan.to_version}`) info(`next: \`insta template rollback ${service}\` returns it to ${plan.from_version}`) renderNextActions(dep.nextActions) diff --git a/src/index.ts b/src/index.ts index d82469b..31ec360 100644 --- a/src/index.ts +++ b/src/index.ts @@ -577,7 +577,7 @@ tpl.command('upgrade ').description('Move a service deployed from a tem .option('--branch ', 'branch the service is on (default: current)') .option('--set ', 'set a template variable the new version requires (repeatable)', (v: string, prev: string[]) => [...prev, v], [] as string[]) .option('-y, --yes', 'do not ask for confirmation') - .option('--json', 'print the plan and exit without upgrading') + .option('--json', 'machine-readable output: alone it prints the plan and does not upgrade; with --yes it upgrades and prints the finished deployment') .action(guard((service, o) => template.templateUpgrade(service, o))) tpl.command('rollback ').description('Return a service to the template version it was upgraded from, one step. Restores the image, start command, port and recorded variables; does not restore data the app migrated, and a volume only grows') .option('--branch ', 'branch the service is on (default: current)') diff --git a/test/template-upgrade.test.ts b/test/template-upgrade.test.ts index 9059ad3..404c344 100644 --- a/test/template-upgrade.test.ts +++ b/test/template-upgrade.test.ts @@ -1,5 +1,8 @@ -import { describe, expect, it } from 'vitest' -import { upgradePlanLines } from '../src/commands/template.js' +import { describe, expect, it, vi, afterEach, afterAll } from 'vitest' +import * as clack from '@clack/prompts' +import { upgradePlanLines, templateUpgrade, templateRollback } from '../src/commands/template.js' + +vi.mock('@clack/prompts', async (orig) => ({ ...(await orig()), confirm: vi.fn(async () => true) })) const plan = (over: Record = {}) => ({ from_version: '1.3.2', to_version: '1.4.0', to_digest: 'a'.repeat(64), removed: [], refusals: [], @@ -41,3 +44,96 @@ describe('upgradePlanLines', () => { expect(upgradePlanLines(p).join('\n')).toContain('N8N_ENCRYPTION_KEY') }) }) + +const PROJECT = { projectId: 'proj_1', orgId: 'org_1', branch: 'main' } +const NO_WAIT = async () => {} + +function fakeApi(opts: { plan?: any; services?: any[] } = {}) { + const posts: { path: string; body: any }[] = [] + const gets: string[] = [] + const api = { + request: async (_m: string, path: string) => { + gets.push(path) + if (path.startsWith('/projects/')) return { services: opts.services ?? [{ name: 'n8n', template_deployment_id: 'dep_9' }] } + if (path.endsWith('/upgrade')) return { plan: opts.plan ?? plan() } + return { status: 'succeeded', step: 'done', template_version: '1.3.2' } + }, + rawRequest: async (_m: string, path: string, body?: unknown) => { + posts.push({ path, body }) + return { status: 202, body: { deploymentId: 'dep_9' } } + }, + } + return { api, posts, gets } +} + +describe('upgrade and rollback flows', () => { + 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 deps = (api: any) => ({ api, project: PROJECT, wait: NO_WAIT }) + + it('refusals print, exit non-zero, and neither prompt nor POST', async () => { + const { api, posts } = fakeApi({ plan: plan({ refusals: ['service app would change type from web to worker'] }) }) + await templateUpgrade('n8n', {}, deps(api)) + expect(stdout.join('')).toContain('cannot run') + expect(process.exitCode).toBe(1) + expect(posts).toEqual([]) + expect(clack.confirm).not.toHaveBeenCalled() + }) + + it('POSTs the version and digest from the plan it printed', async () => { + const { api, posts } = fakeApi() + await templateUpgrade('n8n', { yes: true }, deps(api)) + expect(posts).toEqual([{ + path: '/projects/proj_1/template-deployments/dep_9/upgrade', + body: { variables: {}, expectedVersion: '1.4.0', expectedDigest: 'a'.repeat(64) }, + }]) + }) + + it('--json alone prints the plan and does not upgrade', async () => { + const { api, posts } = fakeApi() + await templateUpgrade('n8n', { json: true }, deps(api)) + expect(JSON.parse(stdout.join('')).to_version).toBe('1.4.0') + expect(posts).toEqual([]) + }) + + it('--json --yes upgrades and prints exactly one JSON document, the deployment', async () => { + const { api, posts } = fakeApi() + await templateUpgrade('n8n', { json: true, yes: true }, deps(api)) + expect(posts).toHaveLength(1) + expect(posts[0].body.expectedVersion).toBe('1.4.0') + expect(JSON.parse(stdout.join('')).status).toBe('succeeded') + }) + + it('rollback says both warnings before it asks to confirm', async () => { + const { api, posts } = fakeApi() + const order: string[] = [] + vi.mocked(clack.confirm).mockImplementationOnce((async () => { order.push(`confirm after: ${stdout.join('')}`); return true }) as any) + const inTty = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY') + const outTty = 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 templateRollback('n8n', {}, deps(api)) + } finally { + for (const [s, d] of [[process.stdin, inTty], [process.stdout, outTty]] as const) { + if (d) Object.defineProperty(s, 'isTTY', d); else delete (s as any).isTTY + } + } + expect(order).toHaveLength(1) + expect(order[0]).toContain('restores the image, the start command, the port and the recorded variables') + expect(order[0]).toContain('does not restore data the app migrated') + expect(order[0]).toContain('a volume only grows') + expect(posts[0].path).toBe('/projects/proj_1/template-deployments/dep_9/rollback') + }) + + it('a service not deployed from a template says so, with no POST', async () => { + const { api, posts } = fakeApi({ services: [{ name: 'n8n', template_deployment_id: null }] }) + await expect(templateUpgrade('n8n', { yes: true }, deps(api))).rejects.toThrow(/was not deployed from a template/) + await expect(templateRollback('n8n', { yes: true }, deps(api))).rejects.toThrow(/was not deployed from a template/) + expect(posts).toEqual([]) + }) +}) From b9b119ac48432d716578c1417767531d03cc089f Mon Sep 17 00:00:00 2001 From: CarmenDou <15951653662@163.com> Date: Thu, 8 Oct 2026 20:22:35 -0700 Subject: [PATCH 3/9] fix(template): rollback --json requires --yes; explain stale-plan and rollback 409s --- src/commands/template.ts | 29 +++++++++++++++++++++++++---- src/index.ts | 2 +- test/template-upgrade.test.ts | 29 +++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 5 deletions(-) diff --git a/src/commands/template.ts b/src/commands/template.ts index 5f809bb..f2b9997 100644 --- a/src/commands/template.ts +++ b/src/commands/template.ts @@ -490,6 +490,24 @@ export function upgradePlanLines(plan: UpgradePlanBody): string[] { return lines } +// The 409 codes a user will actually hit, in a sentence that says what to do next. +const UPGRADE_409: Record = { + template_version_changed: 'The template was republished since the plan was shown. Nothing was changed. Run the command again to review the new version.', +} +const ROLLBACK_409: Record = { + template_no_step_back: 'This service was never upgraded, so there is no earlier version to return to.', + template_version_not_recorded: 'The earlier version of this template is no longer recorded, so it cannot be restored. Nothing was changed.', +} +async function postWithAdvice(api: TemplateApi, path: string, body: unknown, advice: Record) { + try { + return await api.rawRequest('POST', path, body) + } catch (e) { + const said = e instanceof ApiError && e.status === 409 ? advice[e.body?.code] : undefined + if (said) throw new Error(said) + throw e + } +} + /** * A service name is what a person has; a deployment uuid is not. The services list already carries * the attribution, so the lookup lives here rather than in the user's head. @@ -539,9 +557,9 @@ export async function templateUpgrade(service: string, opts: TemplateUpgradeOpts // The version and digest the user just read. A republish in between answers 409 rather than // running something they never saw. - const res = await api.rawRequest('POST', `/projects/${p.projectId}/template-deployments/${target.deploymentId}/upgrade`, { + const res = await postWithAdvice(api, `/projects/${p.projectId}/template-deployments/${target.deploymentId}/upgrade`, { variables, expectedVersion: plan.to_version, expectedDigest: plan.to_digest, - }) + }, UPGRADE_409) if (handleApproval(res, opts.json)) return const deploymentId = res.body.deploymentId ?? (res.body.deployment ?? res.body).id @@ -556,6 +574,9 @@ export async function templateUpgrade(service: string, opts: TemplateUpgradeOpts export async function templateRollback(service: string, opts: TemplateRollbackOpts = {}, deps: TemplateDeployDeps = {}): Promise { const quiet = !!opts.json const out = quiet ? () => {} : info + // Destructive, and its two warnings are load-bearing: a machine-readable run must say --yes out + // loud rather than roll back unseen through a pipe. + if (opts.json && !opts.yes) throw new Error('rollback --json needs --yes: it does not restore data the app migrated, so it will not run without being told to') const api = deps.api ?? (await ApiClient.load()) const p = deps.project ?? (await requireProject()) const branchName = opts.branch ?? p.branch @@ -565,14 +586,14 @@ export async function templateRollback(service: string, opts: TemplateRollbackOp // regrets a rollback, and they are not recoverable by running it again. out('Going back restores the image, the start command, the port and the recorded variables.') out('It does not restore data the app migrated under the newer version, and a volume only grows.') - const tty = !opts.yes && !!process.stdin.isTTY && !!process.stdout.isTTY + const tty = !opts.json && !opts.yes && !!process.stdin.isTTY && !!process.stdout.isTTY if (!opts.yes && tty) { const go = await clack.confirm({ message: `Return ${service} to the version it was upgraded from?` }) if (clack.isCancel(go)) throw new CliCancel() if (!go) return } - const res = await api.rawRequest('POST', `/projects/${p.projectId}/template-deployments/${target.deploymentId}/rollback`, {}) + const res = await postWithAdvice(api, `/projects/${p.projectId}/template-deployments/${target.deploymentId}/rollback`, {}, ROLLBACK_409) if (handleApproval(res, opts.json)) return const deploymentId = res.body.deploymentId ?? (res.body.deployment ?? res.body).id out(`rolling ${service} back (${deploymentId})`) diff --git a/src/index.ts b/src/index.ts index 31ec360..42fe47a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -582,7 +582,7 @@ tpl.command('upgrade ').description('Move a service deployed from a tem tpl.command('rollback ').description('Return a service to the template version it was upgraded from, one step. Restores the image, start command, port and recorded variables; does not restore data the app migrated, and a volume only grows') .option('--branch ', 'branch the service is on (default: current)') .option('-y, --yes', 'do not ask for confirmation') - .option('--json', 'print the finished deployment as JSON and suppress progress lines') + .option('--json', 'print the finished deployment as JSON and suppress progress lines; requires --yes') .action(guard((service, o) => template.templateRollback(service, o))) // Authoring: the org's community templates, the same drafts the console editor changes. tpl.command('create').description("Create a community template draft from the linked project (or --project), or an empty one with --blank. Prints its code, status and the console editor link") diff --git a/test/template-upgrade.test.ts b/test/template-upgrade.test.ts index 404c344..8c706c3 100644 --- a/test/template-upgrade.test.ts +++ b/test/template-upgrade.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi, afterEach, afterAll } from 'vitest' import * as clack from '@clack/prompts' +import { ApiError } from '../src/api.js' import { upgradePlanLines, templateUpgrade, templateRollback } from '../src/commands/template.js' vi.mock('@clack/prompts', async (orig) => ({ ...(await orig()), confirm: vi.fn(async () => true) })) @@ -136,4 +137,32 @@ describe('upgrade and rollback flows', () => { await expect(templateRollback('n8n', { yes: true }, deps(api))).rejects.toThrow(/was not deployed from a template/) expect(posts).toEqual([]) }) + + it('rollback --json without --yes rolls back nothing and fails', async () => { + const { api, posts } = fakeApi() + await expect(templateRollback('n8n', { json: true }, deps(api))).rejects.toThrow(/--yes/) + expect(posts).toEqual([]) + expect(stdout.join('')).toBe('') + }) + + it('rollback --json --yes emits exactly one JSON document and no progress lines', async () => { + const { api, posts } = fakeApi() + await templateRollback('n8n', { json: true, yes: true }, deps(api)) + expect(posts).toHaveLength(1) + expect(JSON.parse(stdout.join('')).status).toBe('succeeded') + }) + + it('a stale pin says the template was republished', async () => { + const { api } = fakeApi() + api.rawRequest = async () => { throw new ApiError(409, 'changed', { code: 'template_version_changed' }) } + await expect(templateUpgrade('n8n', { yes: true }, deps(api))).rejects.toThrow(/republished since the plan was shown/) + }) + + it('rollback explains the two 409s a user will hit', async () => { + for (const [code, re] of [['template_no_step_back', /never upgraded/], ['template_version_not_recorded', /no longer recorded/]] as const) { + const { api } = fakeApi() + api.rawRequest = async () => { throw new ApiError(409, 'x', { code }) } + await expect(templateRollback('n8n', { yes: true }, deps(api))).rejects.toThrow(re) + } + }) }) From 4a464b68986ebf1b4eef2b7654adef20a7f85391 Mon Sep 17 00:00:00 2001 From: CarmenDou <15951653662@163.com> Date: Thu, 8 Oct 2026 20:24:39 -0700 Subject: [PATCH 4/9] fix(template): rollback needs --yes without a terminal --- src/commands/template.ts | 7 ++++--- test/template-upgrade.test.ts | 6 ++++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/commands/template.ts b/src/commands/template.ts index f2b9997..b5153c8 100644 --- a/src/commands/template.ts +++ b/src/commands/template.ts @@ -576,7 +576,9 @@ export async function templateRollback(service: string, opts: TemplateRollbackOp const out = quiet ? () => {} : info // Destructive, and its two warnings are load-bearing: a machine-readable run must say --yes out // loud rather than roll back unseen through a pipe. - if (opts.json && !opts.yes) throw new Error('rollback --json needs --yes: it does not restore data the app migrated, so it will not run without being told to') + // Same rule as `template publish`: 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: it does not restore data the app migrated, so nothing was rolled back') const api = deps.api ?? (await ApiClient.load()) const p = deps.project ?? (await requireProject()) const branchName = opts.branch ?? p.branch @@ -586,8 +588,7 @@ export async function templateRollback(service: string, opts: TemplateRollbackOp // regrets a rollback, and they are not recoverable by running it again. out('Going back restores the image, the start command, the port and the recorded variables.') out('It does not restore data the app migrated under the newer version, and a volume only grows.') - const tty = !opts.json && !opts.yes && !!process.stdin.isTTY && !!process.stdout.isTTY - if (!opts.yes && tty) { + if (tty) { const go = await clack.confirm({ message: `Return ${service} to the version it was upgraded from?` }) if (clack.isCancel(go)) throw new CliCancel() if (!go) return diff --git a/test/template-upgrade.test.ts b/test/template-upgrade.test.ts index 8c706c3..b70b558 100644 --- a/test/template-upgrade.test.ts +++ b/test/template-upgrade.test.ts @@ -165,4 +165,10 @@ describe('upgrade and rollback flows', () => { await expect(templateRollback('n8n', { yes: true }, deps(api))).rejects.toThrow(re) } }) + + it('rollback without a terminal or --yes rolls back nothing and fails', async () => { + const { api, posts } = fakeApi() + await expect(templateRollback('n8n', {}, deps(api))).rejects.toThrow(/--yes/) + expect(posts).toEqual([]) + }) }) From 649a9a5a7bb4607dda95339b5ce9d7d77c095bc3 Mon Sep 17 00:00:00 2001 From: CarmenDou <15951653662@163.com> Date: Thu, 8 Oct 2026 21:39:56 -0700 Subject: [PATCH 5/9] fix(template): say what upgrade and rollback actually rewrite The upgrade applies every field the manifest declares for a service that already exists, always-on, public access and the volume mount path among them. The `--help` text still carried the superseded sentence saying those three were left alone, which is the opposite of what runs, and on `--yes --json` no plan is printed, so that text is all an agent ever reads. Rollback goes through the same planner, so it writes those fields too whenever the OLDER manifest declares them, and it prints no plan at all. Its two warnings were the whole disclosure and named only image, start command, port and variables. They now name every setting, with the qualifier that a field the older version is silent about keeps its current value. Also: one lookup serves both verbs, so a service that came from no template no longer tells someone who ran `rollback` there is nothing to upgrade. --- src/commands/template.ts | 16 +++++++++------- src/index.ts | 4 ++-- test/template-upgrade.test.ts | 20 +++++++++++++++----- 3 files changed, 26 insertions(+), 14 deletions(-) diff --git a/src/commands/template.ts b/src/commands/template.ts index b5153c8..a4cfd09 100644 --- a/src/commands/template.ts +++ b/src/commands/template.ts @@ -512,11 +512,12 @@ async function postWithAdvice(api: TemplateApi, path: string, body: unknown, adv * A service name is what a person has; a deployment uuid is not. The services list already carries * the attribution, so the lookup lives here rather than in the user's head. */ -async function deploymentOfService(api: TemplateApi, projectId: string, branch: string, service: string) { +async function deploymentOfService(api: TemplateApi, projectId: string, branch: string, service: string, verb: 'upgrade' | 'roll back') { const body = await api.request('GET', `/projects/${projectId}/services?branch=${encodeURIComponent(branch)}`) const row = (body.services ?? []).find((s: any) => s.name === service) if (!row) throw new Error(`no service named ${service} on branch ${branch}`) - if (!row.template_deployment_id) throw new Error(`${service} was not deployed from a template, so there is nothing to upgrade`) + // Named by the caller: the same sentence under `rollback` would send the user to upgrade instead. + if (!row.template_deployment_id) throw new Error(`${service} was not deployed from a template, so there is nothing to ${verb}`) return { deploymentId: row.template_deployment_id as string } } @@ -527,7 +528,7 @@ export async function templateUpgrade(service: string, opts: TemplateUpgradeOpts const branchName = opts.branch ?? p.branch const ask = deps.ask ?? promptVariable - const target = await deploymentOfService(api, p.projectId, branchName, service) + const target = await deploymentOfService(api, p.projectId, branchName, service, 'upgrade') // The poll and preview routes are keyed by deployment id, not project: name the project so agent // mode signs with the project-bound session, exactly as templateDeploy does. const { plan } = await api.request('GET', `/template-deployments/${target.deploymentId}/upgrade`, undefined, { projectId: p.projectId }) @@ -582,11 +583,12 @@ export async function templateRollback(service: string, opts: TemplateRollbackOp const api = deps.api ?? (await ApiClient.load()) const p = deps.project ?? (await requireProject()) const branchName = opts.branch ?? p.branch - const target = await deploymentOfService(api, p.projectId, branchName, service) + const target = await deploymentOfService(api, p.projectId, branchName, service, 'roll back') - // Said BEFORE the confirmation, not after the fact: these two are the whole reason someone - // regrets a rollback, and they are not recoverable by running it again. - out('Going back restores the image, the start command, the port and the recorded variables.') + // Said BEFORE the confirmation, not after the fact: rollback prints no plan, so these lines are + // the whole disclosure, and what they warn of is not recoverable by running the command again. + out('Going back restores every setting that version declares: the image, the start command, the port, always-on, public access, the volume mount path and the recorded variables.') + out('A setting the older version does not declare keeps its current value.') out('It does not restore data the app migrated under the newer version, and a volume only grows.') if (tty) { const go = await clack.confirm({ message: `Return ${service} to the version it was upgraded from?` }) diff --git a/src/index.ts b/src/index.ts index 42fe47a..8aa6691 100644 --- a/src/index.ts +++ b/src/index.ts @@ -573,13 +573,13 @@ tpl.command('deploy ').description('Deploy a template onto a .option('-y, --yes', 'non-interactive: missing required variables fail with a --set list instead of prompting') .option('--json') .action(guard((target, o) => template.templateDeploy(target, o))) -tpl.command('upgrade ').description('Move a service deployed from a template to the version the registry serves now. Shows what will change and asks before it runs. The same services are reused: image, start command, port and variables are rewritten; always-on, public access and the volume are left alone') +tpl.command('upgrade ').description('Move a service deployed from a template to the version the registry serves now. Shows what will change and asks before it runs. The same services are reused, and every setting the new version declares is rewritten: image, start command, port, always-on, public access, the volume mount path and the variables, each of them listed in the plan first') .option('--branch ', 'branch the service is on (default: current)') .option('--set ', 'set a template variable the new version requires (repeatable)', (v: string, prev: string[]) => [...prev, v], [] as string[]) .option('-y, --yes', 'do not ask for confirmation') .option('--json', 'machine-readable output: alone it prints the plan and does not upgrade; with --yes it upgrades and prints the finished deployment') .action(guard((service, o) => template.templateUpgrade(service, o))) -tpl.command('rollback ').description('Return a service to the template version it was upgraded from, one step. Restores the image, start command, port and recorded variables; does not restore data the app migrated, and a volume only grows') +tpl.command('rollback ').description('Return a service to the template version it was upgraded from, one step. Restores every setting that version declares: image, start command, port, always-on, public access, the volume mount path and the recorded variables, while a setting it does not declare keeps its current value. It does not restore data the app migrated, and a volume only grows') .option('--branch ', 'branch the service is on (default: current)') .option('-y, --yes', 'do not ask for confirmation') .option('--json', 'print the finished deployment as JSON and suppress progress lines; requires --yes') diff --git a/test/template-upgrade.test.ts b/test/template-upgrade.test.ts index b70b558..6c4530f 100644 --- a/test/template-upgrade.test.ts +++ b/test/template-upgrade.test.ts @@ -109,7 +109,7 @@ describe('upgrade and rollback flows', () => { expect(JSON.parse(stdout.join('')).status).toBe('succeeded') }) - it('rollback says both warnings before it asks to confirm', async () => { + it('rollback names every setting it writes, and both warnings, before it asks to confirm', async () => { const { api, posts } = fakeApi() const order: string[] = [] vi.mocked(clack.confirm).mockImplementationOnce((async () => { order.push(`confirm after: ${stdout.join('')}`); return true }) as any) @@ -125,16 +125,26 @@ describe('upgrade and rollback flows', () => { } } expect(order).toHaveLength(1) - expect(order[0]).toContain('restores the image, the start command, the port and the recorded variables') + // Rollback prints NO plan, so this text is the user's only notice of what it rewrites. It + // runs the same planner as the upgrade, so always-on, public access and the mount path are + // written too whenever the older manifest declares them. + expect(order[0]).toContain('the image, the start command, the port') + expect(order[0]).toContain('always-on') + expect(order[0]).toContain('public access') + expect(order[0]).toContain('the volume mount path') + expect(order[0]).toContain('the recorded variables') + expect(order[0]).toContain('does not declare keeps its current value') expect(order[0]).toContain('does not restore data the app migrated') expect(order[0]).toContain('a volume only grows') expect(posts[0].path).toBe('/projects/proj_1/template-deployments/dep_9/rollback') }) - it('a service not deployed from a template says so, with no POST', async () => { + it('a service not deployed from a template says so, naming the command that was run, with no POST', async () => { const { api, posts } = fakeApi({ services: [{ name: 'n8n', template_deployment_id: null }] }) - await expect(templateUpgrade('n8n', { yes: true }, deps(api))).rejects.toThrow(/was not deployed from a template/) - await expect(templateRollback('n8n', { yes: true }, deps(api))).rejects.toThrow(/was not deployed from a template/) + // One lookup serves both verbs, so the sentence has to follow the verb: telling someone who + // ran `rollback` that there is nothing to UPGRADE sends them to the wrong command. + await expect(templateUpgrade('n8n', { yes: true }, deps(api))).rejects.toThrow(/was not deployed from a template, so there is nothing to upgrade/) + await expect(templateRollback('n8n', { yes: true }, deps(api))).rejects.toThrow(/was not deployed from a template, so there is nothing to roll back/) expect(posts).toEqual([]) }) From 75fed5e00762b86f0e40daff6ec1231967e6c195 Mon Sep 17 00:00:00 2001 From: CarmenDou <15951653662@163.com> Date: Thu, 8 Oct 2026 22:37:50 -0700 Subject: [PATCH 6/9] fix(template): explain the two new 409s, take --set on rollback, mark a skipped service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The platform's final-review pass changed four things in the contract this command speaks. Three of them are reachable from here. Two new coded 409s arrive where only a bare `conflict (HTTP 409)` would have been printed. `template_superseded` (the instance has already moved off this deployment) and `template_version_content_changed` (the recorded manifest for that version is not the content this instance ran) both name a deployment or a version in the platform's own sentence, and that name is the whole of what makes them actionable, so they are printed as given with only the next step appended, rather than replaced by wording of ours. `template_superseded` is raised by the preview too, which is a GET and so never went through postWithAdvice — the preview now gets the same reading as the POST. `template rollback` gains `--set NAME=value`, matching `template upgrade`'s flag. Before this, a version that dropped a required variable could not be rolled back at all: the upgrade's env write is an authoritative replace, so it deleted that value, the older manifest still requires it, and the platform's 400 naming it was a dead end. Rollback prints no plan, so those names exist only in that 400 — the command prompts from it on a terminal and, with no TTY or `--yes`, fails with the `--set NAME=value` list, having rolled nothing back. A deployment's service entry can now carry `skipped: true`: a service the user deleted, which the run stepped over. Its `state` stays `pending`, which under the existing marks read as "not reached yet", so it gets its own mark and names the reason. It is also out of `partialMessage`'s healthy count — a run that did everything it could should not report a shortfall for a service that was not there — while staying in the list, because the count is what raises the question. Descriptions corrected for what the upgrade now actually does: it creates a volume a new version declares on an existing service rather than only rewriting a mount path, and a public-access change on a storage bucket reaches that bucket on every branch that has a copy. --- src/commands/template.ts | 73 ++++++++++++++++++++++++++++---- src/index.ts | 5 ++- test/template-upgrade.test.ts | 79 +++++++++++++++++++++++++++++++++++ test/template.test.ts | 15 +++++++ 4 files changed, 162 insertions(+), 10 deletions(-) diff --git a/src/commands/template.ts b/src/commands/template.ts index a4cfd09..d645891 100644 --- a/src/commands/template.ts +++ b/src/commands/template.ts @@ -254,8 +254,12 @@ export function deploymentUrls(dep: any): string[] { } // One line per service with its terminal state — the anatomy of a partial/failed run. +// `skipped` is the platform saying the user deleted that service and the run stepped over it. Its +// `state` stays `pending`, which under the marks below would read as "not reached yet" — a run that +// is still going — so it gets its own mark and names the reason instead. export function serviceStateLines(dep: any): string[] { return (dep?.services ?? []).map((s: any) => { + if (s?.skipped) return ` - ${s?.name ?? 'service'} [skipped: it no longer exists, so the run stepped over it]` const mark = s?.state === 'healthy' ? '✓' : s?.state === 'failed' ? '✗' : '•' return ` ${mark} ${s?.name ?? 'service'}${s?.url ? ` — ${s.url}` : ''}${s?.state && s.state !== 'healthy' ? ` [${s.state}]` : ''}` }) @@ -266,9 +270,13 @@ export function serviceStateLines(dep: any): string[] { // deploy, or clean up), not just that something went wrong. export function partialMessage(dep: any): string { const services: any[] = dep?.services ?? [] - const healthy = services.filter((s) => s?.state === 'healthy').length + // A skipped service was never part of this run — the user had deleted it — so counting it in the + // denominator would report a run that did everything it could as having left something behind. + // It is still LISTED below, because "where did it go" is the question the count then raises. + const ran = services.filter((s) => !s?.skipped) + const healthy = ran.filter((s) => s?.state === 'healthy').length return [ - `template deployment finished partial: ${healthy}/${services.length} services healthy`, + `template deployment finished partial: ${healthy}/${ran.length} services healthy`, ...serviceStateLines(dep), ...(dep?.error ? [` ${dep.error}`] : []), ...(dep?.logsTail ? ['--- log tail ---', String(dep.logsTail).trimEnd()] : []), @@ -462,7 +470,7 @@ export async function templateDeploy(target: string, opts: TemplateDeployOpts = // ---- upgrade / rollback ---- export type TemplateUpgradeOpts = { set?: string[]; branch?: string; yes?: boolean; json?: boolean } -export type TemplateRollbackOpts = { branch?: string; yes?: boolean; json?: boolean } +export type TemplateRollbackOpts = { set?: string[]; branch?: string; yes?: boolean; json?: boolean } type PlanField = { field: string; label: string; deployed: string | null; live: string | null; next: string | null; drifted: boolean; verdict: string } type PlanService = { key: string; service_name: string | null; gone: boolean; added: boolean; fields: PlanField[]; missing_variables: string[] } @@ -498,13 +506,35 @@ const ROLLBACK_409: Record = { template_no_step_back: 'This service was never upgraded, so there is no earlier version to return to.', template_version_not_recorded: 'The earlier version of this template is no longer recorded, so it cannot be restored. Nothing was changed.', } + +/** + * Codes whose platform sentence names the deployment or the version to act on. Replacing it with + * wording of our own would drop the one name that makes it actionable, so the platform's sentence + * is printed as given and only the next step is appended. + */ +const KEEP_PLATFORM_SENTENCE: Record = { + // "this deployment was already replaced by @ — act on that deployment instead". + // Reachable here only for a service a NEWER version dropped: it keeps pointing at the deployment + // the instance has moved off, which is the one this CLI resolves from the service name. + template_superseded: 'Nothing was changed. This command addresses a service, so name one that the newer deployment carries.', + // "@ was republished with different content after this instance ran it …". + template_version_content_changed: 'Nothing was changed.', +} + +/** The sentence to raise for a coded 409, or undefined when this is not one we explain. */ +function coded409(e: unknown, advice: Record): Error | undefined { + if (!(e instanceof ApiError) || e.status !== 409) return undefined + const code = typeof e.body?.code === 'string' ? e.body.code : '' + if (advice[code]) return new Error(advice[code]) + const next = KEEP_PLATFORM_SENTENCE[code] + return next ? new Error(`${e.message}. ${next}`) : undefined +} + async function postWithAdvice(api: TemplateApi, path: string, body: unknown, advice: Record) { try { return await api.rawRequest('POST', path, body) } catch (e) { - const said = e instanceof ApiError && e.status === 409 ? advice[e.body?.code] : undefined - if (said) throw new Error(said) - throw e + throw coded409(e, advice) ?? e } } @@ -531,7 +561,14 @@ export async function templateUpgrade(service: string, opts: TemplateUpgradeOpts const target = await deploymentOfService(api, p.projectId, branchName, service, 'upgrade') // The poll and preview routes are keyed by deployment id, not project: name the project so agent // mode signs with the project-bound session, exactly as templateDeploy does. - const { plan } = await api.request('GET', `/template-deployments/${target.deploymentId}/upgrade`, undefined, { projectId: p.projectId }) + // The preview is a GET and raises coded 409s of its own (template_superseded among them), so it + // gets the same reading as the POST rather than the raw " (HTTP 409)" the guard prints. + let plan + try { + ;({ plan } = await api.request('GET', `/template-deployments/${target.deploymentId}/upgrade`, undefined, { projectId: p.projectId })) + } catch (e) { + throw coded409(e, UPGRADE_409) ?? e + } // --json alone is "tell me" (the plan, no upgrade); --json --yes is "do it and tell me". const quiet = !!opts.json if (opts.json && (!opts.yes || plan.refusals.length)) { @@ -573,6 +610,7 @@ export async function templateUpgrade(service: string, opts: TemplateUpgradeOpts } export async function templateRollback(service: string, opts: TemplateRollbackOpts = {}, deps: TemplateDeployDeps = {}): Promise { + const given = parseSetFlags(opts.set ?? []) // a typo'd --set fails before any network access const quiet = !!opts.json const out = quiet ? () => {} : info // Destructive, and its two warnings are load-bearing: a machine-readable run must say --yes out @@ -583,12 +621,14 @@ export async function templateRollback(service: string, opts: TemplateRollbackOp const api = deps.api ?? (await ApiClient.load()) const p = deps.project ?? (await requireProject()) const branchName = opts.branch ?? p.branch + const ask = deps.ask ?? promptVariable const target = await deploymentOfService(api, p.projectId, branchName, service, 'roll back') // Said BEFORE the confirmation, not after the fact: rollback prints no plan, so these lines are // the whole disclosure, and what they warn of is not recoverable by running the command again. out('Going back restores every setting that version declares: the image, the start command, the port, always-on, public access, the volume mount path and the recorded variables.') out('A setting the older version does not declare keeps its current value.') + out('Public access on a storage bucket reaches that bucket on every branch that has a copy, not only this one.') out('It does not restore data the app migrated under the newer version, and a volume only grows.') if (tty) { const go = await clack.confirm({ message: `Return ${service} to the version it was upgraded from?` }) @@ -596,7 +636,24 @@ export async function templateRollback(service: string, opts: TemplateRollbackOp if (!go) return } - const res = await postWithAdvice(api, `/projects/${p.projectId}/template-deployments/${target.deploymentId}/rollback`, {}, ROLLBACK_409) + // The platform recovers every value the services still hold, so a rollback normally sends + // nothing. What it cannot recover is a variable the NEWER version stopped declaring: the upgrade + // deleted that value and the older manifest still requires it. Rollback prints no plan, so the + // platform's 400 naming them is the only place those names exist — prompt from it and retry + // once, exactly as templateDeploy does with its own missing_variables. + const rollbackPath = `/projects/${p.projectId}/template-deployments/${target.deploymentId}/rollback` + const variables: Record = { ...given } + let res + try { + res = await postWithAdvice(api, rollbackPath, { variables }, ROLLBACK_409) + } catch (e) { + const missing = e instanceof ApiError ? missingVariablesFrom(e.body) : null + if (!missing?.length) throw e + // No TTY (or --yes) fails here with the `--set NAME=value` list, the same rule the deploy and + // upgrade paths follow — nothing has been rolled back at this point. + Object.assign(variables, await resolveVariables(missing, {}, { tty, ask })) + res = await postWithAdvice(api, rollbackPath, { variables }, ROLLBACK_409) + } if (handleApproval(res, opts.json)) return const deploymentId = res.body.deploymentId ?? (res.body.deployment ?? res.body).id out(`rolling ${service} back (${deploymentId})`) diff --git a/src/index.ts b/src/index.ts index 8aa6691..85f1507 100644 --- a/src/index.ts +++ b/src/index.ts @@ -573,14 +573,15 @@ tpl.command('deploy ').description('Deploy a template onto a .option('-y, --yes', 'non-interactive: missing required variables fail with a --set list instead of prompting') .option('--json') .action(guard((target, o) => template.templateDeploy(target, o))) -tpl.command('upgrade ').description('Move a service deployed from a template to the version the registry serves now. Shows what will change and asks before it runs. The same services are reused, and every setting the new version declares is rewritten: image, start command, port, always-on, public access, the volume mount path and the variables, each of them listed in the plan first') +tpl.command('upgrade ').description('Move a service deployed from a template to the version the registry serves now. Shows what will change and asks before it runs. The same services are reused, and every setting the new version declares is applied: image, start command, port, always-on, public access, the volume and its mount path, and the variables, each of them listed in the plan first. A volume the new version adds is attached at your plan\'s size and mounted where the manifest says; an existing one is never resized. For a storage bucket, a public-access change reaches that bucket on every branch that has a copy. Refused with a 409 when the template was republished since the plan, or when this instance has already moved off this deployment') .option('--branch ', 'branch the service is on (default: current)') .option('--set ', 'set a template variable the new version requires (repeatable)', (v: string, prev: string[]) => [...prev, v], [] as string[]) .option('-y, --yes', 'do not ask for confirmation') .option('--json', 'machine-readable output: alone it prints the plan and does not upgrade; with --yes it upgrades and prints the finished deployment') .action(guard((service, o) => template.templateUpgrade(service, o))) -tpl.command('rollback ').description('Return a service to the template version it was upgraded from, one step. Restores every setting that version declares: image, start command, port, always-on, public access, the volume mount path and the recorded variables, while a setting it does not declare keeps its current value. It does not restore data the app migrated, and a volume only grows') +tpl.command('rollback ').description('Return a service to the template version it was upgraded from, one step. Restores every setting that version declares: image, start command, port, always-on, public access, the volume mount path and the recorded variables, while a setting it does not declare keeps its current value. For a storage bucket, a public-access change reaches that bucket on every branch that has a copy. It does not restore data the app migrated, and a volume only grows. Refused with a 409 when the service was never upgraded, when that version\'s manifest is no longer recorded, or when that version was republished with different content after this instance ran it') .option('--branch ', 'branch the service is on (default: current)') + .option('--set ', "set a template variable the older version requires that the instance no longer holds — an upgrade to a version that stopped declaring it deleted the value (repeatable)", (v: string, prev: string[]) => [...prev, v], [] as string[]) .option('-y, --yes', 'do not ask for confirmation') .option('--json', 'print the finished deployment as JSON and suppress progress lines; requires --yes') .action(guard((service, o) => template.templateRollback(service, o))) diff --git a/test/template-upgrade.test.ts b/test/template-upgrade.test.ts index 6c4530f..93ebc11 100644 --- a/test/template-upgrade.test.ts +++ b/test/template-upgrade.test.ts @@ -176,6 +176,85 @@ describe('upgrade and rollback flows', () => { } }) + it('a superseded deployment keeps the platform sentence naming the one to act on', async () => { + // The platform's message is the only place the successor is named, so it is printed as given + // rather than replaced by wording of ours; a bare `conflict (HTTP 409)` would strand the user. + const said = 'this deployment was already replaced by n8n@1.5.0 — act on that deployment instead' + const { api, posts } = fakeApi() + api.rawRequest = async () => { throw new ApiError(409, said, { error: said, code: 'template_superseded' }) } + await expect(templateUpgrade('n8n', { yes: true }, deps(api))).rejects.toThrow(/already replaced by n8n@1\.5\.0/) + await expect(templateUpgrade('n8n', { yes: true }, deps(api))).rejects.toThrow(/Nothing was changed/) + expect(posts).toEqual([]) + }) + + it('the PREVIEW reads a superseded 409 too — it is a GET on another code path', async () => { + const said = 'this deployment was already replaced by n8n@1.5.0 — act on that deployment instead' + const { api, posts } = fakeApi() + const plainGet = api.request + api.request = async (m: string, path: string) => { + if (path.endsWith('/upgrade')) throw new ApiError(409, said, { error: said, code: 'template_superseded' }) + return plainGet(m, path) + } + await expect(templateUpgrade('n8n', { yes: true }, deps(api))).rejects.toThrow(/already replaced by n8n@1\.5\.0 — act on that deployment instead\. Nothing was changed/) + expect(posts).toEqual([]) + }) + + it('a republished older version refuses the rollback in the platform\'s own words', async () => { + const said = 'n8n@1.3.2 was republished with different content after this instance ran it, so the recorded manifest is not what it was running — deploy the version you want as a new copy instead' + const { api } = fakeApi() + api.rawRequest = async () => { throw new ApiError(409, said, { error: said, code: 'template_version_content_changed' }) } + await expect(templateRollback('n8n', { yes: true }, deps(api))).rejects.toThrow(/republished with different content/) + await expect(templateRollback('n8n', { yes: true }, deps(api))).rejects.toThrow(/Nothing was changed/) + }) + + it('rollback sends --set values and prompts from the platform\'s missing list on a terminal', async () => { + // A version that DROPPED a required variable deleted its value, and the older manifest still + // requires it: the 400 is the only place those names exist, since rollback prints no plan. + const { api, posts } = fakeApi() + let first = true + api.rawRequest = async (_m: string, path: string, body?: unknown) => { + // Snapshot: the command fills the same `variables` object on the retry, so holding the + // reference would make both entries read as the second one. + posts.push({ path, body: structuredClone(body) }) + if (first) { + first = false + throw new ApiError(400, 'missing_variables', { error: 'missing_variables', missing: [{ name: 'ADMIN_TOKEN', key: 'ADMIN_TOKEN', description: 'admin API token' }] }) + } + return { status: 202, body: { deploymentId: 'dep_9' } } + } + const inTty = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY') + const outTty = 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 templateRollback('n8n', { set: ['SMTP_HOST=mail.example.com'] }, { ...deps(api), ask: async () => 'tok' }) + } finally { + for (const [s, d] of [[process.stdin, inTty], [process.stdout, outTty]] as const) { + if (d) Object.defineProperty(s, 'isTTY', d); else delete (s as any).isTTY + } + } + expect(posts).toHaveLength(2) + expect(posts[0].body).toEqual({ variables: { SMTP_HOST: 'mail.example.com' } }) + expect(posts[1].body).toEqual({ variables: { SMTP_HOST: 'mail.example.com', ADMIN_TOKEN: 'tok' } }) + }) + + it('rollback with no terminal prints the --set list instead of rolling back blind', async () => { + const { api, posts } = fakeApi() + api.rawRequest = async (_m: string, path: string, body?: unknown) => { + posts.push({ path, body }) + throw new ApiError(400, 'missing_variables', { error: 'missing_variables', missing: [{ name: 'ADMIN_TOKEN', key: 'ADMIN_TOKEN', description: 'admin API token' }] }) + } + await expect(templateRollback('n8n', { yes: true }, deps(api))).rejects.toThrow(/--set NAME=value/) + await expect(templateRollback('n8n', { yes: true }, deps(api))).rejects.toThrow(/ADMIN_TOKEN/) + }) + + it('a typo\'d --set on rollback fails before any network access', async () => { + const { api, posts, gets } = fakeApi() + await expect(templateRollback('n8n', { yes: true, set: ['lowercase=1'] }, deps(api))).rejects.toThrow(/--set expects NAME=value/) + expect(posts).toEqual([]) + expect(gets).toEqual([]) + }) + it('rollback without a terminal or --yes rolls back nothing and fails', async () => { const { api, posts } = fakeApi() await expect(templateRollback('n8n', {}, deps(api))).rejects.toThrow(/--yes/) diff --git a/test/template.test.ts b/test/template.test.ts index 5fcd10a..a3dca84 100644 --- a/test/template.test.ts +++ b/test/template.test.ts @@ -1114,4 +1114,19 @@ describe('deployment progress', () => { expect(serviceStateLines({ services: [{ name: 'app', state: 'created' }] })).toEqual([' • app [created]']) expect(partialMessage({ services: [] })).toMatch(/0\/0 services healthy/) }) + + it('a skipped service says it was stepped over, not that it is still pending', () => { + // The platform leaves `state: pending` on a service the user deleted and marks it `skipped`. + // Under the plain marks that reads as "not reached yet" — a run still working on it. + const line = serviceStateLines({ services: [{ name: 'side', state: 'pending', skipped: true }] }) + expect(line).toEqual([' - side [skipped: it no longer exists, so the run stepped over it]']) + expect(line[0]).not.toContain('•') + }) + + it('does not count a skipped service against the healthy total', () => { + // Everything the run could reach came up: 1/1, not 1/2 — a deleted service is not a shortfall. + const dep = { services: [{ name: 'app', state: 'healthy' }, { name: 'side', state: 'pending', skipped: true }] } + expect(partialMessage(dep)).toMatch(/1\/1 services healthy/) + expect(partialMessage(dep)).toContain('side [skipped') + }) }) From 3f1295df14b77d8136a256eec031c959505111d2 Mon Sep 17 00:00:00 2001 From: CarmenDou <15951653662@163.com> Date: Fri, 9 Oct 2026 00:03:18 -0700 Subject: [PATCH 7/9] fix(template): keep the platform sentence for a refused rollback `template_no_step_back` has two causes now: the instance was never upgraded, or it was already rolled back and the way forward is an upgrade. Only the platform can tell them apart, and the second names the version to return to. The CLI replaced that sentence with one of its own, so it printed "never upgraded" for both, which is the wrong one in the case a user reaches by rolling back twice. Moved the code into KEEP_PLATFORM_SENTENCE, which exists for exactly this: codes whose platform sentence names the thing to act on. The test that pinned the replaced wording now asserts both causes survive. --- src/commands/template.ts | 5 ++++- test/template-upgrade.test.ts | 22 ++++++++++++++++++---- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/src/commands/template.ts b/src/commands/template.ts index d645891..6e84edd 100644 --- a/src/commands/template.ts +++ b/src/commands/template.ts @@ -503,7 +503,6 @@ const UPGRADE_409: Record = { template_version_changed: 'The template was republished since the plan was shown. Nothing was changed. Run the command again to review the new version.', } const ROLLBACK_409: Record = { - template_no_step_back: 'This service was never upgraded, so there is no earlier version to return to.', template_version_not_recorded: 'The earlier version of this template is no longer recorded, so it cannot be restored. Nothing was changed.', } @@ -519,6 +518,10 @@ const KEEP_PLATFORM_SENTENCE: Record = { template_superseded: 'Nothing was changed. This command addresses a service, so name one that the newer deployment carries.', // "@ was republished with different content after this instance ran it …". template_version_content_changed: 'Nothing was changed.', + // Two causes now, and the second one names the version: "this instance was already rolled back + // to … upgrade it to move forward", beside the original "was not upgraded from + // anything". Our own wording could only say one of them, and said the wrong one for a rollback. + template_no_step_back: 'Nothing was changed.', } /** The sentence to raise for a coded 409, or undefined when this is not one we explain. */ diff --git a/test/template-upgrade.test.ts b/test/template-upgrade.test.ts index 93ebc11..22b3f3b 100644 --- a/test/template-upgrade.test.ts +++ b/test/template-upgrade.test.ts @@ -168,11 +168,25 @@ describe('upgrade and rollback flows', () => { await expect(templateUpgrade('n8n', { yes: true }, deps(api))).rejects.toThrow(/republished since the plan was shown/) }) - it('rollback explains the two 409s a user will hit', async () => { - for (const [code, re] of [['template_no_step_back', /never upgraded/], ['template_version_not_recorded', /no longer recorded/]] as const) { + it('rollback explains the 409s a user will hit', async () => { + const { api } = fakeApi() + api.rawRequest = async () => { throw new ApiError(409, 'x', { code: 'template_version_not_recorded' }) } + await expect(templateRollback('n8n', { yes: true }, deps(api))).rejects.toThrow(/no longer recorded/) + }) + + // template_no_step_back has two causes and only the platform can tell them apart: never upgraded, + // or already rolled back — the second names the version to upgrade back to. Replacing the sentence + // printed one cause for both, and the wrong one after a rollback. + it('rollback keeps the platform sentence for whichever no-step-back cause it was', async () => { + for (const msg of [ + 'this instance was not upgraded from anything, so there is no version to return to', + 'this instance was already rolled back to 1.6.0, so there is no older version to return to — upgrade it to move forward', + ]) { const { api } = fakeApi() - api.rawRequest = async () => { throw new ApiError(409, 'x', { code }) } - await expect(templateRollback('n8n', { yes: true }, deps(api))).rejects.toThrow(re) + api.rawRequest = async () => { throw new ApiError(409, msg, { code: 'template_no_step_back' }) } + await expect(templateRollback('n8n', { yes: true }, deps(api))).rejects.toThrow( + new RegExp(`${msg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\. Nothing was changed\\.`), + ) } }) From f2929bca8b3050a156c9c02c12ac282af051f2eb Mon Sep 17 00:00:00 2001 From: CarmenDou <15951653662@163.com> Date: Fri, 9 Oct 2026 22:48:20 -0700 Subject: [PATCH 8/9] fix(template): plan an added service, and never echo a rejected --set value The upgrade plan printed `added by ` for a service the new version introduces and then skipped the rest of that service: its field rows and its `missing_variables` were never rendered, so the command's own promise that every declared setting is listed before anything runs was false exactly where it mattered most, a service that does not exist yet and will be created and billed. The added line now says the service is created by this upgrade and the loop falls through to whatever rows the plan carries for it, which today is its required variables and, the day the planner sends them, its fields. The help text said "the same services are reused" and promised a per-setting plan for all of them. It now separates the three cases the platform actually reports: an existing service is reused and every declared setting is applied and listed first, an added service is created and named with the variables it requires rather than a per-setting comparison (there is no live service to compare against), and a service the new version no longer declares is left running and named as dropped. parseSetFlags put the whole rejected pair in its error. `--set` is how these commands take secrets, and a lowercase name is a common typo, so one typo wrote a live credential to stderr and from there into CI and agent logs. The parser now shows the name and hides everything after the first `=`; a pair with no `=` is not split at all, so none of it is shown. Redacting here rather than at the three call sites means a fourth caller cannot reintroduce the leak. --- src/commands/template.ts | 12 ++++++++++-- src/index.ts | 2 +- test/template-upgrade.test.ts | 25 +++++++++++++++++++++++-- test/template.test.ts | 7 +++++++ 4 files changed, 41 insertions(+), 5 deletions(-) diff --git a/src/commands/template.ts b/src/commands/template.ts index 6e84edd..0be7c5d 100644 --- a/src/commands/template.ts +++ b/src/commands/template.ts @@ -142,12 +142,19 @@ export function parseSetFlags(pairs: string[]): Record { const values: Record = {} for (const pair of pairs) { const m = /^([A-Z][A-Z0-9_]{0,63})=([\s\S]*)$/.exec(pair) - if (!m) throw new Error(`--set expects NAME=value (NAME matching ^[A-Z][A-Z0-9_]{0,63}$), got: ${pair}`) + if (!m) throw new Error(`--set expects NAME=value (NAME matching ^[A-Z][A-Z0-9_]{0,63}$), got: ${redactSetPair(pair)}`) values[m[1]!] = m[2]! } return values } +// --set carries secrets and this error reaches stderr and CI logs, so only the name is ever shown. +function redactSetPair(pair: string): string { + const eq = pair.indexOf('=') + // Nothing of a pair with no `=`: it was never split, so the whole token may be the value itself. + return eq >= 0 ? `${pair.slice(0, eq)}=