Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/cli/src/ai-context/references/configure.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ Run `npx checkly skills manage plan` for the full reference.

- 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 <id>`).
- 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.
- When the preview shows a resource that was edited outside the project (in the web app or through the API), an interactive `checkly deploy` offers a third choice next to apply and cancel: update the code with the values from Checkly and deploy nothing. It rewrites only literal values (strings, numbers, booleans, and arrays or objects of those) inside the `new ApiCheck('id', { … })` call of checks and check groups, leaves the rest of the file untouched, and lists everything it could not update with the reason (helper values such as `Frequency.EVERY_5M`, references to other resources, secrets, scripts). It exists only in a terminal; there is no flag for it, and the `confirmation_required` envelope is unchanged.
- 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.

Expand Down
194 changes: 194 additions & 0 deletions packages/cli/src/commands/__tests__/confirm-flow-deploy.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import fs from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'

import { Parser } from '@oclif/core'
Expand Down Expand Up @@ -63,6 +65,11 @@ vi.mock('prompts', () => ({
default: vi.fn(() => Promise.resolve({ confirm: true })),
}))

vi.mock('../../services/write-back/plan', async importOriginal => {
const original = await importOriginal<typeof import('../../services/write-back/plan.js')>()
return { ...original, applyWriteBack: vi.fn(original.applyWriteBack) }
})

import prompts from 'prompts'

import { detectCliMode } from '../../helpers/cli-mode.js'
Expand All @@ -77,6 +84,8 @@ import {
import { Ok } from '../../services/check-parser/package-files/result.js'
import { parseProject } from '../../services/project-parser.js'
import { getGitRepoRoot } from '../../services/util.js'
import { applyWriteBack } from '../../services/write-back/plan.js'
import { ApiCheck } from '../../constructs/api-check.js'
import { EmailAlertChannel } from '../../constructs/email-alert-channel.js'
import { Project } from '../../constructs/project.js'
import { Session } from '../../constructs/session.js'
Expand Down Expand Up @@ -1061,3 +1070,188 @@ describe('deploy confirmCommand', () => {
}
})
})

describe('deploy write-back from a terminal', () => {
const SOURCE = `import { ApiCheck } from 'checkly/constructs'

new ApiCheck('api', {
name: 'API',
request: { url: 'https://example.com', method: 'GET' },
})
`
const remoteEdit: DiffEntry = {
type: 'check',
logicalId: 'api',
physicalId: 'a1',
action: 'UPDATE',
changes: [
{ path: '/name', origin: 'remote', before: 'API', after: 'API renamed' },
{ path: '/frequency', origin: 'remote', before: 10, after: 5 },
],
before: { id: 'a1', checkType: 'API', name: 'API renamed', frequency: 5, request: { url: 'https://example.com', method: 'GET' } },
redactions: [],
}
let dir: string

/** The project with an API check declared in a real file, so the write-back has something to edit. */
async function declareProjectWithFile () {
Session.reset()
Session.workspace = Ok({} as any)
const project = new Project('my-project', { name: 'My Project' })
Session.project = project
const file = path.join(dir, 'api.check.ts')
await fs.writeFile(file, SOURCE, 'utf8')
Session.checkFileAbsolutePath = file
new ApiCheck('api', { name: 'API', request: { url: 'https://example.com', method: 'GET' } })
Session.checkFileAbsolutePath = undefined
vi.mocked(parseProject).mockResolvedValue(project)
}

beforeEach(async () => {
vi.clearAllMocks()
vi.mocked(detectCliMode).mockReturnValue('interactive')
storeBundle.mockResolvedValue({ key: 'stored-bundle-key' })
vi.mocked(api.projects.deploy).mockResolvedValue({ data: { project: {} as any, diff: [] } })
dir = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'deploy-write-back-')))
await declareProjectWithFile()
})

afterEach(async () => {
Session.reset()
await fs.rm(dir, { recursive: true, force: true })
})

it('offers to update the code when a resource was edited in Checkly, and does so instead of deploying', async () => {
planResolves([remoteEdit])
vi.mocked(prompts).mockResolvedValue({ action: 'alternative:0' })
const spy = vi.spyOn(process, 'cwd').mockReturnValue(dir)
const ctx = createCommandContext()

try {
await expect(Deploy.prototype.run.call(ctx as any)).rejects.toThrow('EXIT_0')
} finally {
spy.mockRestore()
}

expect(vi.mocked(prompts).mock.calls[0][0]).toMatchObject({
type: 'select',
choices: [
{ value: 'apply' },
{ title: 'Update my code with the changes made in Checkly (deploys nothing)', value: 'alternative:0' },
{ value: 'cancel' },
],
})
expect(await fs.readFile(path.join(dir, 'api.check.ts'), 'utf8')).toBe(`import { ApiCheck } from 'checkly/constructs'

new ApiCheck('api', {
name: 'API renamed',
request: { url: 'https://example.com', method: 'GET' },
frequency: 5,
})
`)
const printed = ctx.logged.join('\n')
expect(printed).toContain('Updated 1 file:\n api.check.ts: check api name: \'API\' -> \'API renamed\'')
// A property the code does not set is added; a whole-minute frequency is
// a plain number the construct accepts.
expect(printed).toContain(' api.check.ts: check api frequency: not set -> 5')
expect(printed).toContain('Nothing was deployed. Review the changes, then run `checkly deploy` again.')
expect(storeBundle).not.toHaveBeenCalled()
expect(api.projects.deploy).not.toHaveBeenCalled()
})

it('does not offer the choice when no remote change could be written', async () => {
planResolves([{
...remoteEdit,
changes: [{ path: '/retryStrategy', origin: 'remote', before: null, after: { type: 'FIXED' } }],
}])
vi.mocked(prompts).mockResolvedValue({ confirm: false })
const ctx = createCommandContext()

await expect(Deploy.prototype.run.call(ctx as any)).rejects.toThrow('EXIT_0')

expect(vi.mocked(prompts).mock.calls[0][0]).toMatchObject({ type: 'confirm' })
expect(await fs.readFile(path.join(dir, 'api.check.ts'), 'utf8')).toBe(SOURCE)
expect(api.projects.deploy).not.toHaveBeenCalled()
})

it('does not offer the choice for a resource whose class it cannot update, or for a secret', async () => {
planResolves([
{
...CHANGED,
redactions: [],
changes: [{ path: '/config/address', origin: 'remote', before: 'ops@example.com', after: 'new@example.com' }],
},
{
...remoteEdit,
changes: [{ path: '/environmentVariables', origin: 'remote', secret: true, after: [{ key: 'K', value: { $masked: 'changed' } }] }],
},
])
vi.mocked(prompts).mockResolvedValue({ confirm: false })
const ctx = createCommandContext()

await expect(Deploy.prototype.run.call(ctx as any)).rejects.toThrow('EXIT_0')

expect(vi.mocked(prompts).mock.calls[0][0]).toMatchObject({ type: 'confirm' })
})

it('lists what it could not update and changes nothing when nothing applies after all', async () => {
// A writable path that turns out unwritable only once the file is read.
await fs.writeFile(path.join(dir, 'api.check.ts'), SOURCE.replace('name: \'API\'', 'name: title'), 'utf8')
planResolves([{ ...remoteEdit, changes: [remoteEdit.changes![0]] }])
vi.mocked(prompts).mockResolvedValue({ action: 'alternative:0' })
const ctx = createCommandContext()

await expect(Deploy.prototype.run.call(ctx as any)).rejects.toThrow('EXIT_0')

const printed = ctx.logged.join('\n')
expect(printed).toContain('Not updated (edit these by hand):\n check api name: name is the variable title, not a plain literal')
expect(printed).toContain('Nothing in the code could be updated automatically, so nothing was changed.')
expect(applyWriteBack).not.toHaveBeenCalled()
expect(api.projects.deploy).not.toHaveBeenCalled()
})

it('reports a write that fails as an error, after listing nothing as updated', async () => {
planResolves([remoteEdit])
vi.mocked(prompts).mockResolvedValue({ action: 'alternative:0' })
vi.mocked(applyWriteBack).mockRejectedValueOnce(new Error('Could not write api.check.ts: EACCES. No file was changed.'))
const ctx = createCommandContext()

await expect(Deploy.prototype.run.call(ctx as any)).rejects.toThrow('EXIT_1')

expect(ctx.style.longError).toHaveBeenCalledWith('Could not update your code.', 'Could not write api.check.ts: EACCES. No file was changed.')
expect(ctx.logged.join('\n')).not.toContain('Updated')
expect(api.projects.deploy).not.toHaveBeenCalled()
})

it('asks the plain yes/no question when nothing was edited in Checkly', async () => {
planResolves([{ ...CHANGED, redactions: [] }])
vi.mocked(prompts).mockResolvedValue({ confirm: true })
const ctx = createCommandContext()

await Deploy.prototype.run.call(ctx as any)

expect(vi.mocked(prompts).mock.calls[0][0]).toMatchObject({ type: 'confirm', message: 'Apply these changes?' })
expect(api.projects.deploy).toHaveBeenCalledOnce()
})

it('applies the plan when the user chooses to', async () => {
planResolves([remoteEdit])
vi.mocked(prompts).mockResolvedValue({ action: 'apply' })
const ctx = createCommandContext()

await Deploy.prototype.run.call(ctx as any)

expect(await fs.readFile(path.join(dir, 'api.check.ts'), 'utf8')).toBe(SOURCE)
expect(api.projects.deploy).toHaveBeenCalledOnce()
})

it('never prompts a forced run', async () => {
planResolves([remoteEdit])
const ctx = createCommandContext({ force: true })

await Deploy.prototype.run.call(ctx as any)

expect(prompts).not.toHaveBeenCalled()
expect(api.projects.deploy).toHaveBeenCalledOnce()
})
})
89 changes: 89 additions & 0 deletions packages/cli/src/commands/__tests__/confirm-or-abort.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,3 +285,92 @@ describe('confirmOrAbort', () => {
expect(output.status).toBe('confirmation_required')
})
})

describe('confirmOrAbort with alternatives', () => {
const alternative = { title: 'Do the other thing', run: vi.fn(() => Promise.resolve()) }
const withAlternative: CommandPreview = {
...basePreview,
question: 'Apply these changes?',
terminal: { plan: () => 'the plan', changes: ['deploy'], alternatives: [alternative] },
}

beforeEach(() => {
vi.clearAllMocks()
vi.mocked(detectCliMode).mockReturnValue('interactive')
})

it('asks a list with the alternative between apply and cancel, starting on cancel', async () => {
vi.mocked(prompts).mockResolvedValue({ action: 'apply' })
const ctx = createMockCommand()

await AuthCommand.prototype.confirmOrAbort.call(ctx as any, withAlternative, { force: false })

expect(vi.mocked(prompts).mock.calls[0][0]).toEqual({
name: 'action',
type: 'select',
message: 'Apply these changes?',
choices: [
{ title: 'Yes, apply these changes', value: 'apply' },
{ title: 'Do the other thing', value: 'alternative:0' },
{ title: 'Cancel', value: 'cancel' },
],
initial: 2,
})
expect(alternative.run).not.toHaveBeenCalled()
expect(ctx.exit).not.toHaveBeenCalled()
})

it('runs the chosen alternative and then ends the command without applying', async () => {
vi.mocked(prompts).mockResolvedValue({ action: 'alternative:0' })
const ctx = createMockCommand()

await expect(AuthCommand.prototype.confirmOrAbort.call(ctx as any, withAlternative, { force: false }))
.rejects.toThrow('EXIT_0')

expect(alternative.run).toHaveBeenCalledOnce()
})

it('ends the command on cancel and on an aborted prompt', async () => {
for (const answer of [{ action: 'cancel' }, {}]) {
vi.mocked(prompts).mockResolvedValue(answer)
const ctx = createMockCommand()
await expect(AuthCommand.prototype.confirmOrAbort.call(ctx as any, withAlternative, { force: false }))
.rejects.toThrow('EXIT_0')
}
expect(alternative.run).not.toHaveBeenCalled()
})

it('keeps the yes/no question when there is nothing else to offer', async () => {
vi.mocked(prompts).mockResolvedValue({ confirm: true })
const ctx = createMockCommand()
const plain: CommandPreview = { ...withAlternative, terminal: { plan: () => 'the plan', changes: ['deploy'], alternatives: [] } }

await AuthCommand.prototype.confirmOrAbort.call(ctx as any, plain, { force: false })

expect(vi.mocked(prompts).mock.calls[0][0]).toMatchObject({ type: 'confirm', message: 'Apply these changes?' })
})

it('lets a command-supplied confirm win over the alternatives', async () => {
const ctx = createMockCommand()
const interactiveConfirm = vi.fn(() => Promise.resolve(true))

await AuthCommand.prototype.confirmOrAbort.call(ctx as any, withAlternative, { force: false, interactiveConfirm })

expect(interactiveConfirm).toHaveBeenCalledOnce()
expect(prompts).not.toHaveBeenCalled()
})

it('never offers the alternative to an agent, a forced run or a dry run', async () => {
vi.mocked(detectCliMode).mockReturnValue('agent')
let ctx = createMockCommand()
await expect(AuthCommand.prototype.confirmOrAbort.call(ctx as any, withAlternative, { force: false })).rejects.toThrow('EXIT_2')
expect(JSON.parse(ctx.logged[0])).not.toHaveProperty('terminal')

ctx = createMockCommand()
await AuthCommand.prototype.confirmOrAbort.call(ctx as any, withAlternative, { force: true })
ctx = createMockCommand()
await expect(AuthCommand.prototype.confirmOrAbort.call(ctx as any, withAlternative, { force: false, dryRun: true })).rejects.toThrow('EXIT_0')
expect(prompts).not.toHaveBeenCalled()
expect(alternative.run).not.toHaveBeenCalled()
})
})
49 changes: 38 additions & 11 deletions packages/cli/src/commands/authCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,18 +85,45 @@ export abstract class AuthCommand extends BaseCommand {
this.log(formatPreviewForTerminal(preview))
this.log()

const confirmed = options.interactiveConfirm
? await options.interactiveConfirm()
: (await prompts({
name: 'confirm',
type: 'confirm',
message: preview.question ?? 'Proceed?',
})).confirm

if (!confirmed) {
return this.exit(0)
if (options.interactiveConfirm !== undefined) {
if (!await options.interactiveConfirm()) {
return this.exit(0)
}
return
}
return

const question = preview.question ?? 'Proceed?'
const alternatives = preview.terminal?.alternatives ?? []
if (alternatives.length === 0) {
const { confirm } = await prompts({ name: 'confirm', type: 'confirm', message: question })
if (!confirm) {
return this.exit(0)
}
return
}

// With something else on offer, the yes/no question becomes a list. The
// cursor starts on Cancel, so Enter alone applies nothing — as the
// confirm's default of No did.
const choices = [
{ title: 'Yes, apply these changes', value: 'apply' },
...alternatives.map((alternative, index) => ({ title: alternative.title, value: `alternative:${index}` })),
{ title: 'Cancel', value: 'cancel' },
]
const { action } = await prompts({
name: 'action',
type: 'select',
message: question,
choices,
initial: choices.length - 1,
})
if (action === 'apply') {
return
}
if (typeof action === 'string' && action.startsWith('alternative:')) {
await alternatives[Number(action.slice('alternative:'.length))].run()
}
return this.exit(0)
}

// Agent or CI mode: output structured JSON and exit 2
Expand Down
Loading
Loading