From e1e64f72559de84d76cd27ad6eb3f99aeec935f2 Mon Sep 17 00:00:00 2001 From: Simo Kinnunen Date: Wed, 23 Sep 2026 16:45:54 +0900 Subject: [PATCH 1/2] feat(write-back): write alert channels, private locations, dashboards, windows and status pages [RED-997] The interactive deploy write-back rewrote checks and check groups only; every other resource type was skipped although the plan reports its deployed row and per-key changes. Add a rule table for every alert channel class (the flat props mapped from the wire's config, number and template renamed onto phoneNumber and payload, the fixed webhook type and method and Telegram's packed template refused by name), private locations, dashboards (customCSS refused as a stylesheet), maintenance windows (name, tags, and the repeat interval and unit as a group; the dates follow), status pages v2 and v3 (each theme colour under an existing light or dark object), services, v3 components and automation rules. A credential arrives blanked and flagged, so it is refused before or at the rule, never written. Every new props type is held at build time to its written list plus a reasoned left-out union, and the spec now requires every concrete construct class to have rules or be excluded on purpose. A member of a written-together group that is refused, or that the code changed, now drops the rest of its group at the candidate level too, so a schedule is never half-written. Co-Authored-By: Claude Fable 5.1 --- .../src/ai-context/references/configure.md | 4 +- .../__tests__/confirm-flow-deploy.spec.ts | 4 +- packages/cli/src/commands/deploy.ts | 6 +- .../write-back/__tests__/plan.spec.ts | 309 ++++++++++++++++- packages/cli/src/services/write-back/plan.ts | 318 ++++++++++++++++-- 5 files changed, 598 insertions(+), 43 deletions(-) diff --git a/packages/cli/src/ai-context/references/configure.md b/packages/cli/src/ai-context/references/configure.md index b1802b2a..e098fa5c 100644 --- a/packages/cli/src/ai-context/references/configure.md +++ b/packages/cli/src/ai-context/references/configure.md @@ -76,7 +76,9 @@ 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 `). - 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 literal values (strings, numbers, booleans, and arrays or objects of those, including `runtimeId` on runtime checks and groups, the `request` of every check and monitor type, `sslCheckDomain` and `aiAutoRepairEnabled` on browser checks, `aiAutoRepairEnabled` on multistep checks and `prompt` on agentic checks; a multi-line string over a template literal is written as a template literal) and the helper-spelled properties `frequency` (`Frequency.EVERY_5M`), `retryStrategy` (`RetryStrategyBuilder`), `alertEscalationPolicy` (`AlertEscalationBuilder`) and `assertions` (the class's assertion builder) inside the `new ApiCheck('id', { … })` call of checks and check groups, adds a helper's import when the file lacks it, leaves the rest of the file untouched, and lists everything it could not update with the reason (references to other resources, secrets, scripts, an incident trigger, a helper call holding a variable, `doubleCheck` set beside a retry strategy, a check moved to the global alert policy, which needs `alertEscalationPolicy` removed by hand). It exists only in a terminal; there is no flag for it, and the `confirmation_required` envelope is unchanged. +- 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 exists only in a terminal; there is no flag for it, and the `confirmation_required` envelope is unchanged. + - It rewrites literal values (strings, numbers, booleans, and arrays or objects of those; a multi-line string over a template literal is written as a template literal) and the helper-spelled properties `frequency` (`Frequency.EVERY_5M`), `retryStrategy` (`RetryStrategyBuilder`), `alertEscalationPolicy` (`AlertEscalationBuilder`) and `assertions` (the class's assertion builder) inside the `new SomeConstruct('id', { … })` call, adds a helper's import when the file lacks it, and leaves the rest of the file untouched. Covered: checks and check groups (including `runtimeId` on runtime checks and groups, the `request` of every check and monitor type, `sslCheckDomain` and `aiAutoRepairEnabled` on browser checks, `aiAutoRepairEnabled` on multistep checks and `prompt` on agentic checks), alert channels of every type, private locations, dashboards, maintenance windows (`repeatInterval` and `repeatUnit` together), status pages (v2 and v3, each theme colour under an existing `themeColors.light`/`.dark`), status page services, v3 components and automation rules. + - It lists everything it could not update with the reason: references to other resources, secrets (a credential such as a webhook URL or API key is never written), scripts, a dashboard's `customCSS`, an incident trigger, an alert channel's type or a webhook-based channel's fixed type and method, Telegram's packed template, a helper call holding a variable, `doubleCheck` set beside a retry strategy, a check moved to the global alert policy, which needs `alertEscalationPolicy` removed by hand. - 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. 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 7c6ceab5..006b7b07 100644 --- a/packages/cli/src/commands/__tests__/confirm-flow-deploy.spec.ts +++ b/packages/cli/src/commands/__tests__/confirm-flow-deploy.spec.ts @@ -1180,12 +1180,12 @@ new ApiCheck('api', { 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 () => { + it('does not offer the choice for a change the table refuses, or for a secret', async () => { planResolves([ { ...CHANGED, redactions: [], - changes: [{ path: '/config/address', origin: 'remote', before: 'ops@example.com', after: 'new@example.com' }], + changes: [{ path: '/type', origin: 'remote', before: 'EMAIL', after: 'SLACK' }], }, { ...remoteEdit, diff --git a/packages/cli/src/commands/deploy.ts b/packages/cli/src/commands/deploy.ts index f2cb6e33..82b49834 100644 --- a/packages/cli/src/commands/deploy.ts +++ b/packages/cli/src/commands/deploy.ts @@ -67,9 +67,9 @@ function rejectsPreviewEraField (err: any): boolean { * outside the project and the code can take the edit: write the account's * current values into the code and deploy nothing, so the user reviews the * diff and deploys again rather than overwriting the edit. Only the - * properties of checks and groups the write-back knows the spelling of (a - * literal, or a helper such as `Frequency.EVERY_5M`) can be written; - * everything else is listed with its reason. + * properties the write-back knows the construct's spelling of (a literal, + * or a helper such as `Frequency.EVERY_5M`) can be written; everything + * else is listed with its reason. */ function writeBackAlternatives (diff: DiffEntry[], project: Project, command: Deploy): CommandAlternative[] { if (!hasWritableChanges(diff, project)) { diff --git a/packages/cli/src/services/write-back/__tests__/plan.spec.ts b/packages/cli/src/services/write-back/__tests__/plan.spec.ts index e603d469..fa9cb644 100644 --- a/packages/cli/src/services/write-back/__tests__/plan.spec.ts +++ b/packages/cli/src/services/write-back/__tests__/plan.spec.ts @@ -10,14 +10,14 @@ import { DnsMonitor } from '../../../constructs/dns-monitor.js' import { IcmpMonitor } from '../../../constructs/icmp-monitor.js' import { SslMonitor } from '../../../constructs/ssl-monitor.js' import { TracerouteMonitor } from '../../../constructs/traceroute-monitor.js' -import { CheckGroup } from '../../../constructs/check-group.js' +import { CheckGroup, CheckGroupRef } from '../../../constructs/check-group.js' import { EmailAlertChannel } from '../../../constructs/email-alert-channel.js' import { HeartbeatMonitor } from '../../../constructs/heartbeat-monitor.js' import { Project } from '../../../constructs/project.js' import { Session } from '../../../constructs/session.js' import { TcpMonitor } from '../../../constructs/tcp-monitor.js' import { UrlMonitor } from '../../../constructs/url-monitor.js' -import type { DiffEntry, DiffRedaction } from '../../../rest/projects.js' +import type { DiffChange, DiffEntry, DiffRedaction } from '../../../rest/projects.js' import * as constructs from '../../../constructs/index.js' import { AgenticCheck } from '../../../constructs/agentic-check.js' import { Check, RuntimeCheck, RepairableRuntimeCheck } from '../../../constructs/check.js' @@ -30,6 +30,29 @@ import { AGENTIC_CHECK_OMITTED_PROPS } from '../../../constructs/internal/agenti import { PLAYWRIGHT_CHECK_OMITTED_PROPS } from '../../../constructs/playwright-check-codegen.js' import { CheckGroupV2 } from '../../../constructs/check-group-v2.js' import { PlaywrightCheck } from '../../../constructs/playwright-check.js' +import { AlertChannel, AlertChannelRef } from '../../../constructs/alert-channel.js' +import { AlertChannelSubscription } from '../../../constructs/alert-channel-subscription.js' +import { Construct } from '../../../constructs/construct.js' +import { Dashboard } from '../../../constructs/dashboard.js' +import { IncidentioAlertChannel } from '../../../constructs/incidentio-alert-channel.js' +import { MaintenanceWindow } from '../../../constructs/maintenance-window.js' +import { MSTeamsAlertChannel } from '../../../constructs/msteams-alert-channel.js' +import { OpsgenieAlertChannel } from '../../../constructs/opsgenie-alert-channel.js' +import { PagerdutyAlertChannel } from '../../../constructs/pagerduty-alert-channel.js' +import { PhoneCallAlertChannel } from '../../../constructs/phone-call-alert-channel.js' +import { PrivateLocation, PrivateLocationRef } from '../../../constructs/private-location.js' +import { PrivateLocationCheckAssignment } from '../../../constructs/private-location-check-assignment.js' +import { PrivateLocationGroupAssignment } from '../../../constructs/private-location-group-assignment.js' +import { SlackAlertChannel } from '../../../constructs/slack-alert-channel.js' +import { SlackAppAlertChannel } from '../../../constructs/slack-app-alert-channel.js' +import { SmsAlertChannel } from '../../../constructs/sms-alert-channel.js' +import { StatusPage } from '../../../constructs/status-page.js' +import { StatusPageService, StatusPageServiceRef } from '../../../constructs/status-page-service.js' +import { StatusPageV3, StatusPageV3Ref } from '../../../constructs/status-page-v3.js' +import { StatusPageV3AutomationRule } from '../../../constructs/status-page-v3-automation-rule.js' +import { StatusPageV3Component, StatusPageV3ComponentRef } from '../../../constructs/status-page-v3-component.js' +import { TelegramAlertChannel } from '../../../constructs/telegram-alert-channel.js' +import { WebhookAlertChannel } from '../../../constructs/webhook-alert-channel.js' vi.mock('../literal-edit.js', async importOriginal => { const original = await importOriginal() @@ -59,6 +82,12 @@ async function declare (name: string, source: string, build: () => T): Promis const read = (name: string) => fs.readFile(path.join(dir, name), 'utf8') +/** The rule table the API reports for an alert channel: every credential blanked, whatever the flags. */ +const ALERT_CHANNEL_REDACTIONS: DiffRedaction[] = [ + '/config/apiKey', '/config/webhookSecret', '/config/url', '/config/serviceKey', + '/config/headers/*/value', '/config/queryParameters/*/value', +].map(path => ({ path, kind: 'value' })) + /** The rule table the API reports for a check: env var values and header values blanked when locked. */ const CHECK_REDACTIONS: DiffRedaction[] = [ { path: '/environmentVariables/*/value', kind: 'value', when: 'lockedOrSecret' }, @@ -108,6 +137,16 @@ function apiEntry (overrides: Partial = {}): DiffEntry { } } +/** A change the account made, with `secret` set the way the API sets it on a credential leaf. */ +const remote = (path: string, before: unknown, after: unknown, secret?: true): DiffChange => + ({ path, origin: 'remote', before, after, ...(secret === undefined ? {} : { secret }) }) + +/** An updated resource of any type, as a full-detail preview reports it. */ +const entry = ( + type: string, logicalId: string, before: Record, changes: DiffChange[], + redactions: DiffRedaction[] = [], +): DiffEntry => ({ type, logicalId, action: 'UPDATE', changes, before, redactions }) + beforeEach(async () => { dir = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'write-back-'))) Session.reset() @@ -360,9 +399,8 @@ describe('planWriteBack', () => { await declare('wrapped.check.ts', `import { ApiCheck } from 'checkly'\nnew Wrapped('wrapped', { name: 'w' })\n`, () => { new Wrapped('wrapped', { name: 'w', request: { url: 'https://example.com', method: 'GET' } }) }) - await declare('email.ts', `import { EmailAlertChannel } from 'checkly'\nnew EmailAlertChannel('mail', { address: 'a@b.c' })\n`, () => { - new EmailAlertChannel('mail', { address: 'a@b.c' }) - }) + // A reference to another project's channel is a class of checkly's with nothing to write. + await declare('ref.ts', `import { AlertChannel } from 'checkly'\nAlertChannel.fromId(7)\n`, () => AlertChannel.fromId(7)) const plan = await planWriteBack({ diff: [ apiEntry({ before: undefined, redactions: undefined }), @@ -371,7 +409,7 @@ describe('planWriteBack', () => { apiEntry({ logicalId: 'gone' }), apiEntry({ type: 'alert-channel', - logicalId: 'mail', + logicalId: 'alert-channel-7', changes: [{ path: '/config/address', origin: 'remote', before: 'a@b.c', after: 'x@b.c' }], }), ], @@ -384,7 +422,7 @@ describe('planWriteBack', () => { 'check api: Checkly did not report which of its values are secret', 'check wrapped: Wrapped is not a class from checkly/constructs', 'check gone: not found in the project', - 'alert-channel mail: updating the code is supported for checks and check groups only', + 'alert-channel alert-channel-7: AlertChannelRef has no properties this tool can update', ]) }) @@ -551,17 +589,27 @@ new HeartbeatMonitor('beat', { name: 'Beat', period: 1, periodUnit: unit, grace: }) it('lists every construct class checkly/constructs exports, or excludes it on purpose', () => { - // Abstract bases never appear in a project; every other check or group - // class must be named so a new one cannot fall back to a base's rules. - const abstract = new Set([Check, RuntimeCheck, RepairableRuntimeCheck, Monitor]) + // Every concrete resource class must be named so a new one cannot fall + // back to a base's rules. Left out on purpose: the abstract bases, which + // never appear in a project; the references to another project's + // resources, which hold nothing to write; the relations, which the plan + // reports on the check or group they belong to; and the project itself. + const onPurpose = new Set([ + Check, RuntimeCheck, RepairableRuntimeCheck, Monitor, AlertChannel, + CheckGroupRef, AlertChannelRef, PrivateLocationRef, StatusPageServiceRef, StatusPageV3Ref, + StatusPageV3ComponentRef, + AlertChannelSubscription, PrivateLocationCheckAssignment, PrivateLocationGroupAssignment, + Project, + ]) const exported = Object.values(constructs).filter((value): value is ConstructClass => - typeof value === 'function' - && (value.prototype instanceof Check || value.prototype instanceof CheckGroupV1 || value === CheckGroupV1) - && !abstract.has(value as ConstructClass)) - expect(exported.length).toBeGreaterThan(10) + typeof value === 'function' && value.prototype instanceof Construct && !onPurpose.has(value as ConstructClass)) + expect(exported.length).toBeGreaterThan(30) for (const cls of exported) { expect(RULES_BY_CLASS.has(cls), `${cls.name} has no rules`).toBe(true) } + for (const cls of onPurpose) { + expect(RULES_BY_CLASS.has(cls), `${cls.name} has rules`).toBe(false) + } }) it('refuses a value carrying a marker, and rewrites a property the code spells as a helper', async () => { @@ -863,6 +911,9 @@ new CheckGroupV1('own', { name: 'Own', alertEscalationPolicy: AlertEscalationBui const has = (rules: readonly Rule[], target: string) => rules.some(rule => rule.target.join('.') === target && rule.companion === undefined) for (const [cls, rules] of RULES_BY_CLASS) { const isGroup = cls === CheckGroupV1 || cls === CheckGroupV2 + if (!isGroup && !(cls.prototype instanceof Check)) { + continue + } const omitted: readonly string[] = cls === AgenticCheck ? AGENTIC_CHECK_OMITTED_PROPS : cls === PlaywrightCheck ? PLAYWRIGHT_CHECK_OMITTED_PROPS : [] @@ -1213,6 +1264,236 @@ new TcpMonitor('tcp', { name: 'Tcp', request: { hostname: 'example.com', port: 4 ]) }) + it('writes the props of every alert channel type onto the construct, flat', async () => { + await declare('channels.ts', `import { + EmailAlertChannel, SlackAlertChannel, SlackAppAlertChannel, WebhookAlertChannel, OpsgenieAlertChannel, + PagerdutyAlertChannel, SmsAlertChannel, PhoneCallAlertChannel, MSTeamsAlertChannel, TelegramAlertChannel, + IncidentioAlertChannel, +} from 'checkly/constructs' + +new EmailAlertChannel('email', { address: 'a@b.c' }) +new SlackAlertChannel('slack', { url: 'https://hooks.slack.com/x', channel: '#ops' }) +new SlackAppAlertChannel('slackapp', { slackChannels: ['C1'] }) +new WebhookAlertChannel('webhook', { name: 'Hook', url: 'https://example.com/hook', method: 'POST', headers: [{ key: 'k', value: 'v' }] }) +new WebhookAlertChannel('webhook2', { name: 'Hook2', url: 'https://example.com/hook2' }) +new OpsgenieAlertChannel('opsgenie', { name: 'Ops', apiKey: 'k', region: 'EU', priority: 'P1' }) +new PagerdutyAlertChannel('pagerduty', { serviceKey: 'k', account: 'acc' }) +new SmsAlertChannel('sms', { phoneNumber: '+1', name: 'Sms' }) +new PhoneCallAlertChannel('call', { phoneNumber: '+1' }) +new MSTeamsAlertChannel('teams', { name: 'Teams', url: 'https://example.com/teams' }) +new TelegramAlertChannel('telegram', { name: 'Tg', chatId: '1', apiKey: 'k' }) +new IncidentioAlertChannel('incidentio', { name: 'Inc', url: 'https://example.com/inc', apiKey: 'k' }) +`, () => { + new EmailAlertChannel('email', { address: 'a@b.c' }) + new SlackAlertChannel('slack', { url: 'https://hooks.slack.com/x', channel: '#ops' }) + new SlackAppAlertChannel('slackapp', { slackChannels: ['C1'] }) + new WebhookAlertChannel('webhook', { name: 'Hook', url: 'https://example.com/hook', method: 'POST', headers: [{ key: 'k', value: 'v' }] }) + new WebhookAlertChannel('webhook2', { name: 'Hook2', url: 'https://example.com/hook2' }) + new OpsgenieAlertChannel('opsgenie', { name: 'Ops', apiKey: 'k', region: 'EU', priority: 'P1' }) + new PagerdutyAlertChannel('pagerduty', { serviceKey: 'k', account: 'acc' }) + new SmsAlertChannel('sms', { phoneNumber: '+1', name: 'Sms' }) + new PhoneCallAlertChannel('call', { phoneNumber: '+1' }) + new MSTeamsAlertChannel('teams', { name: 'Teams', url: 'https://example.com/teams' }) + new TelegramAlertChannel('telegram', { name: 'Tg', chatId: '1', apiKey: 'k' }) + new IncidentioAlertChannel('incidentio', { name: 'Inc', url: 'https://example.com/inc', apiKey: 'k' }) + }) + const channel = (logicalId: string, config: Record, changes: DiffChange[], top = {}) => + entry('alert-channel', logicalId, { config, ...top }, changes, ALERT_CHANNEL_REDACTIONS) + const plan = await planWriteBack({ + diff: [ + channel('email', { address: 'x@b.c' }, [ + remote('/config/address', 'a@b.c', 'x@b.c'), remote('/sendRecovery', true, false), remote('/type', 'EMAIL', 'SLACK'), + ], { sendRecovery: false }), + // A change at a credential arrives as a secret; the value is never in `before`. + channel('slack', { url: '', channel: '#alerts' }, [ + remote('/config/channel', '#ops', '#alerts'), remote('/config/url', { $masked: 'same' }, { $masked: 'changed' }, true), + ]), + channel('slackapp', { slackChannels: ['C1', 'C2'] }, [remote('/config/slackChannels', ['C1'], ['C1', 'C2'])]), + // A header list whose values are all secret is reported as a secret; a + // list change that somehow is not still cannot be written from the + // blanked values. + channel('webhook', { url: '', method: 'PUT', headers: [{ key: 'k', value: '', locked: false }] }, [ + remote('/config/method', 'POST', 'PUT'), remote('/config/headers', undefined, undefined, true), + ]), + channel('webhook2', { url: '', name: 'Hook 2', headers: [{ key: 'k', value: '', locked: false }] }, [ + remote('/config/name', 'Hook2', 'Hook 2'), remote('/config/headers', [], [{ key: 'k', value: '' }]), + ]), + channel('opsgenie', { apiKey: '', region: 'US' }, [ + remote('/config/region', 'EU', 'US'), remote('/config/apiKey', { $masked: 'same' }, { $masked: 'changed' }, true), + ]), + // A credential change the API did not flag is still refused: the + // blanked value can never pass for the account's. + channel('pagerduty', { account: 'acc2', serviceKey: '' }, [ + remote('/config/account', 'acc', 'acc2'), remote('/config/serviceKey', 'a', 'b'), + ]), + channel('sms', { number: '+2' }, [remote('/config/number', '+1', '+2')]), + channel('call', { number: '+2', name: 'Call' }, [remote('/config/number', '+1', '+2'), remote('/config/name', null, 'Call')]), + channel('teams', { url: '', template: '{"x":1}', method: 'GET', webhookType: 'WEBHOOK_MSTEAMS' }, [ + remote('/config/template', '{}', '{"x":1}'), remote('/config/method', 'POST', 'GET'), + remote('/config/webhookType', 'WEBHOOK_MSTEAMS', 'WEBHOOK_TELEGRAM'), + ]), + channel('telegram', { url: '', name: 'Telegram', template: 'chat_id=2' }, [ + remote('/config/name', 'Tg', 'Telegram'), remote('/config/template', 'chat_id=1', 'chat_id=2'), + ]), + channel('incidentio', { url: '', template: '{"y":2}', headers: [{ key: 'authorization', value: '', locked: false }] }, [ + remote('/config/template', '{}', '{"y":2}'), remote('/config/headers', [], [{ key: 'authorization', value: '' }]), + ]), + ], + project, + cwd: dir, + }) + expect(plan.skipped).toEqual([ + 'alert-channel email /type: the channel type is the construct\'s class; change the class by hand', + 'alert-channel slack /config/url: a secret changed; Checkly does not return its value', + 'alert-channel webhook /config/headers: a secret changed; Checkly does not return its value', + 'alert-channel webhook2 headers: contains a locked or secret value that Checkly does not return', + 'alert-channel opsgenie /config/apiKey: a secret changed; Checkly does not return its value', + 'alert-channel pagerduty serviceKey: contains a locked or secret value that Checkly does not return', + 'alert-channel teams /config/method: fixed by the construct; it cannot be changed in the code', + 'alert-channel teams /config/webhookType: fixed by the construct; it cannot be changed in the code', + 'alert-channel telegram /config/template: built from chatId, messageThreadId and payload; edit them by hand', + 'alert-channel incidentio /config/headers: built from apiKey; edit it by hand', + ]) + expect(plan.applied.map(line => [line.logicalId, line.property, line.previous, line.rendered])).toEqual([ + ['email', 'address', '\'a@b.c\'', '\'x@b.c\''], + ['email', 'sendRecovery', undefined, 'false'], + ['slack', 'channel', '\'#ops\'', '\'#alerts\''], + ['slackapp', 'slackChannels', '[\'C1\']', '[\'C1\', \'C2\']'], + ['webhook', 'method', '\'POST\'', '\'PUT\''], + ['webhook2', 'name', '\'Hook2\'', '\'Hook 2\''], + ['opsgenie', 'region', '\'EU\'', '\'US\''], + ['pagerduty', 'account', '\'acc\'', '\'acc2\''], + ['sms', 'phoneNumber', '\'+1\'', '\'+2\''], + ['call', 'phoneNumber', '\'+1\'', '\'+2\''], + ['call', 'name', undefined, '\'Call\''], + ['teams', 'payload', undefined, '\'{"x":1}\''], + ['telegram', 'name', '\'Tg\'', '\'Telegram\''], + ['incidentio', 'payload', undefined, '\'{"y":2}\''], + ]) + const text = plan.files[0].text + expect(text).toContain('new SmsAlertChannel(\'sms\', { phoneNumber: \'+2\', name: \'Sms\' })') + expect(text).toContain('new PhoneCallAlertChannel(\'call\', { phoneNumber: \'+2\', name: \'Call\' })') + expect(text).toContain('new EmailAlertChannel(\'email\', { address: \'x@b.c\', sendRecovery: false })') + }) + + it('writes private locations, dashboards, maintenance windows and status pages', async () => { + await declare('resources.ts', `import { PrivateLocation, Dashboard, MaintenanceWindow } from 'checkly/constructs' + +const unit = 'DAY' +new PrivateLocation('pl', { name: 'PL', slugName: 'pl' }) +new Dashboard('dash', { customUrl: 'dash', tags: ['a'], refreshRate: 60, customCSS: { content: 'a {}' } }) +new MaintenanceWindow('mw', { name: 'MW', tags: ['a'], startsAt: new Date('2026-01-01T00:00:00.000Z'), endsAt: new Date('2026-01-02T00:00:00.000Z'), repeatInterval: 1, repeatUnit: 'DAY' }) +new MaintenanceWindow('mw2', { name: 'MW2', tags: ['a'], startsAt: new Date('2026-01-01T00:00:00.000Z'), endsAt: new Date('2026-01-02T00:00:00.000Z'), repeatInterval: 1, repeatUnit: unit }) +new MaintenanceWindow('mw3', { name: 'MW3', tags: ['a'], startsAt: new Date('2026-01-01T00:00:00.000Z'), endsAt: new Date('2026-01-02T00:00:00.000Z'), repeatInterval: 1, repeatUnit: 'WEEK' }) +new MaintenanceWindow('mw4', { name: 'MW4', tags: ['a'], startsAt: new Date('2026-01-01T00:00:00.000Z'), endsAt: new Date('2026-01-02T00:00:00.000Z'), repeatInterval: 1, repeatUnit: 'DAY' }) +new PrivateLocation('pl2', { name: 'PL2', slugName: 'pl2', proxyUrl: 'http://proxy' }) +`, () => { + const dates = { startsAt: new Date('2026-01-01T00:00:00.000Z'), endsAt: new Date('2026-01-02T00:00:00.000Z') } + new PrivateLocation('pl', { name: 'PL', slugName: 'pl' }) + new Dashboard('dash', { customUrl: 'dash', tags: ['a'], refreshRate: 60, customCSS: { content: 'a {}' } }) + new MaintenanceWindow('mw', { name: 'MW', tags: ['a'], ...dates, repeatInterval: 1, repeatUnit: 'DAY' }) + new MaintenanceWindow('mw2', { name: 'MW2', tags: ['a'], ...dates, repeatInterval: 1, repeatUnit: 'DAY' }) + new MaintenanceWindow('mw3', { name: 'MW3', tags: ['a'], ...dates, repeatInterval: 1, repeatUnit: 'WEEK' }) + new MaintenanceWindow('mw4', { name: 'MW4', tags: ['a'], ...dates, repeatInterval: 1, repeatUnit: 'DAY' }) + new PrivateLocation('pl2', { name: 'PL2', slugName: 'pl2', proxyUrl: 'http://proxy' }) + }) + await declare('pages.ts', `import { + StatusPage, StatusPageService, StatusPageV3, StatusPageV3Component, StatusPageV3AutomationRule, +} from 'checkly/constructs' + +const service = new StatusPageService('svc', { name: 'Svc' }) +new StatusPage('v2', { name: 'V2', url: 'v2', cards: [{ name: 'Card', services: [service] }] }) +const page = new StatusPageV3('v3', { name: 'V3', url: 'v3', themeColors: { light: { bodyBackgroundColor: '#fff' } } }) +const component = new StatusPageV3Component('comp', { statusPage: page, name: 'Comp', displayOrder: 1 }) +new StatusPageV3AutomationRule('rule', { + statusPage: page, name: 'Rule', firstUpdate: 'a', lastUpdate: 'b', tags: ['t'], coolDownMinutes: 5, + components: [{ component, targetImpact: 'MAJOR_OUTAGE' }], +}) +`, () => { + const service = new StatusPageService('svc', { name: 'Svc' }) + new StatusPage('v2', { name: 'V2', url: 'v2', cards: [{ name: 'Card', services: [service] }] }) + const page = new StatusPageV3('v3', { name: 'V3', url: 'v3', themeColors: { light: { bodyBackgroundColor: '#fff' } } }) + const component = new StatusPageV3Component('comp', { statusPage: page, name: 'Comp', displayOrder: 1 }) + new StatusPageV3AutomationRule('rule', { + statusPage: page, name: 'Rule', firstUpdate: 'a', lastUpdate: 'b', tags: ['t'], coolDownMinutes: 5, + components: [{ component, targetImpact: 'MAJOR_OUTAGE' }], + }) + }) + const plan = await planWriteBack({ + diff: [ + entry('private-location', 'pl', { name: 'PL 2', proxyUrl: '' }, [ + remote('/name', 'PL', 'PL 2'), remote('/proxyUrl', { $masked: 'same' }, { $masked: 'changed' }, true), + ], [{ path: '/proxyUrl', kind: 'value' }]), + entry('private-location', 'pl2', { proxyUrl: '' }, [remote('/proxyUrl', 'a', 'b')], [{ path: '/proxyUrl', kind: 'value' }]), + // A repeat setting the code changed, or one the account holds no + // value for, keeps the other from being written alone. + entry('maintenance-window', 'mw3', { repeatInterval: 2, repeatUnit: 'WEEK' }, [ + remote('/repeatInterval', 1, 2), { path: '/repeatUnit', origin: 'code', before: 'DAY', after: 'WEEK' }, + ]), + entry('maintenance-window', 'mw4', { repeatInterval: 2, repeatUnit: 'WEEK' }, [ + remote('/repeatInterval', 1, 2), remote('/repeatUnit', 'DAY', 'MONTH'), + ]), + entry('dashboard', 'dash', { tags: ['a', 'b'], refreshRate: 300, customCSS: 'b {}' }, [ + { path: '/tags/x', origin: 'remote', after: 'b' }, remote('/refreshRate', 60, 300), remote('/customCSS', 'a {}', 'b {}'), + ]), + entry('maintenance-window', 'mw', { repeatInterval: 2, repeatUnit: 'WEEK' }, [ + remote('/repeatInterval', 1, 2), remote('/repeatUnit', 'DAY', 'WEEK'), + ]), + entry('maintenance-window', 'mw2', { repeatInterval: 2, repeatUnit: 'WEEK' }, [ + remote('/repeatInterval', 1, 2), remote('/repeatUnit', 'DAY', 'WEEK'), + ]), + entry('status-page', 'v2', { name: 'V2 renamed', cards: [{ name: 'Card 2' }] }, [ + remote('/name', 'V2', 'V2 renamed'), remote('/cards/0/name', 'Card', 'Card 2'), + ]), + entry('status-page', 'v3', { + version: 3, themeColors: { light: { bodyBackgroundColor: '#000' }, dark: { bodyBackgroundColor: '#111' } }, + }, [ + remote('/themeColors/light/bodyBackgroundColor', '#fff', '#000'), + remote('/themeColors/dark/bodyBackgroundColor', null, '#111'), remote('/version', 2, 3), + ]), + entry('status-page-service', 'svc', { name: 'Service' }, [remote('/name', 'Svc', 'Service')]), + entry('status-page-component', 'comp', { configuration: { showHistoricalData: false }, statusPageId: 'p', parentId: 'q' }, [ + remote('/configuration/showHistoricalData', true, false), remote('/statusPageId', 'p', 'p2'), remote('/parentId', null, 'q'), + ]), + entry('status-page-automation-rule', 'rule', { coolDownWindowMinutes: 10, tags: ['t', 'u'], components: [] }, [ + remote('/coolDownWindowMinutes', 5, 10), { path: '/tags/x', origin: 'remote', after: 'u' }, + { path: '/components/x', origin: 'remote', before: { componentId: 'c' } }, + ]), + ], + project, + cwd: dir, + }) + expect(plan.skipped).toEqual([ + 'private-location pl /proxyUrl: a secret changed; Checkly does not return its value', + 'private-location pl2 proxyUrl: contains a locked or secret value that Checkly does not return', + 'maintenance-window mw3 repeatInterval: your code also changed it since the last deploy; merge by hand', + 'maintenance-window mw4 repeatUnit: Checkly reported two different current values', + 'maintenance-window mw4 repeatInterval: written together with repeatUnit', + 'dashboard dash /customCSS: a stylesheet, not a property; edit the file or the content by hand', + 'status-page v2 /cards/0/name: cards hold status page services; edit them by hand', + 'status-page v3 /version: fixed by the construct; it cannot be changed in the code', + 'status-page-component comp /statusPageId: references another resource', + 'status-page-component comp /parentId: references another resource', + 'status-page-automation-rule rule /components/x: references another resource', + 'maintenance-window mw2 repeatInterval: written together with repeatUnit', + 'maintenance-window mw2 repeatUnit: repeatUnit is the variable unit, not a plain literal', + 'status-page v3 themeColors.dark.bodyBackgroundColor: themeColors.dark is not set in the code', + ]) + expect(plan.applied.map(line => [line.logicalId, line.property, line.previous, line.rendered])).toEqual([ + ['pl', 'name', '\'PL\'', '\'PL 2\''], + ['dash', 'tags', '[\'a\']', '[\'a\', \'b\']'], + ['dash', 'refreshRate', '60', '300'], + ['mw', 'repeatInterval', '1', '2'], + ['mw', 'repeatUnit', '\'DAY\'', '\'WEEK\''], + ['v2', 'name', '\'V2\'', '\'V2 renamed\''], + ['v3', 'themeColors.light.bodyBackgroundColor', '\'#fff\'', '\'#000\''], + ['svc', 'name', '\'Svc\'', '\'Service\''], + ['comp', 'showHistoricalData', undefined, 'false'], + ['rule', 'coolDownMinutes', '5', '10'], + ['rule', 'tags', '[\'t\']', '[\'t\', \'u\']'], + ]) + }) + it('accepts a leaf that became a subtree, but not an object the account no longer holds', async () => { await declare('api.check.ts', API_SOURCE, () => { new ApiCheck('api', { name: 'API', request: { url: 'https://example.com', method: 'GET' } }) diff --git a/packages/cli/src/services/write-back/plan.ts b/packages/cli/src/services/write-back/plan.ts index a8849765..7ebe7075 100644 --- a/packages/cli/src/services/write-back/plan.ts +++ b/packages/cli/src/services/write-back/plan.ts @@ -12,6 +12,33 @@ import type { CheckProps, RuntimeCheckProps } from '../../constructs/check.js' import { CheckGroupV1, type CheckGroupV1Props } from '../../constructs/check-group-v1.js' import { CheckGroupV2, type CheckGroupV2Props } from '../../constructs/check-group-v2.js' import type { Construct } from '../../constructs/construct.js' +import type { AlertChannelProps } from '../../constructs/alert-channel.js' +import { Dashboard, type DashboardProps } from '../../constructs/dashboard.js' +import { EmailAlertChannel, type EmailAlertChannelProps } from '../../constructs/email-alert-channel.js' +import { IncidentioAlertChannel, type IncidentioAlertChannelProps } from '../../constructs/incidentio-alert-channel.js' +import { MaintenanceWindow, type MaintenanceWindowProps } from '../../constructs/maintenance-window.js' +import { MSTeamsAlertChannel, type MSTeamsAlertChannelProps } from '../../constructs/msteams-alert-channel.js' +import { OpsgenieAlertChannel, type OpsgenieAlertChannelProps } from '../../constructs/opsgenie-alert-channel.js' +import { PagerdutyAlertChannel, type PagerdutyAlertChannelProps } from '../../constructs/pagerduty-alert-channel.js' +import { PhoneCallAlertChannel, type PhoneCallAlertChannelProps } from '../../constructs/phone-call-alert-channel.js' +import { PrivateLocation, type PrivateLocationProps } from '../../constructs/private-location.js' +import { SlackAlertChannel, type SlackAlertChannelProps } from '../../constructs/slack-alert-channel.js' +import { SlackAppAlertChannel, type SlackAppAlertChannelProps } from '../../constructs/slack-app-alert-channel.js' +import { SmsAlertChannel, type SmsAlertChannelProps } from '../../constructs/sms-alert-channel.js' +import { StatusPage, type StatusPageProps } from '../../constructs/status-page.js' +import { StatusPageService, type StatusPageServiceProps } from '../../constructs/status-page-service.js' +import { + StatusPageV3, type StatusPageV3Props, type StatusPageV3ThemeColorGroup, type StatusPageV3ThemeColors, +} from '../../constructs/status-page-v3.js' +import { + StatusPageV3AutomationRule, type StatusPageV3AutomationRuleProps, +} from '../../constructs/status-page-v3-automation-rule.js' +import { + StatusPageV3Component, type StatusPageV3ComponentProps, type StatusPageV3GroupComponentProps, + type StatusPageV3ServiceComponentProps, +} from '../../constructs/status-page-v3-component.js' +import { TelegramAlertChannel, type TelegramAlertChannelProps } from '../../constructs/telegram-alert-channel.js' +import { WebhookAlertChannel, type WebhookAlertChannelProps } from '../../constructs/webhook-alert-channel.js' import { DnsMonitor, type DnsMonitorProps } from '../../constructs/dns-monitor.js' import type { DnsRequest } from '../../constructs/dns-request.js' import { GrpcMonitor, type GrpcMonitorProps } from '../../constructs/grpc-monitor.js' @@ -41,7 +68,9 @@ import { findConstructOptions, parseSource, WriteBackSkipped } from './source-fi /** * Turns the remote changes of a deploy plan into edits of the construct - * source files, and applies them. + * source files, and applies them: checks, groups, alert channels, private + * locations, dashboards, maintenance windows and status pages with their + * services, components and automation rules. * * A remote change is a property that moved in the Checkly account since the * last deploy (`origin: 'remote'`, or `'both'` when the code moved too). The @@ -279,8 +308,134 @@ const GROUP_RULES: Rule[] = [ ...RETRY_RULES, ] -// What each class writes, at the top level of its props. `request` and -// `apiCheckDefaults` stand for every rule under them. +/** + * A rule that refuses every change at its pointer with `reason` and writes + * nothing: a companion with no primary, for a leaf the construct fixes or + * builds from other props, so the refusal names why rather than saying + * the property is unknown. + */ +const refusing = (pointer: string[], reason: string): Rule => + ({ pointer, target: pointer, companion: true, refuse: () => reason }) +const FIXED = 'fixed by the construct; it cannot be changed in the code' + +// An alert channel's own props are flat in the code and nested under +// `config` on the wire (`synthesize` moves them), so their rules map +// `config/` onto the top-level prop. A credential key (`url`, `apiKey`, +// `serviceKey`, `webhookSecret`, the values of `headers` and +// `queryParameters`) is listed like any other: the account blanks it, so +// the redaction check refuses it by name, and a change at the leaf itself +// arrives flagged as a secret and never reaches a rule. +const config = (keys: readonly string[]): Rule[] => keys.map(key => ({ pointer: ['config', key], target: [key] })) +const ALERT_CHANNEL_KEYS = [ + 'sendRecovery', 'sendFailure', 'sendDegraded', 'sslExpiry', 'sslExpiryThreshold', +] as const satisfies readonly (keyof AlertChannelProps)[] +const ALERT_CHANNEL_RULES: Rule[] = [ + ...ALERT_CHANNEL_KEYS.map(key => identity(key)), + refusing(['type'], 'the channel type is the construct\'s class; change the class by hand'), +] +const EMAIL_KEYS = ['address'] as const satisfies readonly (keyof EmailAlertChannelProps)[] +const SLACK_KEYS = ['url', 'channel'] as const satisfies readonly (keyof SlackAlertChannelProps)[] +const SLACK_APP_KEYS = ['slackChannels'] as const satisfies readonly (keyof SlackAppAlertChannelProps)[] +const WEBHOOK_KEYS = [ + 'name', 'webhookType', 'url', 'template', 'method', 'headers', 'queryParameters', 'webhookSecret', +] as const satisfies readonly (keyof WebhookAlertChannelProps)[] +const OPSGENIE_KEYS = ['name', 'apiKey', 'region', 'priority'] as const satisfies readonly (keyof OpsgenieAlertChannelProps)[] +const PAGERDUTY_KEYS = ['account', 'serviceName', 'serviceKey'] as const satisfies readonly (keyof PagerdutyAlertChannelProps)[] +// SMS and phone call channels spell the number as `phoneNumber`; the wire has `number`. +const PHONE_KEYS = ['name'] as const satisfies readonly (keyof SmsAlertChannelProps & keyof PhoneCallAlertChannelProps)[] +const PHONE_RULES: Rule[] = [...config(PHONE_KEYS), { pointer: ['config', 'number'], target: ['phoneNumber'] }] +// The webhook-based channels fix the webhook type and method, and spell the +// template as `payload`; Telegram packs its chat id, thread and payload +// into the template and its API key into the URL, and incident.io sends +// its API key as a header, none of which this module unpacks. +const WEBHOOK_FIXED_RULES: Rule[] = [refusing(['config', 'webhookType'], FIXED), refusing(['config', 'method'], FIXED)] +const TEMPLATE_RULE: Rule = { pointer: ['config', 'template'], target: ['payload'] } +const MSTEAMS_KEYS = ['name', 'url'] as const satisfies readonly (keyof MSTeamsAlertChannelProps)[] +const MSTEAMS_RULES: Rule[] = [...config(MSTEAMS_KEYS), TEMPLATE_RULE, ...WEBHOOK_FIXED_RULES] +const TELEGRAM_KEYS = ['name'] as const satisfies readonly (keyof TelegramAlertChannelProps)[] +const TELEGRAM_RULES: Rule[] = [ + ...config(TELEGRAM_KEYS), + refusing(['config', 'template'], 'built from chatId, messageThreadId and payload; edit them by hand'), + ...WEBHOOK_FIXED_RULES, +] +const INCIDENTIO_KEYS = ['name', 'url'] as const satisfies readonly (keyof IncidentioAlertChannelProps)[] +const INCIDENTIO_RULES: Rule[] = [ + ...config(INCIDENTIO_KEYS), TEMPLATE_RULE, + refusing(['config', 'headers'], 'built from apiKey; edit it by hand'), + ...WEBHOOK_FIXED_RULES, +] + +const PRIVATE_LOCATION_KEYS = ['name', 'slugName', 'icon', 'proxyUrl'] as const satisfies readonly (keyof PrivateLocationProps)[] +// `customCSS` is a stylesheet the bundle sends as one string, which the +// construct takes as a file or content; it is refused, never written. +const DASHBOARD_KEYS = [ + 'customUrl', 'customDomain', 'logo', 'favicon', 'link', 'header', 'description', 'width', 'refreshRate', 'paginate', + 'paginationRate', 'checksPerPage', 'useTagsAndOperator', 'hideTags', 'enableIncidents', 'expandChecks', 'showHeader', + 'isPrivate', 'showP95', 'showP99', +] as const satisfies readonly (keyof DashboardProps)[] +const DASHBOARD_SET_KEYS = ['tags'] as const satisfies readonly (keyof DashboardProps)[] +const DASHBOARD_RULES: Rule[] = [ + ...DASHBOARD_KEYS.map(key => identity(key)), ...DASHBOARD_SET_KEYS.map(set), + refusing(['customCSS'], 'a stylesheet, not a property; edit the file or the content by hand'), +] +// The repeat settings only mean something together: an interval written +// without its unit would be a different schedule. +const MAINTENANCE_WINDOW_KEYS = ['name'] as const satisfies readonly (keyof MaintenanceWindowProps)[] +const MAINTENANCE_WINDOW_SET_KEYS = ['tags'] as const satisfies readonly (keyof MaintenanceWindowProps)[] +const REPEAT_KEYS = ['repeatInterval', 'repeatUnit'] as const satisfies readonly (keyof MaintenanceWindowProps)[] +const MAINTENANCE_WINDOW_RULES: Rule[] = [ + ...MAINTENANCE_WINDOW_KEYS.map(key => identity(key)), ...MAINTENANCE_WINDOW_SET_KEYS.map(set), + ...REPEAT_KEYS.map(key => ({ ...identity(key), group: 'repeat' })), +] +// A v2 status page's cards hold references to its services. +const STATUS_PAGE_KEYS = [ + 'name', 'url', 'customDomain', 'logo', 'redirectTo', 'favicon', 'defaultTheme', +] as const satisfies readonly (keyof StatusPageProps)[] +const STATUS_PAGE_RULES: Rule[] = [ + ...STATUS_PAGE_KEYS.map(key => identity(key)), + refusing(['cards'], 'cards hold status page services; edit them by hand'), +] +const STATUS_PAGE_V3_KEYS = [ + 'name', 'url', 'customDomain', 'description', 'logo', 'logoDark', 'redirectTo', 'favicon', 'defaultTheme', + 'privacyPolicyLink', 'termsOfServiceLink', 'supportLink', 'footerText', 'googleAnalyticsTag', 'allowIndexing', +] as const satisfies readonly (keyof StatusPageV3Props)[] +const THEMES = ['light', 'dark'] as const satisfies readonly (keyof StatusPageV3ThemeColors)[] +const THEME_COLOR_KEYS = [ + 'bodyBackgroundColor', 'headerBackgroundColor', 'headerFontColor', 'titleFontColor', 'bodyFontColor', + 'bodyFontColorMuted', 'navigationFontColor', 'linkFontColor', 'cardBackgroundColor', 'borderColor', + 'primaryButtonBackgroundColor', 'primaryButtonFontColor', +] as const satisfies readonly (keyof StatusPageV3ThemeColorGroup)[] +// The account reports each colour as its own leaf; the code holds it under +// `themeColors.light` or `.dark`, which has to exist for a colour to be added. +const STATUS_PAGE_V3_RULES: Rule[] = [ + ...STATUS_PAGE_V3_KEYS.map(key => identity(key)), + ...THEMES.flatMap(theme => under(['themeColors', theme], THEME_COLOR_KEYS)), + refusing(['version'], FIXED), +] +const STATUS_PAGE_SERVICE_KEYS = ['name'] as const satisfies readonly (keyof StatusPageServiceProps)[] +// A component's `showHistoricalData` and `expandedByDefault` travel inside +// `configuration`; its page and parent are references. +const COMPONENT_KEYS = [ + 'type', 'name', 'description', 'hidden', 'displayOrder', +] as const satisfies readonly (keyof StatusPageV3ComponentProps)[] +const COMPONENT_CONFIGURATION_KEYS = [ + 'showHistoricalData', 'expandedByDefault', +] as const satisfies readonly (keyof StatusPageV3ComponentProps)[] +const COMPONENT_RULES: Rule[] = [ + ...COMPONENT_KEYS.map(key => identity(key)), + ...COMPONENT_CONFIGURATION_KEYS.map(key => ({ pointer: ['configuration', key], target: [key] })), +] +const AUTOMATION_RULE_KEYS = [ + 'name', 'enabled', 'firstUpdate', 'lastUpdate', 'notifySubscribers', +] as const satisfies readonly (keyof StatusPageV3AutomationRuleProps)[] +const AUTOMATION_RULE_SET_KEYS = ['tags'] as const satisfies readonly (keyof StatusPageV3AutomationRuleProps)[] +const AUTOMATION_RULE_RULES: Rule[] = [ + ...AUTOMATION_RULE_KEYS.map(key => identity(key)), ...AUTOMATION_RULE_SET_KEYS.map(set), + { pointer: ['coolDownWindowMinutes'], target: ['coolDownMinutes'] }, +] + +// What each class writes, at the top level of its props. `request`, +// `apiCheckDefaults` and `themeColors` stand for every rule under them. const API_WRITTEN = [...CHECK_WRITTEN, ...RUNTIME_CHECK_KEYS, ...RESPONSE_TIME_KEYS, 'request'] as const const BROWSER_WRITTEN = [...CHECK_WRITTEN, ...RUNTIME_CHECK_KEYS, ...BROWSER_KEYS] as const const MULTI_STEP_WRITTEN = [...CHECK_WRITTEN, ...RUNTIME_CHECK_KEYS, ...MULTI_STEP_KEYS] as const @@ -291,6 +446,21 @@ const MONITOR_WRITTEN = [...CHECK_WRITTEN, ...RESPONSE_TIME_KEYS, 'request'] as const ICMP_WRITTEN = [...CHECK_WRITTEN, ...PACKET_LOSS_KEYS, 'request'] as const const HEARTBEAT_WRITTEN = [...CHECK_WRITTEN, ...HEARTBEAT_KEYS] as const const GROUP_WRITTEN = [...GROUP_KEYS, ...GROUP_SET_KEYS, ...GROUP_HELPER_KEYS] as const +const EMAIL_WRITTEN = [...ALERT_CHANNEL_KEYS, ...EMAIL_KEYS] as const +const SLACK_WRITTEN = [...ALERT_CHANNEL_KEYS, ...SLACK_KEYS] as const +const SLACK_APP_WRITTEN = [...ALERT_CHANNEL_KEYS, ...SLACK_APP_KEYS] as const +const WEBHOOK_WRITTEN = [...ALERT_CHANNEL_KEYS, ...WEBHOOK_KEYS] as const +const OPSGENIE_WRITTEN = [...ALERT_CHANNEL_KEYS, ...OPSGENIE_KEYS] as const +const PAGERDUTY_WRITTEN = [...ALERT_CHANNEL_KEYS, ...PAGERDUTY_KEYS] as const +const PHONE_WRITTEN = [...ALERT_CHANNEL_KEYS, ...PHONE_KEYS, 'phoneNumber'] as const +const MSTEAMS_WRITTEN = [...ALERT_CHANNEL_KEYS, ...MSTEAMS_KEYS, 'payload'] as const +const TELEGRAM_WRITTEN = [...ALERT_CHANNEL_KEYS, ...TELEGRAM_KEYS] as const +const INCIDENTIO_WRITTEN = [...ALERT_CHANNEL_KEYS, ...INCIDENTIO_KEYS, 'payload'] as const +const DASHBOARD_WRITTEN = [...DASHBOARD_KEYS, ...DASHBOARD_SET_KEYS] as const +const MAINTENANCE_WINDOW_WRITTEN = [...MAINTENANCE_WINDOW_KEYS, ...MAINTENANCE_WINDOW_SET_KEYS, ...REPEAT_KEYS] as const +const STATUS_PAGE_V3_WRITTEN = [...STATUS_PAGE_V3_KEYS, 'themeColors'] as const +const COMPONENT_WRITTEN = [...COMPONENT_KEYS, ...COMPONENT_CONFIGURATION_KEYS] as const +const AUTOMATION_RULE_WRITTEN = [...AUTOMATION_RULE_KEYS, ...AUTOMATION_RULE_SET_KEYS, 'coolDownMinutes'] as const /** * The props keys the write-back leaves out on purpose, by reason; each @@ -315,6 +485,16 @@ type GroupMemberKey = 'frequency' | 'browserChecks' | 'multiStepChecks' // null and is usually inherited from the project config. /** What every check class leaves out. */ type CheckLeftOut = ReferenceKey | LocalOnlyKey | NotWrittenKey +/** Packed into the Telegram channel's template and URL by the construct. */ +type TelegramDerivedKey = 'chatId' | 'apiKey' | 'messageThreadId' | 'payload' +/** Sent by the incident.io channel as an authorization header. */ +type IncidentioDerivedKey = 'apiKey' +/** A dashboard's stylesheet, a file or content the bundle sends as one string. */ +type StylesheetKey = 'customCSS' +/** A maintenance window's dates, `Date` objects in the code. */ +type DateKey = 'startsAt' | 'endsAt' | 'repeatEndsAt' +/** A status page prop that names other status page resources. */ +type StatusPageReferenceKey = 'cards' | 'statusPage' | 'parent' | 'components' /** `true` when every key of `Written` is a key of `T`, `never` otherwise. */ type Within = Exclude extends never ? true : never @@ -346,6 +526,38 @@ const _everyPropIsListed: [ Exact, Covers, ] = [true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true] +// The same for the other resource types. +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const _everyResourcePropIsListed: [ + Exact, + Exact, + Exact, + Exact, + Exact, + Exact, + Exact, + Exact, + Exact, + Exact, + Exact, + Exact, + Exact, + Exact, + Exact, + Exact, + Exact, + Covers, + Covers, + Exact, + // The union's keys are the ones both members share; each member is held on its own. + Exact, + Exact, + Exact, + Exact, +] = [ + true, true, true, true, true, true, true, true, true, true, true, true, + true, true, true, true, true, true, true, true, true, true, true, true, +] /** * The properties this module writes, per construct class: the ones whose @@ -353,8 +565,13 @@ const _everyPropIsListed: [ * same shape, and the ones the construct spells with a helper this module * can render (`frequency`, `retryStrategy`, `alertEscalationPolicy`, the * `assertions` of a request), and the SSL request whose wire keys map onto - * the construct's (`SSL_REQUEST_RULES`). Anything else — references, - * scripts, a request key the construct does not take — is left to the user. + * the construct's (`SSL_REQUEST_RULES`). A rule may likewise map a wire + * pointer onto a prop spelled or nested differently in the construct (an + * alert channel's `config/` onto its flat prop, a component's + * `configuration/`, `coolDownWindowMinutes` onto `coolDownMinutes`), + * and a `refusing` rule names the reason for a leaf the construct fixes or + * derives. Anything else — references, scripts, a request key the + * construct does not take — is left to the user. * * Keyed by the exact class, not by `instanceof`: a class this table does not * name gets nothing rather than a base class's rules, so a construct that @@ -402,6 +619,25 @@ export const RULES_BY_CLASS: ReadonlyMap = new ]], [CheckGroupV1, [...GROUP_RULES, ...alertRules('group')]], [CheckGroupV2, [...GROUP_RULES, ...alertRules('group-v2')]], + [EmailAlertChannel, [...ALERT_CHANNEL_RULES, ...config(EMAIL_KEYS)]], + [SlackAlertChannel, [...ALERT_CHANNEL_RULES, ...config(SLACK_KEYS)]], + [SlackAppAlertChannel, [...ALERT_CHANNEL_RULES, ...config(SLACK_APP_KEYS)]], + [WebhookAlertChannel, [...ALERT_CHANNEL_RULES, ...config(WEBHOOK_KEYS)]], + [OpsgenieAlertChannel, [...ALERT_CHANNEL_RULES, ...config(OPSGENIE_KEYS)]], + [PagerdutyAlertChannel, [...ALERT_CHANNEL_RULES, ...config(PAGERDUTY_KEYS)]], + [SmsAlertChannel, [...ALERT_CHANNEL_RULES, ...PHONE_RULES]], + [PhoneCallAlertChannel, [...ALERT_CHANNEL_RULES, ...PHONE_RULES]], + [MSTeamsAlertChannel, [...ALERT_CHANNEL_RULES, ...MSTEAMS_RULES]], + [TelegramAlertChannel, [...ALERT_CHANNEL_RULES, ...TELEGRAM_RULES]], + [IncidentioAlertChannel, [...ALERT_CHANNEL_RULES, ...INCIDENTIO_RULES]], + [PrivateLocation, PRIVATE_LOCATION_KEYS.map(key => identity(key))], + [Dashboard, DASHBOARD_RULES], + [MaintenanceWindow, MAINTENANCE_WINDOW_RULES], + [StatusPage, STATUS_PAGE_RULES], + [StatusPageV3, STATUS_PAGE_V3_RULES], + [StatusPageService, STATUS_PAGE_SERVICE_KEYS.map(key => identity(key))], + [StatusPageV3Component, COMPONENT_RULES], + [StatusPageV3AutomationRule, AUTOMATION_RULE_RULES], ]) /** The top-level props keys each class's rules write, as the build-time assertion above knows them; the spec holds `RULES_BY_CLASS` to it. */ @@ -413,10 +649,23 @@ export const WRITTEN_BY_CLASS: ReadonlyMap = [GrpcMonitor, MONITOR_WRITTEN], [SslMonitor, MONITOR_WRITTEN], [TracerouteMonitor, MONITOR_WRITTEN], [IcmpMonitor, ICMP_WRITTEN], [HeartbeatMonitor, HEARTBEAT_WRITTEN], [CheckGroupV1, GROUP_WRITTEN], [CheckGroupV2, GROUP_WRITTEN], + [EmailAlertChannel, EMAIL_WRITTEN], [SlackAlertChannel, SLACK_WRITTEN], [SlackAppAlertChannel, SLACK_APP_WRITTEN], + [WebhookAlertChannel, WEBHOOK_WRITTEN], [OpsgenieAlertChannel, OPSGENIE_WRITTEN], + [PagerdutyAlertChannel, PAGERDUTY_WRITTEN], [SmsAlertChannel, PHONE_WRITTEN], + [PhoneCallAlertChannel, PHONE_WRITTEN], + [MSTeamsAlertChannel, MSTEAMS_WRITTEN], [TelegramAlertChannel, TELEGRAM_WRITTEN], + [IncidentioAlertChannel, INCIDENTIO_WRITTEN], + [PrivateLocation, PRIVATE_LOCATION_KEYS], [Dashboard, DASHBOARD_WRITTEN], + [MaintenanceWindow, MAINTENANCE_WINDOW_WRITTEN], [StatusPage, STATUS_PAGE_KEYS], + [StatusPageV3, STATUS_PAGE_V3_WRITTEN], [StatusPageService, STATUS_PAGE_SERVICE_KEYS], + [StatusPageV3Component, COMPONENT_WRITTEN], [StatusPageV3AutomationRule, AUTOMATION_RULE_WRITTEN], ]) /** Paths that name another resource or a relation rather than a value of this one. */ -const REFERENCE_PREFIXES = ['alertChannels', 'privateLocations', 'alertChannelSubscriptions', 'privateLocationAssignments', 'groupId'] +const REFERENCE_PREFIXES = [ + 'alertChannels', 'privateLocations', 'alertChannelSubscriptions', 'privateLocationAssignments', 'groupId', + 'statusPageId', 'parentId', 'components', +] /** Properties a remote change to is reported rather than written, with the reason. */ const NOT_WRITTEN: ReadonlyMap = new Map([ @@ -585,16 +834,17 @@ function candidates (context: EntryContext, rules: readonly Rule[]): Candidate[] continue } const rule = ruleFor(segments) + // A refusing rule has a reason of its own and may have no primary. + const refused = rule?.refuse?.(context.entry.before) + if (refused !== undefined) { + context.skip(refused, change.path) + continue + } const primary = rule === undefined ? undefined : primaryOf(rule) if (rule === undefined || primary === undefined) { context.skip('not a property this tool can update', change.path) continue } - const refused = rule.refuse?.(context.entry.before) - if (refused !== undefined) { - context.skip(refused, change.path) - continue - } const key = primary.target.join('.') const candidate = byPath.get(key) ?? { rule: primary, changes: [], local: [] } candidate.changes.push({ change, rule }) @@ -602,15 +852,22 @@ function candidates (context: EntryContext, rules: readonly Rule[]): Candidate[] } // A list is written whole from `before`, which knows nothing of an element // the code added and has not deployed; such an edit must not be erased. + // A property written together with others (`group`) is local when any of + // them is: an interval written next to a unit the code changed would be a + // schedule nobody set. for (const change of context.entry.changes ?? []) { if (change.origin !== 'code') { continue } const segments = segmentsOf(change) const rule = segments === undefined ? undefined : ruleFor(segments) - const candidate = rule === undefined ? undefined : byPath.get(rule.target.join('.')) - if (candidate !== undefined) { - candidate.local.push(change) + if (rule === undefined) { + continue + } + for (const candidate of byPath.values()) { + if (candidate.rule === primaryOf(rule) || (rule.group !== undefined && candidate.rule.group === rule.group)) { + candidate.local.push(change) + } } } return [...byPath.values()] @@ -756,9 +1013,11 @@ export async function planWriteBack ({ diff, project, cwd }: WriteBackOptions): } const rules = RULES_BY_CLASS.get(construct.constructor as ConstructClass) if (rules === undefined) { + // A reference to a resource of another project, or a relation, has + // no properties of its own; a class of the user's own is not known. context.skip(exportedNamesOf(construct).size === 0 ? `${construct.constructor.name} is not a class from checkly/constructs` - : 'updating the code is supported for checks and check groups only') + : `${construct.constructor.name} has no properties this tool can update`) continue } if (construct.checkFileAbsolutePath === undefined) { @@ -779,16 +1038,29 @@ export async function planWriteBack ({ diff, project, cwd }: WriteBackOptions): } throw err } + const found = candidates(context, rules).map(candidate => ({ + candidate, found: valueFor(context, candidate, entry.before, blanked), + })) + // A member of a group refused here takes the rest of its group with it, + // as one the splicer refuses does below. + const refusedGroups = new Set(found.filter(entry => entry.found === undefined) + .map(entry => entry.candidate.rule.group)) const edits: FileWork['edits'] = [] - for (const candidate of candidates(context, rules)) { - const found = valueFor(context, candidate, entry.before, blanked) - if (found !== undefined) { - edits.push({ - ...editFor(candidate.rule.target, found), - replacesLocalEdit: candidate.changes.some(({ change }) => change.origin === 'both'), - group: candidate.rule.group, - }) + for (const { candidate, found: value } of found) { + const { group, target } = candidate.rule + if (value === undefined) { + continue + } + if (group !== undefined && refusedGroups.has(group)) { + const others = found.filter(entry => entry.candidate.rule.group === group && entry.candidate !== candidate) + context.skip(`written together with ${others.map(entry => entry.candidate.rule.target.join('.')).join(', ')}`, target.join('.')) + continue } + edits.push({ + ...editFor(target, value), + replacesLocalEdit: candidate.changes.some(({ change }) => change.origin === 'both'), + group, + }) } if (edits.length === 0) { continue From d6065e467e5a16c7c7b3931b68774e361f93bf4f Mon Sep 17 00:00:00 2001 From: Simo Kinnunen Date: Wed, 23 Sep 2026 16:49:03 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(write-back):=20write=20a=20maintenance?= =?UTF-8?q?=20window's=20dates=20as=20new=20Date(=E2=80=A6)=20[RED-997]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The account reports startsAt, endsAt and repeatEndsAt as ISO strings; the construct takes Date objects, which checkly import spells as `new Date('')`. Add a `date` helper kind that builds that expression with the codegen's own valueForDate (now shared with the import), needs no import since Date is a global, replaces an existing `new Date(…)` or a string, and refuses a variable or a file that binds Date to something of its own. repeatEndsAt joins the repeat group, so the interval, unit and end date are written together or not at all. Co-Authored-By: Claude Fable 5.1 --- .../constructs/maintenance-window-codegen.ts | 31 ++++++-------- .../write-back/__tests__/helper-edit.spec.ts | 42 +++++++++++++++++++ .../write-back/__tests__/plan.spec.ts | 18 +++++++- .../src/services/write-back/apply-edits.ts | 14 ++++++- .../src/services/write-back/helper-edit.ts | 25 +++++++++-- packages/cli/src/services/write-back/plan.ts | 22 ++++++---- .../src/services/write-back/source-file.ts | 27 ++++++++++++ 7 files changed, 147 insertions(+), 32 deletions(-) diff --git a/packages/cli/src/constructs/maintenance-window-codegen.ts b/packages/cli/src/constructs/maintenance-window-codegen.ts index a79d38ff..6911c1e5 100644 --- a/packages/cli/src/constructs/maintenance-window-codegen.ts +++ b/packages/cli/src/constructs/maintenance-window-codegen.ts @@ -1,5 +1,14 @@ import { Codegen, Context } from './internal/codegen/index.js' -import { expr, ident } from '../sourcegen/index.js' +import { expr, ident, Value } from '../sourcegen/index.js' + +/** The expression for a timestamp the API reports as an ISO string: `new Date('')`. */ +export function valueForDate (iso: string): Value { + return expr(ident('Date'), builder => { + builder.new(builder => { + builder.string(iso) + }) + }) +} export interface MaintenanceWindowResource { name: string @@ -39,17 +48,8 @@ export class MaintenanceWindowCodegen extends Codegen } }) - builder.expr('startsAt', ident('Date'), builder => { - builder.new(builder => { - builder.string(resource.startsAt) - }) - }) - - builder.expr('endsAt', ident('Date'), builder => { - builder.new(builder => { - builder.string(resource.endsAt) - }) - }) + builder.value('startsAt', valueForDate(resource.startsAt)) + builder.value('endsAt', valueForDate(resource.endsAt)) if (resource.repeatInterval !== undefined && resource.repeatInterval !== null) { builder.number('repeatInterval', resource.repeatInterval) @@ -60,12 +60,7 @@ export class MaintenanceWindowCodegen extends Codegen } if (resource.repeatEndsAt) { - const repeatEndsAt = resource.repeatEndsAt - builder.expr('repeatEndsAt', ident('Date'), builder => { - builder.new(builder => { - builder.string(repeatEndsAt) - }) - }) + builder.value('repeatEndsAt', valueForDate(resource.repeatEndsAt)) } }) }) diff --git a/packages/cli/src/services/write-back/__tests__/helper-edit.spec.ts b/packages/cli/src/services/write-back/__tests__/helper-edit.spec.ts index 53385f5a..b798132f 100644 --- a/packages/cli/src/services/write-back/__tests__/helper-edit.spec.ts +++ b/packages/cli/src/services/write-back/__tests__/helper-edit.spec.ts @@ -47,6 +47,7 @@ const escalation = (value: unknown): HelperEdit => ({ path: ['alertEscalationPolicy'], value, helper: 'alertEscalation' }) const assertions = (value: unknown, builder: AssertionBuilderName = 'AssertionBuilder'): HelperEdit => ({ path: ['request', 'assertions'], value, helper: 'assertions', builder }) +const date = (value: unknown): HelperEdit => ({ path: ['startsAt'], value, helper: 'date' }) const STYLE = { quote: '\'' as const, indentUnit: ' ', lineEnding: '\n' as const } const LAYOUT = { column: ' ', inline: false, trailingComma: true, locals: new Map() } @@ -75,6 +76,8 @@ describe('buildHelperValue and renderExpression', () => { expect(render(frequency(7))).toBe('7') expect(render(frequency({ frequency: 0, frequencyOffset: 15 }))).toBe('new Frequency(0, 15)') expect(render(retry(null))).toBe('RetryStrategyBuilder.noRetries()') + expect(render(date('2026-01-01T00:00:00.000Z'))).toBe('new Date(\'2026-01-01T00:00:00.000Z\')') + expect(buildHelperValue(date('2026-01-01T00:00:00.000Z')).imports).toEqual([]) expect(render(retry({ type: 'FIXED', baseBackoffSeconds: 60, maxRetries: 2, maxDurationSeconds: 600, sameRegion: true }))) .toBe('RetryStrategyBuilder.fixedStrategy({})') expect(render(retry({ type: 'LINEAR', baseBackoffSeconds: 10, maxRetries: 3, sameRegion: false, onlyOn: 'NETWORK_ERROR' }))) @@ -137,6 +140,8 @@ describe('buildHelperValue and renderExpression', () => { expect(() => buildHelperValue(assertions([null]))).toThrow('Checkly has no value for request.assertions; edit the property by hand') expect(() => buildHelperValue(escalation({}))).toThrow('Checkly has no value for alertEscalationPolicy; edit the property by hand') expect(() => buildHelperValue(retry({}))).toThrow('Checkly has no value for retryStrategy; edit the property by hand') + expect(() => buildHelperValue(date(null))).toThrow('Checkly has no value for startsAt; edit the property by hand') + expect(() => buildHelperValue(date('yesterday'))).toThrow('Checkly has no value for startsAt; edit the property by hand') // A number the codegen cannot compute is refused before it reaches the file. const nan = assertions([{ source: 'STATUS_CODE', comparison: 'EQUALS', target: 'abc', property: '', regex: null }]) expect(() => renderExpression(buildHelperValue(nan).value, STYLE, LAYOUT)).toThrow('NaN cannot be written as a literal') @@ -208,6 +213,8 @@ describe('isHelperExpression and matchesValue', () => { [assertions([{ source: 'STATUS_CODE', property: '', comparison: 'EQUALS', target: '200', regex: null }]), '[AssertionBuilder.statusCode().equals(-200)]', false], [assertions([]), '[]', true], + [date('2026-01-01T00:00:00.000Z'), 'new Date(\'2026-01-01T00:00:00.000Z\')', true], + [date('2026-01-01T00:00:00.000Z'), 'new Date(\'2026-01-01T00:00:00Z\')', false], ] const boundAs = new Map() for (const [edit, code, expected] of cases) { @@ -499,6 +506,41 @@ new ApiCheck('api', { retryStrategy: RetryStrategyBuilder.fixedStrategy({ sameRe } }) + it('writes a date over a Date expression or a string, without an import, and not over a variable', () => { + const text = `import { ApiCheck } from 'checkly/constructs' +const when = new Date() +new ApiCheck('api', { + startsAt: new Date('2026-01-01T00:00:00Z'), + endsAt: '2026-01-02T00:00:00.000Z', + repeatEndsAt: when, +}) +` + const result = apply('a.ts', text, [ + date('2026-01-01T00:00:00.000Z'), + { path: ['endsAt'], value: '2026-01-03T00:00:00.000Z', helper: 'date' }, + { path: ['repeatEndsAt'], value: '2026-01-04T00:00:00.000Z', helper: 'date' }, + { path: ['name'], value: '2026-01-05T00:00:00.000Z', helper: 'date' }, + ]) + expect(result.applied.map(edit => [edit.path.join('.'), edit.rendered])).toEqual([ + ['startsAt', 'new Date(\'2026-01-01T00:00:00.000Z\')'], + ['endsAt', 'new Date(\'2026-01-03T00:00:00.000Z\')'], + ['name', 'new Date(\'2026-01-05T00:00:00.000Z\')'], + ]) + expect(result.skipped.map(edit => [edit.path.join('.'), edit.reason])).toEqual([ + ['repeatEndsAt', 'repeatEndsAt is the variable when, not a literal or a Date expression'], + ]) + expect(result.imports).toEqual([]) + expect(result.text).toContain(' startsAt: new Date(\'2026-01-01T00:00:00.000Z\'),\n endsAt: new Date(\'2026-01-03T00:00:00.000Z\'),') + const reparsed = findConstructOptions(parseSource('a.ts', result.text), 'api', NAMES) + expect(result.applied.every(edit => readsBack(reparsed, edit))).toBe(true) + // A file that binds `Date` to something of its own does not hold the global. + const shadowed = apply('a.ts', text.replace('const when = new Date()', 'import { Date } from \'./dates.js\''), [ + date('2026-01-01T00:00:00.000Z'), + ]) + expect(shadowed.applied).toEqual([]) + expect(shadowed.skipped.map(edit => edit.reason)).toEqual(['Date is bound to something else in this file']) + }) + it('works through acorn for JavaScript files with a require', () => { const text = `const { ApiCheck } = require('checkly/constructs')\nnew ApiCheck('api', { frequency: 10 })\n` const result = apply('a.js', text, [frequency({ frequency: 0, frequencyOffset: 20 })]) diff --git a/packages/cli/src/services/write-back/__tests__/plan.spec.ts b/packages/cli/src/services/write-back/__tests__/plan.spec.ts index fa9cb644..120ef024 100644 --- a/packages/cli/src/services/write-back/__tests__/plan.spec.ts +++ b/packages/cli/src/services/write-back/__tests__/plan.spec.ts @@ -1386,6 +1386,7 @@ new MaintenanceWindow('mw', { name: 'MW', tags: ['a'], startsAt: new Date('2026- new MaintenanceWindow('mw2', { name: 'MW2', tags: ['a'], startsAt: new Date('2026-01-01T00:00:00.000Z'), endsAt: new Date('2026-01-02T00:00:00.000Z'), repeatInterval: 1, repeatUnit: unit }) new MaintenanceWindow('mw3', { name: 'MW3', tags: ['a'], startsAt: new Date('2026-01-01T00:00:00.000Z'), endsAt: new Date('2026-01-02T00:00:00.000Z'), repeatInterval: 1, repeatUnit: 'WEEK' }) new MaintenanceWindow('mw4', { name: 'MW4', tags: ['a'], startsAt: new Date('2026-01-01T00:00:00.000Z'), endsAt: new Date('2026-01-02T00:00:00.000Z'), repeatInterval: 1, repeatUnit: 'DAY' }) +new MaintenanceWindow('mw5', { name: 'MW5', tags: ['a'], startsAt: new Date('2026-01-01T00:00:00.000Z'), endsAt: new Date('2026-01-02T00:00:00.000Z'), repeatInterval: 1, repeatUnit: 'DAY', repeatEndsAt: new Date('2026-03-01T00:00:00.000Z') }) new PrivateLocation('pl2', { name: 'PL2', slugName: 'pl2', proxyUrl: 'http://proxy' }) `, () => { const dates = { startsAt: new Date('2026-01-01T00:00:00.000Z'), endsAt: new Date('2026-01-02T00:00:00.000Z') } @@ -1395,6 +1396,9 @@ new PrivateLocation('pl2', { name: 'PL2', slugName: 'pl2', proxyUrl: 'http://pro new MaintenanceWindow('mw2', { name: 'MW2', tags: ['a'], ...dates, repeatInterval: 1, repeatUnit: 'DAY' }) new MaintenanceWindow('mw3', { name: 'MW3', tags: ['a'], ...dates, repeatInterval: 1, repeatUnit: 'WEEK' }) new MaintenanceWindow('mw4', { name: 'MW4', tags: ['a'], ...dates, repeatInterval: 1, repeatUnit: 'DAY' }) + new MaintenanceWindow('mw5', { + name: 'MW5', tags: ['a'], ...dates, repeatInterval: 1, repeatUnit: 'DAY', repeatEndsAt: new Date('2026-03-01T00:00:00.000Z'), + }) new PrivateLocation('pl2', { name: 'PL2', slugName: 'pl2', proxyUrl: 'http://proxy' }) }) await declare('pages.ts', `import { @@ -1433,11 +1437,19 @@ new StatusPageV3AutomationRule('rule', { entry('maintenance-window', 'mw4', { repeatInterval: 2, repeatUnit: 'WEEK' }, [ remote('/repeatInterval', 1, 2), remote('/repeatUnit', 'DAY', 'MONTH'), ]), + // An end date the account cleared is a null the writer refuses, which takes the interval with it. + entry('maintenance-window', 'mw5', { repeatInterval: 2, repeatEndsAt: null }, [ + remote('/repeatInterval', 1, 2), remote('/repeatEndsAt', '2026-03-01T00:00:00.000Z', null), + ]), entry('dashboard', 'dash', { tags: ['a', 'b'], refreshRate: 300, customCSS: 'b {}' }, [ { path: '/tags/x', origin: 'remote', after: 'b' }, remote('/refreshRate', 60, 300), remote('/customCSS', 'a {}', 'b {}'), ]), - entry('maintenance-window', 'mw', { repeatInterval: 2, repeatUnit: 'WEEK' }, [ + entry('maintenance-window', 'mw', { + startsAt: '2026-02-01T00:00:00.000Z', repeatInterval: 2, repeatUnit: 'WEEK', repeatEndsAt: '2026-03-01T00:00:00.000Z', + }, [ + remote('/startsAt', '2026-01-01T00:00:00.000Z', '2026-02-01T00:00:00.000Z'), remote('/repeatInterval', 1, 2), remote('/repeatUnit', 'DAY', 'WEEK'), + remote('/repeatEndsAt', null, '2026-03-01T00:00:00.000Z'), ]), entry('maintenance-window', 'mw2', { repeatInterval: 2, repeatUnit: 'WEEK' }, [ remote('/repeatInterval', 1, 2), remote('/repeatUnit', 'DAY', 'WEEK'), @@ -1475,6 +1487,8 @@ new StatusPageV3AutomationRule('rule', { 'status-page-component comp /statusPageId: references another resource', 'status-page-component comp /parentId: references another resource', 'status-page-automation-rule rule /components/x: references another resource', + 'maintenance-window mw5 repeatInterval: written together with repeatEndsAt', + 'maintenance-window mw5 repeatEndsAt: Checkly has no value for repeatEndsAt; edit the property by hand', 'maintenance-window mw2 repeatInterval: written together with repeatUnit', 'maintenance-window mw2 repeatUnit: repeatUnit is the variable unit, not a plain literal', 'status-page v3 themeColors.dark.bodyBackgroundColor: themeColors.dark is not set in the code', @@ -1483,8 +1497,10 @@ new StatusPageV3AutomationRule('rule', { ['pl', 'name', '\'PL\'', '\'PL 2\''], ['dash', 'tags', '[\'a\']', '[\'a\', \'b\']'], ['dash', 'refreshRate', '60', '300'], + ['mw', 'startsAt', 'new Date(\'2026-01-01T00:00:00.000Z\')', 'new Date(\'2026-02-01T00:00:00.000Z\')'], ['mw', 'repeatInterval', '1', '2'], ['mw', 'repeatUnit', '\'DAY\'', '\'WEEK\''], + ['mw', 'repeatEndsAt', undefined, 'new Date(\'2026-03-01T00:00:00.000Z\')'], ['v2', 'name', '\'V2\'', '\'V2 renamed\''], ['v3', 'themeColors.light.bodyBackgroundColor', '\'#fff\'', '\'#000\''], ['svc', 'name', '\'Svc\'', '\'Service\''], diff --git a/packages/cli/src/services/write-back/apply-edits.ts b/packages/cli/src/services/write-back/apply-edits.ts index 2f0f2d1e..e9b975d3 100644 --- a/packages/cli/src/services/write-back/apply-edits.ts +++ b/packages/cli/src/services/write-back/apply-edits.ts @@ -5,6 +5,7 @@ import { buildHelperValue, type HelperEdit, helperClass, + isGlobalHelper, isHelperEdit, isReplaceableByHelper, matchesValue, @@ -32,7 +33,9 @@ import { type SourceStyle, trailingCommaOf, } from './literal-edit.js' -import { checklyBindings, localBinding, type Node, type ParsedSource, type Splice, walk, WriteBackSkipped } from './source-file.js' +import { + checklyBindings, declaresName, localBinding, type Node, type ParsedSource, type Splice, walk, WriteBackSkipped, +} from './source-file.js' export type { HelperEdit, SourceEdit } @@ -73,7 +76,14 @@ export function applyEdits ( const helper = isHelperEdit(edit) ? edit : undefined // Any assertion builder spells the property; the other kinds have one class each. const classes = helper === undefined ? [] : helper.helper === 'assertions' ? ASSERTION_BUILDERS : [helperClass(helper)] - const locals = checklyBindings(program, new Set(classes)) + // A global such as `Date` is bound in every file under its own name, + // unless the file binds the name to something of its own. + const global = helper !== undefined && isGlobalHelper(helper) + if (global && classes.some(name => declaresName(program, name))) { + skip(edit, `${classes.join(', ')} is bound to something else in this file`) + continue + } + const locals = global ? new Set(classes) : checklyBindings(program, new Set(classes)) const resolution = helper === undefined ? resolvePath(options, edit.path) : resolvePath(options, edit.path, { diff --git a/packages/cli/src/services/write-back/helper-edit.ts b/packages/cli/src/services/write-back/helper-edit.ts index c6decb98..1b83eac7 100644 --- a/packages/cli/src/services/write-back/helper-edit.ts +++ b/packages/cli/src/services/write-back/helper-edit.ts @@ -6,6 +6,7 @@ import { valueForDnsAssertion } from '../../constructs/dns-assertion-codegen.js' import { valueForFrequency } from '../../constructs/frequency-codegen.js' import { valueForGrpcAssertion } from '../../constructs/grpc-assertion-codegen.js' import { valueForIcmpAssertion } from '../../constructs/icmp-assertion-codegen.js' +import { valueForDate } from '../../constructs/maintenance-window-codegen.js' import { valueForRetryStrategy } from '../../constructs/retry-strategy-codegen.js' import { valueForSslAssertion } from '../../constructs/ssl-assertion-codegen.js' import { valueForTcpAssertion } from '../../constructs/tcp-monitor-codegen.js' @@ -36,7 +37,8 @@ import { type Node, WriteBackSkipped } from './source-file.js' * than a literal: `frequency: Frequency.EVERY_5M`, * `retryStrategy: RetryStrategyBuilder.fixedStrategy({ … })`, * `alertEscalationPolicy: AlertEscalationBuilder.runBasedEscalation(…)`, - * `assertions: [AssertionBuilder.statusCode().equals(200)]`. + * `assertions: [AssertionBuilder.statusCode().equals(200)]`, and a date + * the account reports as an ISO string: `startsAt: new Date('…')`. * * The expression is built by the same codegen `checkly import` uses for the * property, so the text written is the one an import would have generated; @@ -52,7 +54,7 @@ import { type Node, WriteBackSkipped } from './source-file.js' * meant, and a rewrite must not throw it away. */ -export type HelperKind = 'frequency' | 'retryStrategy' | 'alertEscalation' | 'assertions' +export type HelperKind = 'frequency' | 'retryStrategy' | 'alertEscalation' | 'assertions' | 'date' type AssertionCodegen = (genfile: GeneratedFile, assertion: any) => Value @@ -91,7 +93,7 @@ interface HelperEditBase extends Omit { * has its own builder. */ export type HelperEdit = HelperEditBase & ( - | { helper: 'frequency' | 'retryStrategy' | 'alertEscalation', builder?: undefined } + | { helper: 'frequency' | 'retryStrategy' | 'alertEscalation' | 'date', builder?: undefined } | { helper: 'assertions', builder: AssertionBuilderName } ) @@ -105,13 +107,19 @@ const HELPER_CLASSES: Readonly, string> frequency: 'Frequency', retryStrategy: 'RetryStrategyBuilder', alertEscalation: 'AlertEscalationBuilder', + date: 'Date', } -/** The class the edit's expression is built on, as `checkly/constructs` exports it. */ +/** The class the edit's expression is built on, as `checkly/constructs` exports it, or the global `Date`. */ export function helperClass (edit: HelperEdit): string { return edit.helper === 'assertions' ? edit.builder : HELPER_CLASSES[edit.helper] } +/** Whether the edit's class is a global every file binds, rather than one imported from `checkly/constructs`. */ +export function isGlobalHelper (edit: HelperEdit): boolean { + return edit.helper === 'date' +} + /** * A `GeneratedFile` that only remembers which names the codegen asked to * import. The names are added to the file's `checkly/constructs` import, so @@ -214,6 +222,15 @@ export function buildHelperValue (edit: HelperEdit): { value: Value, imports: st } value = valueForAlertEscalation(sink, edit.value) break + case 'date': { + // The account reports a timestamp as an ISO string; the construct + // takes a Date, which the import spells as `new Date('')`. + if (typeof edit.value !== 'string' || Number.isNaN(Date.parse(edit.value))) { + throw missing() + } + value = valueForDate(edit.value) + break + } case 'assertions': { const codegen: AssertionCodegen | undefined = ASSERTION_CODEGENS[edit.builder] if (codegen === undefined) { diff --git a/packages/cli/src/services/write-back/plan.ts b/packages/cli/src/services/write-back/plan.ts index 7ebe7075..c6e386a6 100644 --- a/packages/cli/src/services/write-back/plan.ts +++ b/packages/cli/src/services/write-back/plan.ts @@ -135,7 +135,7 @@ export interface Rule { group?: string /** The helper the construct spells the property with; the value is written as its expression. */ helper?: - | { kind: 'frequency' | 'retryStrategy' } + | { kind: 'frequency' | 'retryStrategy' | 'date' } | { kind: 'alertEscalation', policy: AlertPolicyHolder } | { kind: 'assertions', builder: AssertionBuilderName } /** Further pointers whose `before` values the expression needs (`frequencyOffset` beside `frequency`). */ @@ -378,14 +378,21 @@ const DASHBOARD_RULES: Rule[] = [ ...DASHBOARD_KEYS.map(key => identity(key)), ...DASHBOARD_SET_KEYS.map(set), refusing(['customCSS'], 'a stylesheet, not a property; edit the file or the content by hand'), ] -// The repeat settings only mean something together: an interval written -// without its unit would be a different schedule. +// The dates are `Date` objects in the code, written as `new Date('')` +// from the ISO string the account reports. The repeat settings only mean +// something together: an interval written without its unit, or without +// the date the repetition ends on, would be a different schedule. const MAINTENANCE_WINDOW_KEYS = ['name'] as const satisfies readonly (keyof MaintenanceWindowProps)[] const MAINTENANCE_WINDOW_SET_KEYS = ['tags'] as const satisfies readonly (keyof MaintenanceWindowProps)[] +const MAINTENANCE_WINDOW_DATE_KEYS = ['startsAt', 'endsAt'] as const satisfies readonly (keyof MaintenanceWindowProps)[] const REPEAT_KEYS = ['repeatInterval', 'repeatUnit'] as const satisfies readonly (keyof MaintenanceWindowProps)[] +const REPEAT_DATE_KEYS = ['repeatEndsAt'] as const satisfies readonly (keyof MaintenanceWindowProps)[] +const date = (key: string): Rule => ({ ...identity(key), helper: { kind: 'date' } }) const MAINTENANCE_WINDOW_RULES: Rule[] = [ ...MAINTENANCE_WINDOW_KEYS.map(key => identity(key)), ...MAINTENANCE_WINDOW_SET_KEYS.map(set), + ...MAINTENANCE_WINDOW_DATE_KEYS.map(date), ...REPEAT_KEYS.map(key => ({ ...identity(key), group: 'repeat' })), + ...REPEAT_DATE_KEYS.map(key => ({ ...date(key), group: 'repeat' })), ] // A v2 status page's cards hold references to its services. const STATUS_PAGE_KEYS = [ @@ -457,7 +464,10 @@ const MSTEAMS_WRITTEN = [...ALERT_CHANNEL_KEYS, ...MSTEAMS_KEYS, 'payload'] as c const TELEGRAM_WRITTEN = [...ALERT_CHANNEL_KEYS, ...TELEGRAM_KEYS] as const const INCIDENTIO_WRITTEN = [...ALERT_CHANNEL_KEYS, ...INCIDENTIO_KEYS, 'payload'] as const const DASHBOARD_WRITTEN = [...DASHBOARD_KEYS, ...DASHBOARD_SET_KEYS] as const -const MAINTENANCE_WINDOW_WRITTEN = [...MAINTENANCE_WINDOW_KEYS, ...MAINTENANCE_WINDOW_SET_KEYS, ...REPEAT_KEYS] as const +const MAINTENANCE_WINDOW_WRITTEN = [ + ...MAINTENANCE_WINDOW_KEYS, ...MAINTENANCE_WINDOW_SET_KEYS, ...MAINTENANCE_WINDOW_DATE_KEYS, + ...REPEAT_KEYS, ...REPEAT_DATE_KEYS, +] as const const STATUS_PAGE_V3_WRITTEN = [...STATUS_PAGE_V3_KEYS, 'themeColors'] as const const COMPONENT_WRITTEN = [...COMPONENT_KEYS, ...COMPONENT_CONFIGURATION_KEYS] as const const AUTOMATION_RULE_WRITTEN = [...AUTOMATION_RULE_KEYS, ...AUTOMATION_RULE_SET_KEYS, 'coolDownMinutes'] as const @@ -491,8 +501,6 @@ type TelegramDerivedKey = 'chatId' | 'apiKey' | 'messageThreadId' | 'payload' type IncidentioDerivedKey = 'apiKey' /** A dashboard's stylesheet, a file or content the bundle sends as one string. */ type StylesheetKey = 'customCSS' -/** A maintenance window's dates, `Date` objects in the code. */ -type DateKey = 'startsAt' | 'endsAt' | 'repeatEndsAt' /** A status page prop that names other status page resources. */ type StatusPageReferenceKey = 'cards' | 'statusPage' | 'parent' | 'components' @@ -543,7 +551,7 @@ const _everyResourcePropIsListed: [ Exact, Exact, Exact, - Exact, + Exact, Exact, Exact, Covers, diff --git a/packages/cli/src/services/write-back/source-file.ts b/packages/cli/src/services/write-back/source-file.ts index 39e4fd61..fbe813f2 100644 --- a/packages/cli/src/services/write-back/source-file.ts +++ b/packages/cli/src/services/write-back/source-file.ts @@ -322,6 +322,33 @@ export function findConstructOptions ( * yields a parent before its children, so the key or property node to * ignore is known by the time it comes up. */ +/** + * Whether the program binds `name` at its top level: an import, a variable, + * function or class declaration. A global such as `Date` that a file binds + * to something of its own is not the global there. + */ +export function declaresName (program: TSESTree.Program, name: string): boolean { + for (const statement of program.body) { + const declaration = statement.type === 'ExportNamedDeclaration' ? statement.declaration : statement + if (declaration === null || declaration === undefined) { + continue + } + if (declaration.type === 'ImportDeclaration') { + if (declaration.specifiers.some(specifier => specifier.local.name === name)) { + return true + } + } else if (declaration.type === 'VariableDeclaration') { + if (declaration.declarations.some(declarator => usesIdentifier(declarator.id, name))) { + return true + } + } else if ((declaration.type === 'FunctionDeclaration' || declaration.type === 'ClassDeclaration') + && declaration.id?.name === name) { + return true + } + } + return false +} + export function usesIdentifier (root: Node, name: string): boolean { const ignored = new Set() for (const node of walk(root)) {