From 0b0feb35d36a6015500eaf8a60d04d0f806cb958 Mon Sep 17 00:00:00 2001 From: Herve Labas Date: Sat, 4 Jul 2026 09:46:05 +0200 Subject: [PATCH] Add resources code export command --- packages/cli/package.json | 3 + .../__tests__/command-metadata.spec.ts | 2 + .../__tests__/resources-export.spec.ts | 204 +++++++++++++ packages/cli/src/commands/resources/export.ts | 287 ++++++++++++++++++ .../cli/src/rest/__tests__/resources.spec.ts | 21 ++ packages/cli/src/rest/api.ts | 2 + packages/cli/src/rest/resources.ts | 80 +++++ 7 files changed, 599 insertions(+) create mode 100644 packages/cli/src/commands/__tests__/resources-export.spec.ts create mode 100644 packages/cli/src/commands/resources/export.ts create mode 100644 packages/cli/src/rest/__tests__/resources.spec.ts create mode 100644 packages/cli/src/rest/resources.ts diff --git a/packages/cli/package.json b/packages/cli/package.json index fda21c3d7..fc1043118 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -95,6 +95,9 @@ "rca": { "description": "Trigger and retrieve root cause analyses." }, + "resources": { + "description": "Export Checkly resources as Monitoring as Code constructs." + }, "status-pages": { "description": "List and manage status pages in your Checkly account." }, diff --git a/packages/cli/src/commands/__tests__/command-metadata.spec.ts b/packages/cli/src/commands/__tests__/command-metadata.spec.ts index 76830669b..a5c343b25 100644 --- a/packages/cli/src/commands/__tests__/command-metadata.spec.ts +++ b/packages/cli/src/commands/__tests__/command-metadata.spec.ts @@ -46,6 +46,7 @@ import MembersDelete from '../members/delete.js' import Api from '../api.js' import TestSessionsList from '../test-sessions/list.js' import TestSessionsGet from '../test-sessions/get.js' +import ResourcesExport from '../resources/export.js' const commands: Array<[string, typeof BaseCommand]> = [ ['api', Api], @@ -65,6 +66,7 @@ const commands: Array<[string, typeof BaseCommand]> = [ ['status-pages get', StatusPagesGet], ['test-sessions list', TestSessionsList], ['test-sessions get', TestSessionsGet], + ['resources export', ResourcesExport], ['incidents list', IncidentsList], ['incidents create', IncidentsCreate], ['incidents update', IncidentsUpdate], diff --git a/packages/cli/src/commands/__tests__/resources-export.spec.ts b/packages/cli/src/commands/__tests__/resources-export.spec.ts new file mode 100644 index 000000000..336573575 --- /dev/null +++ b/packages/cli/src/commands/__tests__/resources-export.spec.ts @@ -0,0 +1,204 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' + +vi.mock('../../rest/api.js', () => ({ + resources: { + exportCode: vi.fn(), + }, +})) + +import * as api from '../../rest/api.js' +import ResourcesExport, { + parseResourceSpec, + resolveUpdatedAfter, +} from '../resources/export.js' + +function createCommandContext (parsed: unknown) { + return { + parse: vi.fn().mockResolvedValue(parsed), + } +} + +describe('resources export', () => { + let stdout = '' + let stderr = '' + let stdoutSpy: ReturnType + let stderrSpy: ReturnType + let tempDir: string + + beforeEach(async () => { + vi.clearAllMocks() + process.exitCode = undefined + stdout = '' + stderr = '' + tempDir = await mkdtemp(path.join(tmpdir(), 'checkly-resources-export-')) + stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk: any) => { + stdout += String(chunk) + return true + }) + stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation((chunk: any) => { + stderr += String(chunk) + return true + }) + vi.mocked(api.resources.exportCode).mockResolvedValue({ + files: [{ path: 'resources/api-checks/example.check.ts', content: 'new ApiCheck("example", {})\n' }], + resources: [{ type: 'check', id: 'check-id', codeManaged: true }], + }) + }) + + afterEach(async () => { + stdoutSpy.mockRestore() + stderrSpy.mockRestore() + vi.useRealTimers() + await rm(tempDir, { recursive: true, force: true }) + }) + + it('exports one explicit resource to stdout by default', async () => { + const ctx = createCommandContext({ + argv: ['check:check-id'], + flags: {}, + }) + + await ResourcesExport.prototype.run.call(ctx as any) + + expect(api.resources.exportCode).toHaveBeenCalledWith({ + resources: [{ type: 'check', id: 'check-id' }], + }) + expect(stdout).toBe('new ApiCheck("example", {})\n') + expect(stderr).toBe('') + }) + + it('maps filters and updated-within to the export endpoint', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-07-04T12:00:00.000Z')) + vi.mocked(api.resources.exportCode).mockResolvedValue({ + files: [], + resources: [], + }) + const ctx = createCommandContext({ + argv: [], + flags: { + 'type': ['check'], + 'updated-within': '1h', + 'code-managed-only': true, + 'project': 'mac-project', + 'output-dir': tempDir, + }, + }) + + await ResourcesExport.prototype.run.call(ctx as any) + + expect(api.resources.exportCode).toHaveBeenCalledWith({ + types: ['check'], + updatedAfter: '2026-07-04T11:00:00.000Z', + projectLogicalId: 'mac-project', + codeManagedOnly: true, + }) + expect(stdout).toBe('') + expect(stderr).toBe('No files exported.\n') + }) + + it('requires output-dir for filter exports', async () => { + const ctx = createCommandContext({ + argv: [], + flags: { + type: ['check'], + }, + }) + + await expect(ResourcesExport.prototype.run.call(ctx as any)) + .rejects + .toThrow('Pass --output-dir when exporting multiple resources or using filters.') + + expect(api.resources.exportCode).not.toHaveBeenCalled() + }) + + it('requires output-dir when the backend returns multiple files for stdout export', async () => { + vi.mocked(api.resources.exportCode).mockResolvedValue({ + files: [ + { path: 'resources/api-checks/example.check.ts', content: 'construct\n' }, + { path: 'resources/api-checks/example.spec.ts', content: 'spec\n' }, + ], + resources: [{ type: 'check', id: 'check-id', codeManaged: true }], + }) + const ctx = createCommandContext({ + argv: ['check:check-id'], + flags: {}, + }) + + await expect(ResourcesExport.prototype.run.call(ctx as any)) + .rejects + .toThrow('Export returned multiple files. Pass --output-dir to write them safely.') + + expect(stdout).toBe('') + }) + + it('writes multiple files to output-dir and warns about unmanaged resources', async () => { + vi.mocked(api.resources.exportCode).mockResolvedValue({ + files: [ + { path: 'resources/api-checks/example.check.ts', content: 'construct\n' }, + { path: 'resources/api-checks/example.spec.ts', content: 'spec\n' }, + ], + resources: [{ type: 'check', id: 'check-id', name: 'Example', codeManaged: false }], + }) + const ctx = createCommandContext({ + argv: ['check:check-id'], + flags: { + 'output-dir': tempDir, + }, + }) + + await ResourcesExport.prototype.run.call(ctx as any) + + await expect(readFile(path.join(tempDir, 'resources/api-checks/example.check.ts'), 'utf8')) + .resolves + .toBe('construct\n') + await expect(readFile(path.join(tempDir, 'resources/api-checks/example.spec.ts'), 'utf8')) + .resolves + .toBe('spec\n') + expect(stdout).toBe('') + expect(stderr).toContain('Warning: check:check-id (Example) is not linked to this Monitoring as Code project.') + expect(stderr).toContain(`Exported 2 files to ${tempDir}`) + }) + + it('does not overwrite output-dir files unless forced', async () => { + await writeFile(path.join(tempDir, 'example.check.ts'), 'existing\n', 'utf8') + vi.mocked(api.resources.exportCode).mockResolvedValue({ + files: [{ path: 'example.check.ts', content: 'new\n' }], + resources: [{ type: 'check', id: 'check-id', codeManaged: true }], + }) + const ctx = createCommandContext({ + argv: ['check:check-id'], + flags: { + 'output-dir': tempDir, + }, + }) + + await expect(ResourcesExport.prototype.run.call(ctx as any)) + .rejects + .toMatchObject({ code: 'EEXIST' }) + + await expect(readFile(path.join(tempDir, 'example.check.ts'), 'utf8')) + .resolves + .toBe('existing\n') + }) + + it('parses resource specs and validates numeric IDs', () => { + expect(parseResourceSpec('check:abc')).toEqual({ type: 'check', id: 'abc' }) + expect(parseResourceSpec('alert-channel:123')).toEqual({ type: 'alert-channel', id: 123 }) + expect(() => parseResourceSpec('alert-channel:abc')).toThrow('must be an integer') + expect(() => parseResourceSpec('unknown:abc')).toThrow('Unsupported resource type') + }) + + it('validates updated timestamps and durations', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-07-04T12:00:00.000Z')) + + expect(resolveUpdatedAfter('2026-07-04T10:00:00Z')).toBe('2026-07-04T10:00:00.000Z') + expect(resolveUpdatedAfter(undefined, '30m')).toBe('2026-07-04T11:30:00.000Z') + expect(() => resolveUpdatedAfter('not-a-date')).toThrow('Invalid --updated-after') + expect(() => resolveUpdatedAfter(undefined, '1hour')).toThrow('Invalid --updated-within') + }) +}) diff --git a/packages/cli/src/commands/resources/export.ts b/packages/cli/src/commands/resources/export.ts new file mode 100644 index 000000000..b570bb717 --- /dev/null +++ b/packages/cli/src/commands/resources/export.ts @@ -0,0 +1,287 @@ +import { Args, Flags } from '@oclif/core' +import fs from 'node:fs/promises' +import path from 'node:path' + +import { AuthCommand } from '../authCommand.js' +import * as api from '../../rest/api.js' +import { + resourceExportTypes, + type ResourceCodeExportFile, + type ResourceCodeExportRequest, + type ResourceCodeExportResponse, + type ResourceCodeExportTarget, + type ResourceExportType, +} from '../../rest/resources.js' + +const numericResourceTypes = new Set([ + 'alert-channel', + 'alert-channel-subscription', + 'check-group', + 'dashboard', + 'maintenance-window', +]) + +export default class ResourcesExport extends AuthCommand { + static hidden = false + static readOnly = true + static destructive = false + static idempotent = true + static description = 'Export Checkly resources as Monitoring as Code constructs.' + + static args = { + resource: Args.string({ + required: false, + description: 'Resource to export, formatted as type:id, for example check:uuid.', + }), + } + + static flags = { + 'type': Flags.string({ + description: 'Resource type to export. Can be specified multiple times.', + options: [...resourceExportTypes], + multiple: true, + delimiter: ',', + }), + 'updated-after': Flags.string({ + description: 'Only export resources updated after this ISO-8601 timestamp.', + }), + 'updated-within': Flags.string({ + description: 'Only export resources updated within a recent duration, for example 30m, 1h, or 7d.', + }), + 'code-managed-only': Flags.boolean({ + description: 'Only export resources already linked to a Monitoring as Code project.', + default: false, + }), + 'project': Flags.string({ + description: 'Project logical ID used for code-managed filtering and metadata.', + }), + 'output-dir': Flags.string({ + description: 'Directory to write exported files. Required for multi-resource or multi-file exports.', + }), + 'force': Flags.boolean({ + description: 'Overwrite existing files when using --output-dir.', + default: false, + }), + } + + static strict = false + + async run (): Promise { + const { flags, argv } = await this.parse(ResourcesExport) + + const resources = argv.map(value => parseResourceSpec(String(value))) + const hasBulkSelector = Boolean(flags.type?.length || flags['updated-after'] || flags['updated-within'] || flags['code-managed-only']) + + if (resources.length === 0 && !hasBulkSelector) { + throw new Error('Pass a resource specifier such as check:, or use filters such as --type or --code-managed-only.') + } + + if (resources.length > 0 && flags.type?.length) { + throw new Error('Use either explicit resource specifiers or --type filters, not both.') + } + + if (flags['updated-after'] && flags['updated-within']) { + throw new Error('Use only one of --updated-after or --updated-within.') + } + + if ((resources.length !== 1 || hasBulkSelector) && !flags['output-dir']) { + throw new Error('Pass --output-dir when exporting multiple resources or using filters.') + } + + const payload = buildExportRequest({ + resources, + types: flags.type as ResourceExportType[] | undefined, + updatedAfter: resolveUpdatedAfter(flags['updated-after'], flags['updated-within']), + codeManagedOnly: flags['code-managed-only'], + projectLogicalId: flags.project, + }) + + const result = await api.resources.exportCode(payload) + + writeResultDiagnostics(result) + + if (result.errors?.length) { + process.exitCode = 1 + } + + if (result.files.length === 0) { + writeStderr('No files exported.\n') + return + } + + writeUnmanagedWarnings(result) + + if (flags['output-dir']) { + await writeFiles(flags['output-dir'], result.files, { force: flags.force }) + writeStderr(`Exported ${result.files.length} file${result.files.length === 1 ? '' : 's'} to ${path.resolve(flags['output-dir'])}\n`) + return + } + + if (result.files.length > 1) { + throw new Error('Export returned multiple files. Pass --output-dir to write them safely.') + } + + process.stdout.write(result.files[0].content) + } +} + +export function buildExportRequest (options: { + resources: ResourceCodeExportTarget[] + types?: ResourceExportType[] + updatedAfter?: string + projectLogicalId?: string + codeManagedOnly?: boolean +}): ResourceCodeExportRequest { + const payload: ResourceCodeExportRequest = {} + + if (options.resources.length > 0) { + payload.resources = options.resources + } + + if (options.types?.length) { + payload.types = options.types + } + + if (options.updatedAfter) { + payload.updatedAfter = options.updatedAfter + } + + if (options.projectLogicalId) { + payload.projectLogicalId = options.projectLogicalId + } + + if (options.codeManagedOnly) { + payload.codeManagedOnly = true + } + + return payload +} + +export function parseResourceSpec (spec: string): ResourceCodeExportTarget { + const separator = spec.indexOf(':') + if (separator <= 0 || separator === spec.length - 1) { + throw new Error(`Invalid resource specifier "${spec}". Use type:id, for example check:uuid.`) + } + + const type = spec.slice(0, separator) + const id = spec.slice(separator + 1) + + if (!isResourceExportType(type)) { + throw new Error(`Unsupported resource type "${type}". Supported types: ${resourceExportTypes.join(', ')}.`) + } + + if (numericResourceTypes.has(type)) { + const parsed = Number(id) + if (!Number.isInteger(parsed)) { + throw new Error(`Resource ID "${id}" for ${type} must be an integer.`) + } + + return { type, id: parsed } + } + + return { type, id } +} + +export function resolveUpdatedAfter (updatedAfter?: string, updatedWithin?: string): string | undefined { + if (updatedAfter) { + const date = new Date(updatedAfter) + if (Number.isNaN(date.valueOf())) { + throw new Error(`Invalid --updated-after value "${updatedAfter}". Use an ISO-8601 timestamp.`) + } + + return date.toISOString() + } + + if (updatedWithin) { + return new Date(Date.now() - parseDurationMs(updatedWithin)).toISOString() + } +} + +function parseDurationMs (value: string): number { + const match = /^(\d+)(m|h|d|w)$/.exec(value) + if (!match) { + throw new Error(`Invalid --updated-within value "${value}". Use a duration like 30m, 1h, or 7d.`) + } + + const amount = Number(match[1]) + const unit = match[2] as 'm' | 'h' | 'd' | 'w' + const multipliers: Record = { + m: 60_000, + h: 60 * 60_000, + d: 24 * 60 * 60_000, + w: 7 * 24 * 60 * 60_000, + } + + return amount * multipliers[unit] +} + +function isResourceExportType (value: string): value is ResourceExportType { + return (resourceExportTypes as readonly string[]).includes(value) +} + +function writeResultDiagnostics (result: ResourceCodeExportResponse): void { + for (const skipped of result.skipped ?? []) { + writeStderr(`Skipped ${formatResourceLabel(skipped)}: ${skipped.reason}\n`) + } + + for (const err of result.errors ?? []) { + writeStderr(`Failed to export ${formatResourceLabel(err)}: ${err.message}\n`) + } +} + +function writeUnmanagedWarnings (result: ResourceCodeExportResponse): void { + for (const resource of result.resources ?? []) { + if (resource.codeManaged === false) { + writeStderr( + `Warning: ${formatResourceLabel(resource)} is not linked to this Monitoring as Code project. ` + + `Deploying it as-is may create a duplicate.\n`, + ) + } + } +} + +function formatResourceLabel (resource: { type?: string, id?: string | number, name?: string }): string { + const spec = resource.type && resource.id !== undefined + ? `${resource.type}:${resource.id}` + : 'resource' + + if (resource.name) { + return `${spec} (${resource.name})` + } + + return spec +} + +function writeStderr (message: string): void { + process.stderr.write(message) +} + +async function writeFiles ( + outputDir: string, + files: ResourceCodeExportFile[], + options: { force: boolean }, +): Promise { + const root = path.resolve(outputDir) + + for (const file of files) { + const destination = resolveOutputPath(root, file.path) + await fs.mkdir(path.dirname(destination), { recursive: true }) + await fs.writeFile(destination, file.content, { + encoding: 'utf8', + flag: options.force ? 'w' : 'wx', + }) + } +} + +function resolveOutputPath (root: string, filePath: string): string { + if (filePath.includes('\0')) { + throw new Error(`Invalid export file path "${filePath}".`) + } + + const normalized = path.normalize(filePath) + if (path.isAbsolute(normalized) || normalized === '..' || normalized.startsWith(`..${path.sep}`)) { + throw new Error(`Export file path "${filePath}" must be relative to --output-dir.`) + } + + return path.join(root, normalized) +} diff --git a/packages/cli/src/rest/__tests__/resources.spec.ts b/packages/cli/src/rest/__tests__/resources.spec.ts new file mode 100644 index 000000000..e6bb824b1 --- /dev/null +++ b/packages/cli/src/rest/__tests__/resources.spec.ts @@ -0,0 +1,21 @@ +import { describe, expect, it, vi } from 'vitest' +import Resources from '../resources.js' + +describe('Resources REST client', () => { + it('exports resources through the public code export endpoint', async () => { + const response = { + files: [{ path: 'resources/api-checks/example.check.ts', content: 'construct\n' }], + } + const api = { + post: vi.fn().mockResolvedValue({ data: response }), + } + const resources = new Resources(api as any) + const payload = { + resources: [{ type: 'check' as const, id: 'check-id' }], + } + + await expect(resources.exportCode(payload)).resolves.toEqual(response) + + expect(api.post).toHaveBeenCalledWith('/v1/resources/code-export', payload) + }) +}) diff --git a/packages/cli/src/rest/api.ts b/packages/cli/src/rest/api.ts index a452053d4..c8bb40db4 100644 --- a/packages/cli/src/rest/api.ts +++ b/packages/cli/src/rest/api.ts @@ -30,6 +30,7 @@ import AlertChannels from './alert-channels.js' import AlertNotifications from './alert-notifications.js' import Rca from './rca.js' import Cancel from './cancel.js' +import Resources from './resources.js' import { handleErrorResponse, UnauthorizedError } from './errors.js' import { detectOperator } from '../helpers/cli-mode.js' @@ -139,3 +140,4 @@ export const alertChannels = new AlertChannels(api) export const alertNotifications = new AlertNotifications(api) export const rca = new Rca(api) export const cancel = new Cancel(api) +export const resources = new Resources(api) diff --git a/packages/cli/src/rest/resources.ts b/packages/cli/src/rest/resources.ts new file mode 100644 index 000000000..d54cf72e9 --- /dev/null +++ b/packages/cli/src/rest/resources.ts @@ -0,0 +1,80 @@ +import type { AxiosInstance } from 'axios' + +export const resourceExportTypes = [ + 'alert-channel', + 'alert-channel-subscription', + 'check', + 'check-group', + 'dashboard', + 'maintenance-window', + 'private-location', + 'private-location-check-assignment', + 'private-location-group-assignment', + 'status-page', + 'status-page-service', +] as const + +export type ResourceExportType = typeof resourceExportTypes[number] + +export interface ResourceCodeExportTarget { + type: ResourceExportType + id: string | number +} + +export interface ResourceCodeExportRequest { + resources?: ResourceCodeExportTarget[] + types?: ResourceExportType[] + updatedAfter?: string + projectLogicalId?: string + codeManagedOnly?: boolean +} + +export interface ResourceCodeExportFile { + path: string + content: string + role?: 'main' | 'support' | 'spec' | string +} + +export interface ResourceCodeExportMetadata { + type: ResourceExportType + id: string | number + name?: string + updatedAt?: string | null + codeManaged?: boolean + projectLogicalId?: string + logicalId?: string +} + +export interface ResourceCodeExportSkipped { + type?: ResourceExportType + id?: string | number + reason: string +} + +export interface ResourceCodeExportError { + type?: ResourceExportType + id?: string | number + message: string +} + +export interface ResourceCodeExportResponse { + files: ResourceCodeExportFile[] + resources?: ResourceCodeExportMetadata[] + skipped?: ResourceCodeExportSkipped[] + errors?: ResourceCodeExportError[] +} + +class Resources { + api: AxiosInstance + + constructor (api: AxiosInstance) { + this.api = api + } + + async exportCode (payload: ResourceCodeExportRequest): Promise { + const response = await this.api.post('/v1/resources/code-export', payload) + return response.data + } +} + +export default Resources