diff --git a/packages/cli/e2e/__tests__/deploy.spec.ts b/packages/cli/e2e/__tests__/deploy.spec.ts index f047526c..8d579883 100644 --- a/packages/cli/e2e/__tests__/deploy.spec.ts +++ b/packages/cli/e2e/__tests__/deploy.spec.ts @@ -239,6 +239,18 @@ describe('deploy', { timeout: 45_000 }, () => { .filter(({ slugName }: { slugName: string }) => slugName.startsWith(privateLocationSlugname)).length).toEqual(1) }) + it('deploys without a plan under --skip-plan', async () => { + const { stderr, stdout } = await runDeploy(fixt, ['--skip-plan', '--force'], { + env: { + PROJECT_LOGICAL_ID: projectLogicalId, + PRIVATE_LOCATION_SLUG_NAME: privateLocationSlugname, + CHECKLY_E2E_CLI_VERSION: undefined, + }, + }) + expect(stderr).toBe('') + expect(stdout).toContain('Successfully deployed project') + }) + it('Simple project should deploy successfully', async () => { const { stderr, stdout } = await runDeploy(fixt, ['--force'], { env: { diff --git a/packages/cli/src/ai-context/references/configure.md b/packages/cli/src/ai-context/references/configure.md index 5b93a3b3..2e31e94f 100644 --- a/packages/cli/src/ai-context/references/configure.md +++ b/packages/cli/src/ai-context/references/configure.md @@ -75,7 +75,8 @@ Run `npx checkly skills manage plan` for the full reference. ## Deploying - Deploy checks using the `npx checkly deploy` command. Use `--output` to see the created, updated, and deleted resources. Use `--verbose` to also include each resource's name and physical ID (UUID), which is useful for programmatically referencing deployed resources (e.g. `npx checkly checks get `). -- Use `--preview` to see which resources a deploy would create, update, delete or keep, without applying it. The machine-readable forms (`--dry-run`, and the `confirmation_required` envelope) additionally carry the individual properties that would change. +- Use `--preview` to see which resources a deploy would create, update, delete or keep, without applying it: an overview of every resource the deploy touches and a diff of each updated resource's construct as deployed against as in code. A plain interactive `checkly deploy` prints that same preview before asking the user to apply the changes or cancel. The machine-readable forms (`--dry-run`, and the `confirmation_required` envelope) additionally carry the individual properties that would change. +- Use `--skip-plan` to deploy without asking Checkly for a plan: nothing is previewed and no plan token is used, so the deploy applies whatever the account looks like when it runs. Resources to delete are still listed before the confirmation, but the code bundle is uploaded before it rather than after. Incompatible with `--preview`, `--dry-run`, `--plan-token` and `--prune-relations`. Prefer a planned deploy unless the plan itself is the problem. - Use `--prune-relations` to also delete the alert channel subscriptions and private location assignments on this project's checks and groups that the project does not manage. Without it they are only reported. ### Deleted resources diff --git a/packages/cli/src/commands/__tests__/confirm-flow-deploy.spec.ts b/packages/cli/src/commands/__tests__/confirm-flow-deploy.spec.ts index e7e1b8ec..285f8e2e 100644 --- a/packages/cli/src/commands/__tests__/confirm-flow-deploy.spec.ts +++ b/packages/cli/src/commands/__tests__/confirm-flow-deploy.spec.ts @@ -63,9 +63,12 @@ vi.mock('prompts', () => ({ default: vi.fn(() => Promise.resolve({ confirm: true })), })) +import prompts from 'prompts' + import { detectCliMode } from '../../helpers/cli-mode.js' import { buildConfirmCommand } from '../../helpers/command-preview.js' import * as api from '../../rest/api.js' +import { ConflictError, ValidationError } from '../../rest/errors.js' import { type DiffEntry, ProjectPlanStaleError, @@ -116,6 +119,7 @@ const DEFAULT_FLAGS = { 'preview': false, 'dry-run': false, 'plan-token': undefined, + 'skip-plan': false, 'prune-relations': false, 'output': false, 'verbose': false, @@ -131,6 +135,7 @@ const DEFAULT_FLAGS = { const DEFAULT_METADATA = { 'preview': { setFromDefault: true }, 'dry-run': { setFromDefault: true }, + 'skip-plan': { setFromDefault: true }, 'prune-relations': { setFromDefault: true }, 'output': { setFromDefault: true }, 'verbose': { setFromDefault: true }, @@ -634,7 +639,7 @@ describe('deploy confirmation flow', () => { await Deploy.prototype.run.call(ctx as any) const printed = ctx.logged.join('\n') - expect(printed).toContain('relation not managed by this project, deleted by --prune-relations') + expect(printed).toContain('relation on Check chk not managed by this project, deleted by --prune-relations') expect(printed).not.toContain('pass --prune-relations to delete them') }) @@ -750,6 +755,282 @@ describe('deploy confirmation flow', () => { }) }) +describe('deploy confirmation in a terminal', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(detectCliMode).mockReturnValue('interactive') + vi.mocked(prompts).mockResolvedValue({ confirm: true }) + storeBundle.mockResolvedValue({ key: 'stored-bundle-key' }) + vi.mocked(api.projects.deploy).mockResolvedValue({ data: { project: {} as any, diff: [] } }) + declareProject() + }) + + afterEach(() => { + Session.reset() + }) + + it('shows the rendered plan, then asks whether to apply it', async () => { + // A `full` plan carries each entry's redaction rules; the construct diff + // renders only from such an entry. + planResolves([{ ...CHANGED, redactions: [] }, DELETED]) + const ctx = createCommandContext() + + await Deploy.prototype.run.call(ctx as any) + + // The construct diff needs each changed resource's deployed state. + expect(vi.mocked(api.projects.preview).mock.calls[0][1]).toMatchObject({ detail: 'full' }) + + const printed = ctx.logged.join('\n') + expect(printed).toContain('Deploy preview') + expect(printed).toMatch(/^ {2}~ EmailAlertChannel {2}ops$/m) + expect(printed).toMatch(/^ {2}- Check +gone +permanently deleted, run history lost$/m) + expect(printed).toContain('- address: \'old@example.com\'') + expect(printed).toContain('+ address: \'ops@example.com\'') + // The options follow the plan; the resources are not listed a second time, + // and the token is not advertised since this run pins it. + expect(printed).toContain('This will:\n - Deploy project "My Project" to account "Test Account"') + expect(printed).not.toContain('Update AlertChannel: ops') + expect(printed).not.toContain('--plan-token') + expect(vi.mocked(prompts).mock.calls[0][0]).toMatchObject({ message: 'Apply these changes?' }) + + // Applying uploads and deploys the plan that was shown. + expect(storeBundle).toHaveBeenCalledOnce() + expect(api.projects.deploy).toHaveBeenCalledOnce() + expect(vi.mocked(api.projects.deploy).mock.calls[0][1]).toMatchObject({ planToken: PLAN_TOKEN }) + }) + + it('cancels without uploading or deploying anything', async () => { + planResolves() + vi.mocked(prompts).mockResolvedValue({ confirm: false }) + const ctx = createCommandContext() + + await expect(Deploy.prototype.run.call(ctx as any)).rejects.toThrow('EXIT_0') + + expect(ctx.logged.join('\n')).toContain('Deploy preview') + expect(storeBundle).not.toHaveBeenCalled() + expect(api.projects.deploy).not.toHaveBeenCalled() + }) + + it('prints no plan for a forced run', async () => { + planResolves() + const ctx = createCommandContext({ force: true }) + + await Deploy.prototype.run.call(ctx as any) + + expect(prompts).not.toHaveBeenCalled() + expect(ctx.logged.join('\n')).not.toContain('Deploy preview') + expect(api.projects.deploy).toHaveBeenCalledOnce() + }) + + it('prints the plan before the prompt and what was done after, under --output', async () => { + planResolves() + vi.mocked(api.projects.deploy).mockResolvedValue({ + data: { project: {} as any, diff: [{ type: 'check', logicalId: 'gone', physicalId: 7, action: 'DELETE' }] }, + }) + const ctx = createCommandContext({ output: true }) + + await Deploy.prototype.run.call(ctx as any) + + const printed = ctx.logged.join('\n') + expect(printed).toContain('\n1 to update, 1 to delete, 0 unchanged\n') + expect(printed).toContain('\n1 deleted, 0 unchanged\n') + expect(printed.indexOf('1 to update')).toBeLessThan(printed.indexOf('1 deleted')) + }) + + it('lists what the dry run found when there is no plan to render', async () => { + vi.mocked(api.projects.preview).mockRejectedValue(new ProjectPreviewNotSupportedError()) + vi.mocked(api.projects.deploy).mockResolvedValue({ + data: { project: {} as any, diff: [{ type: 'check', logicalId: 'gone', physicalId: 7, action: 'DELETE' }] }, + }) + const ctx = createCommandContext() + + await Deploy.prototype.run.call(ctx as any) + + const printed = ctx.logged.join('\n') + expect(printed).not.toContain('Deploy preview') + expect(printed).toContain(' - Permanently delete Check: gone, losing its run history') + expect(vi.mocked(prompts).mock.calls[0][0]).toMatchObject({ message: 'Proceed?' }) + // The dry run, then the deploy the user confirmed. + expect(api.projects.deploy).toHaveBeenCalledTimes(2) + const confirmed = vi.mocked(api.projects.deploy).mock.calls[1][1] + expect(confirmed?.dryRun).toBeFalsy() + expect(confirmed?.planToken).toBeUndefined() + }) +}) + +describe('deploy --skip-plan', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(detectCliMode).mockReturnValue('interactive') + vi.mocked(prompts).mockResolvedValue({ confirm: true }) + storeBundle.mockResolvedValue({ key: 'stored-bundle-key' }) + vi.mocked(api.projects.deploy).mockResolvedValue({ data: { project: {} as any, diff: [] } }) + declareProject() + }) + + afterEach(() => { + Session.reset() + }) + + it('asks with the deletions the dry run found, without a plan', async () => { + vi.mocked(api.projects.deploy).mockResolvedValue({ + data: { project: {} as any, diff: [{ type: 'check', logicalId: 'gone', physicalId: 7, action: 'DELETE' }] }, + }) + const ctx = createCommandContext({ 'skip-plan': true }) + + await Deploy.prototype.run.call(ctx as any) + + expect(api.projects.preview).not.toHaveBeenCalled() + expect(ctx.style.actionStart).not.toHaveBeenCalledWith('Checking what would change') + const printed = ctx.logged.join('\n') + expect(printed).not.toContain('Deploy preview') + expect(printed).toContain(' - Deploy project "My Project" to account "Test Account"') + expect(printed).toContain(' - Permanently delete Check: gone, losing its run history') + expect(vi.mocked(prompts).mock.calls[0][0]).toMatchObject({ message: 'Proceed?' }) + + // The dry run that found the deletion, then the confirmed deploy, neither + // pinned to a token. + expect(api.projects.deploy).toHaveBeenCalledTimes(2) + expect(vi.mocked(api.projects.deploy).mock.calls[0][1]).toMatchObject({ dryRun: true }) + const confirmed = vi.mocked(api.projects.deploy).mock.calls[1][1] + expect(confirmed?.dryRun).toBeFalsy() + expect(confirmed?.planToken).toBeUndefined() + }) + + it('deploys straight away with --force', async () => { + const ctx = createCommandContext({ 'skip-plan': true, 'force': true }) + + await Deploy.prototype.run.call(ctx as any) + + expect(api.projects.preview).not.toHaveBeenCalled() + expect(prompts).not.toHaveBeenCalled() + expect(api.projects.deploy).toHaveBeenCalledOnce() + expect(vi.mocked(api.projects.deploy).mock.calls[0][1]).toMatchObject({ planToken: undefined }) + }) + + it('sends the older payload once when the API rejects the full one, and says so', async () => { + vi.mocked(getGitRepoRoot).mockReturnValue(repoRoot) + declareProjectIn(repoRoot) + vi.mocked(api.projects.deploy) + .mockRejectedValueOnce(new ValidationError({ statusCode: 400, error: 'Bad Request', message: '"sourceFile" is not allowed' })) + .mockResolvedValueOnce({ data: { project: {} as any, diff: [] } }) + const ctx = createCommandContext({ 'skip-plan': true, 'force': true }) + + await Deploy.prototype.run.call(ctx as any) + + expect(api.projects.deploy).toHaveBeenCalledTimes(2) + const [first] = vi.mocked(api.projects.deploy).mock.calls[0] + const [second] = vi.mocked(api.projects.deploy).mock.calls[1] + expect(first.resources[0]).toHaveProperty('sourceFile') + expect(second.resources[0]).not.toHaveProperty('sourceFile') + expect(ctx.style.longWarning).toHaveBeenCalledWith( + expect.stringContaining('does not know the fields the preview endpoint added'), + expect.any(String), + ) + }) + + it('treats a raw 400 naming such a field the same way, and surfaces the first refusal when both fail', async () => { + vi.mocked(getGitRepoRoot).mockReturnValue(repoRoot) + declareProjectIn(repoRoot) + const first = Object.assign( + new Error('"resources[0].sourceFile" is not allowed'), + { response: { status: 400, data: { message: '"resources[0].sourceFile" is not allowed' } } }, + ) + const second = new ValidationError({ statusCode: 400, error: 'Bad Request', message: 'something else' }) + vi.mocked(api.projects.deploy).mockRejectedValueOnce(first).mockRejectedValueOnce(second) + const ctx = createCommandContext({ 'skip-plan': true, 'force': true }) + + await expect(Deploy.prototype.run.call(ctx as any)).rejects.toThrow('EXIT_1') + + expect(api.projects.deploy).toHaveBeenCalledTimes(2) + expect(vi.mocked(api.projects.deploy).mock.calls[1][0].resources[0]).not.toHaveProperty('sourceFile') + expect(ctx.style.longError).toHaveBeenCalledWith(expect.any(String), first) + expect(ctx.style.longWarning).not.toHaveBeenCalled() + }) + + it('does not retry a refusal that is not about an unknown field', async () => { + vi.mocked(getGitRepoRoot).mockReturnValue(repoRoot) + declareProjectIn(repoRoot) + for (const message of ['"frequency" must be a number', '"resources[0].sourceFile" must be a string']) { + vi.mocked(api.projects.deploy).mockClear() + vi.mocked(api.projects.deploy) + .mockRejectedValue(new ValidationError({ statusCode: 400, error: 'Bad Request', message })) + const ctx = createCommandContext({ 'skip-plan': true, 'force': true }) + + await expect(Deploy.prototype.run.call(ctx as any)).rejects.toThrow('EXIT_1') + + expect(api.projects.deploy).toHaveBeenCalledOnce() + } + }) + + it('keeps a second failure that is not a refusal, and retries a plan-less run without the flag', async () => { + vi.mocked(getGitRepoRoot).mockReturnValue(repoRoot) + declareProjectIn(repoRoot) + const refusal = new ValidationError({ statusCode: 400, error: 'Bad Request', message: '"sourceFile" is not allowed' }) + const conflict = new ConflictError({ statusCode: 409, error: 'Conflict', message: 'in progress' }) + vi.mocked(api.projects.deploy).mockRejectedValueOnce(refusal).mockRejectedValueOnce(conflict) + const ctx = createCommandContext({ 'skip-plan': true, 'force': true }) + + await expect(Deploy.prototype.run.call(ctx as any)).rejects.toThrow('EXIT_1') + expect(api.projects.deploy).toHaveBeenCalledTimes(2) + expect(ctx.style.longError).toHaveBeenCalledWith(expect.stringContaining('in progress'), expect.anything()) + + // A preview that failed for a reason other than a missing endpoint leaves + // the run equally unsure of the API, so it gets the same retry. + vi.mocked(api.projects.deploy).mockClear() + vi.mocked(api.projects.preview).mockRejectedValue(new Error('gateway timeout')) + vi.mocked(api.projects.deploy) + .mockRejectedValueOnce(refusal) + .mockResolvedValueOnce({ data: { project: {} as any, diff: [] } }) + const unplanned = createCommandContext({ force: true }) + await Deploy.prototype.run.call(unplanned as any) + expect(api.projects.deploy).toHaveBeenCalledTimes(2) + expect(unplanned.style.longWarning).toHaveBeenCalledWith( + expect.stringContaining('does not know the fields the preview endpoint added'), + expect.any(String), + ) + }) + + it('warns after the deploy, not before a prompt, when the dry run already fell back', async () => { + vi.mocked(getGitRepoRoot).mockReturnValue(repoRoot) + declareProjectIn(repoRoot) + vi.mocked(api.projects.deploy) + .mockRejectedValueOnce(new ValidationError({ statusCode: 400, error: 'Bad Request', message: '"sourceFile" is not allowed' })) + .mockResolvedValue({ data: { project: {} as any, diff: [] } }) + const ctx = createCommandContext({ 'skip-plan': true }) + + await Deploy.prototype.run.call(ctx as any) + + // Dry run, its retry, then the confirmed deploy already in the older form. + expect(api.projects.deploy).toHaveBeenCalledTimes(3) + expect(vi.mocked(api.projects.deploy).mock.calls[2][0].resources[0]).not.toHaveProperty('sourceFile') + expect(ctx.style.longWarning).toHaveBeenCalledOnce() + expect(vi.mocked(ctx.style.longWarning).mock.invocationCallOrder[0]) + .toBeGreaterThan(vi.mocked(prompts).mock.invocationCallOrder[0]) + }) + + it('does not retry a planned deploy', async () => { + planResolves() + vi.mocked(api.projects.deploy) + .mockRejectedValue(new ValidationError({ statusCode: 400, error: 'Bad Request', message: '"sourceFile" is not allowed' })) + const ctx = createCommandContext({ force: true }) + + await expect(Deploy.prototype.run.call(ctx as any)).rejects.toThrow('EXIT_1') + + expect(api.projects.deploy).toHaveBeenCalledOnce() + }) + + it('is --skip-preview too, and cannot be combined with a plan feature', async () => { + const { flags } = await Parser.parse(['--skip-preview'], { flags: Deploy.flags, strict: true }) + expect(flags['skip-plan']).toBe(true) + + for (const argv of [['--skip-plan', '--preview'], ['--skip-plan', '--dry-run'], ['--skip-plan', '--plan-token', PLAN_TOKEN], ['--skip-plan', '--prune-relations']]) { + await expect(Parser.parse(argv, { flags: Deploy.flags, strict: true }), argv.join(' ')) + .rejects.toThrow(/cannot also be provided/) + } + }) +}) + describe('deploy confirmCommand', () => { it('echoes only the flags the user typed', async () => { expect(await confirmCommandFor([])).toBe('checkly deploy --force') @@ -769,6 +1050,7 @@ describe('deploy confirmCommand', () => { ['--verbose'], ['--prune-relations'], ['--plan-token', PLAN_TOKEN], + ['--skip-plan'], ] for (const argv of argvs) { const confirmCommand = await confirmCommandFor(argv) diff --git a/packages/cli/src/commands/__tests__/confirm-or-abort.spec.ts b/packages/cli/src/commands/__tests__/confirm-or-abort.spec.ts index d1c2540a..cbe64ac2 100644 --- a/packages/cli/src/commands/__tests__/confirm-or-abort.spec.ts +++ b/packages/cli/src/commands/__tests__/confirm-or-abort.spec.ts @@ -131,6 +131,45 @@ describe('confirmOrAbort', () => { expect(ctx.exit).not.toHaveBeenCalled() }) + it('asks the terminal question under the rendered plan, and renders it only there', async () => { + vi.mocked(detectCliMode).mockReturnValue('interactive') + vi.mocked(prompts).mockResolvedValue({ confirm: true }) + const plan = vi.fn(() => 'Deploy preview\n') + const preview: CommandPreview = { + ...basePreview, + terminal: { plan, changes: ['Deploy project "Acme"'] }, + question: 'Apply these changes?', + } + + const ctx = createMockCommand() + await AuthCommand.prototype.confirmOrAbort.call( + { ...ctx, constructor: AuthCommand } as any, + preview, + { force: false, dryRun: false }, + ) + expect(plan).toHaveBeenCalledOnce() + expect(ctx.logged[0]).toBe('Deploy preview\n\nThis will Deploy project "Acme"') + expect(vi.mocked(prompts).mock.calls[0][0]).toMatchObject({ message: 'Apply these changes?' }) + + // Neither a forced run nor an agent envelope shows the plan, so neither + // renders it; the envelope keeps the flat changes. + plan.mockClear() + await AuthCommand.prototype.confirmOrAbort.call( + { ...createMockCommand(), constructor: AuthCommand } as any, + preview, + { force: true }, + ) + vi.mocked(detectCliMode).mockReturnValue('agent') + const agent = createMockCommand() + await expect(AuthCommand.prototype.confirmOrAbort.call( + { ...agent, constructor: AuthCommand } as any, + preview, + { force: false }, + )).rejects.toThrow('EXIT_2') + expect(plan).not.toHaveBeenCalled() + expect(JSON.parse(agent.logged[0]).changes).toEqual(['Will create incident "Test"']) + }) + it('exits 0 when user declines interactive confirmation', async () => { vi.mocked(detectCliMode).mockReturnValue('interactive') vi.mocked(prompts).mockResolvedValue({ confirm: false }) diff --git a/packages/cli/src/commands/authCommand.ts b/packages/cli/src/commands/authCommand.ts index d7b276b3..c2d09073 100644 --- a/packages/cli/src/commands/authCommand.ts +++ b/packages/cli/src/commands/authCommand.ts @@ -87,7 +87,11 @@ export abstract class AuthCommand extends BaseCommand { const confirmed = options.interactiveConfirm ? await options.interactiveConfirm() - : (await prompts({ name: 'confirm', type: 'confirm', message: 'Proceed?' })).confirm + : (await prompts({ + name: 'confirm', + type: 'confirm', + message: preview.question ?? 'Proceed?', + })).confirm if (!confirmed) { return this.exit(0) diff --git a/packages/cli/src/commands/deploy.ts b/packages/cli/src/commands/deploy.ts index c5200322..ed61ebb0 100644 --- a/packages/cli/src/commands/deploy.ts +++ b/packages/cli/src/commands/deploy.ts @@ -3,7 +3,6 @@ import * as fs from 'fs/promises' import * as api from '../rest/api.js' import { Flags } from '@oclif/core' import { AuthCommand } from './authCommand.js' -import { detectCliMode } from '../helpers/cli-mode.js' import { parseProject } from '../services/project-parser.js' import { loadChecklyConfig, resolveDependencyCacheVersion } from '../services/checkly-config-loader.js' import { Session } from '../constructs/index.js' @@ -26,7 +25,7 @@ import { ProjectPreviewResponse, ProjectSync, } from '../rest/projects.js' -import { ConflictError } from '../rest/errors.js' +import { ConflictError, ValidationError } from '../rest/errors.js' import { stripUnsupportedDeployFields } from '../services/deploy-diff/legacy-payload.js' import { planChangeLines, reducePlanForAgent } from '../services/deploy-diff/plan-summary.js' import { uploadSnapshots } from '../services/snapshot-service.js' @@ -34,6 +33,31 @@ import { BrowserCheckBundle } from '../constructs/browser-check-bundle.js' import { Runtime } from '../runtimes/index.js' import { Bundler } from '../services/check-parser/bundler.js' +/** + * The deploy payload fields that arrived with the preview endpoint, which a + * deploy schema older than it rejects by name (`"sourceFile" is not allowed`). + * Kept in step with `stripUnsupportedDeployFields`. + */ +const PREVIEW_ERA_FIELDS = ['sourceFile', 'codeBundleSha256', 'sha256'] + +/** + * Whether the API refused the payload because of a field an older deploy + * schema does not know: a 400 whose message names the field as not allowed. + * Only such a refusal is worth repeating in the older form: any other 400, + * including a bad value for one of these very fields, describes a problem + * the older form would not fix, or would hide. A refusal whose body the + * client recognises arrives as a {@link ValidationError}; one it does not + * arrives as the raw HTTP error. + */ +function rejectsPreviewEraField (err: any): boolean { + const status = err instanceof ValidationError ? 400 : err?.response?.status ?? err?.data?.statusCode + if (status !== 400) { + return false + } + const message = String(err?.data?.message ?? err?.response?.data?.message ?? err?.message ?? '') + return message.includes('is not allowed') && PREVIEW_ERA_FIELDS.some(field => message.includes(field)) +} + export default class Deploy extends AuthCommand { static coreCommand = true static hidden = false @@ -71,6 +95,13 @@ export default class Deploy extends AuthCommand { description: 'Deploy only if the plan still matches this token from an earlier run. ' + 'Aborts if anything changed in your Checkly account since then.', }), + 'skip-plan': Flags.boolean({ + description: 'Deploy without asking Checkly for a plan first. Nothing is previewed and no plan token is ' + + 'used; resources to delete are still listed before you confirm.', + default: false, + aliases: ['skip-preview'], + exclusive: ['preview', 'dry-run', 'plan-token', 'prune-relations'], + }), 'prune-relations': Flags.boolean({ description: 'Delete the alert channel subscriptions and private location assignments on this project\'s ' + 'checks and groups that the project does not manage.', @@ -109,6 +140,7 @@ export default class Deploy extends AuthCommand { preview, 'dry-run': dryRun, 'plan-token': requestedPlanToken, + 'skip-plan': skipPlan, 'prune-relations': pruneRelations, 'cancel-in-progress-deployment': cancelInProgress, 'schedule-on-deploy': scheduleOnDeploy, @@ -285,78 +317,83 @@ export default class Deploy extends AuthCommand { // upload: the payload describes the code bundle and every snapshot by // content hash. // `full` buys each changed resource's current state, which is what the - // rendered construct diff (`--preview`, `--output`) and the machine-readable - // envelope (`--dry-run`, the `confirmation_required` an agent or CI run - // prints) show. A plain interactive deploy lists resources, not - // properties, so it does not pay for state it would not print. - const detail = dryRun || preview || output || (!force && detectCliMode() !== 'interactive') ? 'full' : 'changes' + // rendered construct diff (`--preview`, `--output`, the plan an interactive + // run shows before asking) and the machine-readable envelope (`--dry-run`, + // the `confirmation_required` an agent or CI run prints) show. Only a + // forced deploy prints nothing of the kind, so only it skips paying for + // the state. + const detail = dryRun || preview || output || !force ? 'full' : 'changes' let plan: ProjectPreviewResponse | undefined // Set when the API has no preview endpoint, which also means it rejects the // payload fields that arrived with it. let previewNotSupported = false - this.style.actionStart('Checking what would change') - try { - plan = await api.projects.preview(projectPayload, { - detail, - preserveResources, - pruneRelations, - onStatus: message => this.style.actionStatus(message), - }) - this.style.actionSuccess() - } catch (err: any) { - this.style.actionFailure() - previewNotSupported = err instanceof ProjectPreviewNotSupportedError - const previewSupported = !previewNotSupported - - // --prune-relations deletes data, and without a plan nothing can say - // what: an API that predates the preview endpoint would not prune at all, - // and an API that would prune cannot be asked what it is about to delete. - // Both are refused rather than silently downgraded. - if (pruneRelations) { - this.style.longError( + // --skip-plan deploys whatever the account looks like when the deploy + // runs: no plan, no token, and nothing to render before the prompt. + if (!skipPlan) { + this.style.actionStart('Checking what would change') + try { + plan = await api.projects.preview(projectPayload, { + detail, + preserveResources, + pruneRelations, + onStatus: message => this.style.actionStatus(message), + }) + this.style.actionSuccess() + } catch (err: any) { + this.style.actionFailure() + previewNotSupported = err instanceof ProjectPreviewNotSupportedError + const previewSupported = !previewNotSupported + + // --prune-relations deletes data, and without a plan nothing can say + // what: an API that predates the preview endpoint would not prune at all, + // and an API that would prune cannot be asked what it is about to delete. + // Both are refused rather than silently downgraded. + if (pruneRelations) { + this.style.longError( + previewSupported + ? 'Could not check which relations --prune-relations would delete, so nothing was deployed.' + : 'This Checkly API cannot prune relations yet.', + previewSupported ? 'Try again in a moment.' : 'Re-run without --prune-relations.', + ) + this.exit(1) + } + + // A deploy pinned to a plan cannot proceed without knowing the plan. + if (requestedPlanToken !== undefined) { + this.style.longError( + 'Could not check the plan this deploy is pinned to.', + previewSupported + ? err.message + : 'This Checkly API does not support deploy previews; re-run without --plan-token.', + ) + this.exit(1) + } + + // Deploying without a reviewed plan beats not deploying at all: one + // resource Checkly cannot read, or an API that is a version behind, must + // not make a project undeployable. The run falls back to the coarser + // dry-run diff and its delete guard, and sends no plan token. + this.style.longWarning( previewSupported - ? 'Could not check which relations --prune-relations would delete, so nothing was deployed.' - : 'This Checkly API cannot prune relations yet.', - previewSupported ? 'Try again in a moment.' : 'Re-run without --prune-relations.', + // Say which failure it was: the user is about to get a coarser answer + // than they asked for and deserves to know why. + ? `Could not check what this deploy would change: ${err.message}` + // A 404 from this path means the endpoint is not there; whether that + // is an API predating it or something else in the way, the CLI cannot + // tell, so it says what it observed. + : 'This Checkly API answered 404 for the deploy preview endpoint.', + 'Falling back to a summary of created, updated and deleted resources.', ) - this.exit(1) } - // A deploy pinned to a plan cannot proceed without knowing the plan. - if (requestedPlanToken !== undefined) { + if (plan !== undefined && requestedPlanToken !== undefined && requestedPlanToken !== plan.planToken) { this.style.longError( - 'Could not check the plan this deploy is pinned to.', - previewSupported - ? err.message - : 'This Checkly API does not support deploy previews; re-run without --plan-token.', + 'Your Checkly account no longer matches the plan this deploy is pinned to, so nothing was deployed.', + 'Re-run `checkly deploy --preview` to see the current plan.', ) this.exit(1) } - - // Deploying without a reviewed plan beats not deploying at all: one - // resource Checkly cannot read, or an API that is a version behind, must - // not make a project undeployable. The run falls back to the coarser - // dry-run diff and its delete guard, and sends no plan token. - this.style.longWarning( - previewSupported - // Say which failure it was: the user is about to get a coarser answer - // than they asked for and deserves to know why. - ? `Could not check what this deploy would change: ${err.message}` - // A 404 from this path means the endpoint is not there; whether that - // is an API predating it or something else in the way, the CLI cannot - // tell, so it says what it observed. - : 'This Checkly API answered 404 for the deploy preview endpoint.', - 'Falling back to a summary of created, updated and deleted resources.', - ) - } - - if (plan !== undefined && requestedPlanToken !== undefined && requestedPlanToken !== plan.planToken) { - this.style.longError( - 'Your Checkly account no longer matches the plan this deploy is pinned to, so nothing was deployed.', - 'Re-run `checkly deploy --preview` to see the current plan.', - ) - this.exit(1) } // The payload goes out in the form the deploy route accepted before the @@ -372,6 +409,43 @@ export default class Deploy extends AuthCommand { const deployPayload = (): ProjectSync => previewNotSupported || !uploaded ? stripUnsupportedDeployFields(synthesize()) : synthesize() + // A planned run learns from the preview call whether the API has the + // endpoint, and with it whether the deploy route knows the fields that + // arrived with it. A run without a plan — --skip-plan, or a preview that + // failed for a reason other than a missing endpoint — does not, so a + // payload the route refuses because of one of those fields is sent once + // more in the older form; the rest of the run then sends that form too. + // If the older form is refused as well, the refusal of the payload the + // user asked for is the one that surfaces; any other failure of the + // second attempt is its own and keeps its own handling. The older form + // blanks the stored content hashes, which the next planned deploy reports + // as a change to every Playwright check suite and every check with + // snapshots — once, and worth a warning once a deploy has gone out that + // way. + let sentLegacyPayload = false + const deployOrRetryLegacy = async ( + options: Parameters[1], + ): Promise<{ data: ProjectDeployResponse }> => { + try { + return await api.projects.deploy(deployPayload(), options) + } catch (err: any) { + if (plan !== undefined || previewNotSupported || !rejectsPreviewEraField(err)) { + throw err + } + previewNotSupported = true + try { + const result = await api.projects.deploy(deployPayload(), options) + sentLegacyPayload = true + return result + } catch (retryErr: any) { + if (retryErr instanceof ValidationError) { + throw err + } + throw retryErr + } + } + } + // Without a plan, deletions are only visible in a dry-run deploy — which // validates the storage keys, so the uploads have to happen first. let fallbackDiff: ProjectDeployResponse | undefined @@ -385,7 +459,7 @@ export default class Deploy extends AuthCommand { } this.style.actionStart('Verifying deployed state') try { - const { data } = await api.projects.deploy(deployPayload(), { + const { data } = await deployOrRetryLegacy({ dryRun: true, scheduleOnDeploy, preserveResources, @@ -399,16 +473,20 @@ export default class Deploy extends AuthCommand { } } + // The plan as `--preview` prints it, which is also what a terminal sees + // before it is asked. Without a plan, the dry run's findings are listed. + const renderPlan = (planToken?: string): string => formatPreview({ + heading: { title: 'Deploy preview', projectName: project.name, accountName: account.name }, + diff: plan?.diff ?? fallbackDiff?.diff ?? [], + project, + verbose, + pruneRelations, + rendering: plan !== undefined ? { plan: plan.diff, local: projectPayload.resources } : undefined, + planToken, + }) + if (preview && !dryRun) { - this.log(formatPreview({ - heading: { title: 'Deploy preview', projectName: project.name, accountName: account.name }, - diff: plan?.diff ?? fallbackDiff?.diff ?? [], - project, - verbose, - pruneRelations, - rendering: plan !== undefined ? { plan: plan.diff, local: projectPayload.resources } : undefined, - planToken: plan?.planToken, - })) + this.log(renderPlan(plan?.planToken)) return } @@ -423,11 +501,17 @@ export default class Deploy extends AuthCommand { // The one confirmation of the command: the plan is known by now, so the // prompt, the agent envelope and --dry-run all describe the deploy that is - // about to run rather than a deploy nobody has seen. + // about to run rather than a deploy nobody has seen. A terminal gets the + // plan rendered as `--preview` prints it, with the options under it; the + // plan lines are not repeated there since the overview names every + // resource. No plan-token footer: this run pins the token itself. await this.confirmOrAbort({ command: 'deploy', description: 'Deploy project to Checkly', changes: [...optionLines, ...planLines], + ...plan !== undefined + ? { terminal: { plan: renderPlan, changes: optionLines }, question: 'Apply these changes?' } + : {}, // The token rides along in the echoed command, so the confirming run // deploys the plan that was shown here and refuses a different one. flags: plan !== undefined ? { ...flags, 'plan-token': plan.planToken } : flags, @@ -440,18 +524,15 @@ export default class Deploy extends AuthCommand { await uploadArtifacts() - const runDeploy = () => api.projects.deploy( - deployPayload(), - { - scheduleOnDeploy, - preserveResources, - pruneRelations, - planToken: plan?.planToken, - cancelInProgress, - onProgress: progress => this.style.actionStatus(`${progress}% complete`), - onStatus: message => this.style.actionStatus(message), - }, - ) + const runDeploy = () => deployOrRetryLegacy({ + scheduleOnDeploy, + preserveResources, + pruneRelations, + planToken: plan?.planToken, + cancelInProgress, + onProgress: progress => this.style.actionStatus(`${progress}% complete`), + onStatus: message => this.style.actionStatus(message), + }) try { this.style.actionStart('Deploying project') @@ -463,7 +544,9 @@ export default class Deploy extends AuthCommand { // failing a pipeline because someone touched the account while the code // bundle was uploading, it plans again and deploys that. A pinned run, // or one a person confirmed, is refused instead — see the catch below. - if (!(err instanceof ProjectPlanStaleError) || !force || requestedPlanToken !== undefined) { + // A run that skipped the plan sent no token, so it cannot be told its + // plan went stale; the guard keeps it out of the re-plan regardless. + if (!(err instanceof ProjectPlanStaleError) || !force || requestedPlanToken !== undefined || skipPlan) { throw err } this.style.actionStatus('Your Checkly account changed; checking again and deploying the current plan') @@ -471,6 +554,12 @@ export default class Deploy extends AuthCommand { ;({ data } = await runDeploy()) } this.style.actionSuccess() + if (sentLegacyPayload) { + this.style.longWarning( + 'This Checkly API does not know the fields the preview endpoint added, so the deploy was sent without them.', + 'The next `checkly deploy` reports every Playwright check suite and every check with snapshots as changed.', + ) + } if (output) { // The deploy response names every resource with its id; the plan the // deploy was confirmed against is where each one's deployed state is. diff --git a/packages/cli/src/helpers/__tests__/command-preview.spec.ts b/packages/cli/src/helpers/__tests__/command-preview.spec.ts index 1b3c5d71..3bf603bc 100644 --- a/packages/cli/src/helpers/__tests__/command-preview.spec.ts +++ b/packages/cli/src/helpers/__tests__/command-preview.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { formatPreviewForAgent, formatPreviewForTerminal, @@ -73,6 +73,26 @@ describe('formatPreviewForTerminal', () => { expect(result).toContain('Affected services') expect(result).toContain('Will notify subscribers') }) + + it('prints the rendered plan and its own lines instead of the changes', () => { + const plan = vi.fn(() => 'Deploy preview\n ~ Check api\n\n1 to update, 0 unchanged\n') + const result = formatPreviewForTerminal({ + ...samplePreview, + terminal: { plan, changes: ['Deploy project "Acme" to account "Test"', 'Schedule checks after deploy'] }, + }) + expect(plan).toHaveBeenCalledOnce() + expect(result).toBe([ + 'Deploy preview', + ' ~ Check api', + '', + '1 to update, 0 unchanged', + '', + 'This will:', + ' - Deploy project "Acme" to account "Test"', + ' - Schedule checks after deploy', + ].join('\n')) + expect(result).not.toContain('DB outage') + }) }) describe('buildConfirmCommand', () => { diff --git a/packages/cli/src/helpers/command-preview.ts b/packages/cli/src/helpers/command-preview.ts index 659ea877..c4fc9f90 100644 --- a/packages/cli/src/helpers/command-preview.ts +++ b/packages/cli/src/helpers/command-preview.ts @@ -25,6 +25,21 @@ export type CommandPlanPreview = { diff: DiffEntry[] } +/** + * What an interactive terminal shows instead of the flat `changes` list: a + * rendered plan (an overview of every resource the command touches, with a + * diff per updated one) followed by the lines that still need saying — the + * options the command runs with, which no overview row carries. + * + * `plan` is rendered on demand because only the interactive branch prints it; + * a forced, dry-run or agent run never pays for the rendering, and an error + * in it cannot break an unattended run. + */ +export type CommandTerminalPreview = { + plan: () => string + changes: string[] +} + export type CommandPreview = { command: string description: string @@ -34,6 +49,9 @@ export type CommandPreview = { args?: Record classification: CommandClassification preview?: CommandPlanPreview + terminal?: CommandTerminalPreview + /** The yes/no question an interactive terminal asks. `Proceed?` when left out. */ + question?: string } export type AgentPreviewResponse = { @@ -112,14 +130,19 @@ export function formatPreviewForAgent ( } export function formatPreviewForTerminal (preview: CommandPreview): string { - if (preview.changes.length === 1) { - return `This will ${preview.changes[0]}` - } - + const changes = preview.terminal?.changes ?? preview.changes const lines: string[] = [] - lines.push('This will:') - for (const change of preview.changes) { - lines.push(` - ${change}`) + if (preview.terminal !== undefined) { + // The rendered plan ends in a blank line of its own. + lines.push(preview.terminal.plan()) + } + if (changes.length === 1) { + lines.push(`This will ${changes[0]}`) + } else { + lines.push('This will:') + for (const change of changes) { + lines.push(` - ${change}`) + } } return lines.join('\n') } diff --git a/packages/cli/src/services/deploy-diff/__tests__/preview-output.spec.ts b/packages/cli/src/services/deploy-diff/__tests__/preview-output.spec.ts index 54362d4b..302a9970 100644 --- a/packages/cli/src/services/deploy-diff/__tests__/preview-output.spec.ts +++ b/packages/cli/src/services/deploy-diff/__tests__/preview-output.spec.ts @@ -178,9 +178,11 @@ describe('formatPreview', () => { } const pruning = uncoloured(formatPreview({ diff: [check, relation], project, pruneRelations: true })) expect(pruning).toContain( - ' - alert-channel-subscription unmanaged:7 relation not managed by this project, deleted by --prune-relations', + ' - alert-channel-subscription unmanaged:7 relation on Check api-health not managed by this project, ' + + 'deleted by --prune-relations', ) - expect(pruning).not.toContain('api-health') + // The check itself gets no row: its only change is the relation. + expect(pruning).not.toContain('! Check') expect(pruning).toContain('1 relation pruned, 0 unchanged') const leaving = uncoloured(formatPreview({ diff: [check], project })) diff --git a/packages/cli/src/services/deploy-diff/preview-output.ts b/packages/cli/src/services/deploy-diff/preview-output.ts index 8de7e7ba..52f07e63 100644 --- a/packages/cli/src/services/deploy-diff/preview-output.ts +++ b/packages/cli/src/services/deploy-diff/preview-output.ts @@ -94,6 +94,8 @@ interface Listed { logicalId: string physicalId?: string | number construct?: any + /** For a pruned relation: the check or group it belongs to. */ + owner?: string } const compareEntries = (a: Listed, b: Listed): number => @@ -129,7 +131,14 @@ export function formatPreview (input: PreviewOutputInput): string { // the project does NOT manage is only ever reported when --prune-relations // would delete it, and that is worth its own line. if (isPrunedRelation(change)) { - pruning.push({ resourceType: type, logicalId }) + // The relation's own id says nothing to the user; the check or group + // it hangs off is what tells them what the prune touches. + const owner = change.foldedInto + pruning.push({ + resourceType: type, + logicalId, + owner: owner ? `${PRETTY_RESOURCE_TYPES[owner.type] ?? owner.type} ${owner.logicalId}` : undefined, + }) } continue } @@ -147,7 +156,10 @@ export function formatPreview (input: PreviewOutputInput): string { } continue } - const construct = project.data[type as keyof ProjectData][logicalId] + // A type this CLI version does not know (a newer CLI deployed it) has no + // slot in the project. Such an entry has no construct, and a created or + // updated one is dropped from the overview below rather than crashing it. + const construct = project.data[type as keyof ProjectData]?.[logicalId] if (action === ResourceDeployStatus.UPDATE) { updating.push({ resourceType: type, logicalId, physicalId, construct }) } else if (action === ResourceDeployStatus.UNCHANGED) { @@ -247,7 +259,9 @@ export function formatPreview (input: PreviewOutputInput): string { MARKER.detach, listed, chalk.yellow('kept in your Checkly account, now managed from the Checkly web app'), )), ...sortedPruning.map(listed => withNote( - MARKER.prune, listed, chalk.red('relation not managed by this project, deleted by --prune-relations'), + MARKER.prune, listed, + chalk.red(`relation${listed.owner ? ` on ${listed.owner}` : ''} not managed by this project, ` + + 'deleted by --prune-relations'), )), ...sortedUnmanaged.map(listed => withNote( MARKER.warn, listed,