From 7e0b65f5c48915fc676740b0a717d2335b78c5dc Mon Sep 17 00:00:00 2001 From: caballeto Date: Mon, 26 Oct 2026 14:15:02 +0100 Subject: [PATCH 1/3] fix: keep 1.11 enabled, wider prune flags and state pull scope Restore what 1.11.0 users rely on: - monitors accept enabled in devhelm.yml (and defaults.monitors); sent on create only when set, sent as enabled ?? null on update - deploy --prune-org-cli / --prune-all and plan --prune-all widen the delete scope as in 1.11; plain --prune also covers this project's own monitors - state pull scopes monitors to project: only when the config sets it Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 ++---- skills/devhelm-synthetics/SKILL.md | 3 ++- src/commands/deploy/index.ts | 30 ++++++++++++++++------ src/commands/monitors/pause.ts | 2 +- src/commands/plan.ts | 17 ++++++------- src/commands/state/pull.ts | 19 ++++++++++---- src/lib/monitors/export.ts | 2 +- src/lib/project/pipeline.ts | 6 ++++- src/lib/yaml/differ.ts | 16 ++++++------ src/lib/yaml/handlers.ts | 6 +++-- src/lib/yaml/parser.ts | 3 ++- src/lib/yaml/schema.ts | 3 +++ src/lib/yaml/transform.ts | 8 ++++-- src/lib/yaml/zod-schemas.ts | 18 +++----------- test/commands/state-pull.test.ts | 20 +++++++++++++++ test/yaml/differ.test.ts | 40 ++++++++++++++++++++++++------ test/yaml/transform.test.ts | 12 +++++++++ test/yaml/zod-schemas.test.ts | 13 +++++++--- 18 files changed, 157 insertions(+), 68 deletions(-) create mode 100644 test/commands/state-pull.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 18d0097..d4c2d8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,14 +10,11 @@ - Frozen exit codes (see README), mapped from the error category; `X-DevHelm-Client` on every request and an `Idempotency-Key` on every POST. Keys are random per invocation and reused only for retries of that request, so a second identical command is a new request (as before), never a replay. ### Changed -- `deploy` never changes `enabled` (pause is an overlay); `enabled` in devhelm.yml is now an error naming `monitors pause|resume`. -- `--prune` deletes only monitors this project owns. `state pull` pulls only this project's monitors. +- `deploy --prune` also deletes monitors this project owns (`ownerProject == project`) that are absent from the config, in addition to the resources in `.devhelm/state.json`. `--prune-org-cli` and `--prune-all` keep their wider scopes. +- `state pull` pulls only this project's monitors when devhelm.yml sets `project:`; without `project:` it pulls every monitor, as before. - `monitors test` exits 14 when a probe fails. - The owner the CLI writes is `GIT`. -### Removed -- `deploy --prune-org-cli`, `--prune-all`. - ### Deprecated (removed 2027-01-31) - `monitors results` (use `monitors history`). - The bare JSON output of pre-1.12 commands (set `DEVHELM_JSON_ENVELOPE=1` for the wrapper). diff --git a/skills/devhelm-synthetics/SKILL.md b/skills/devhelm-synthetics/SKILL.md index d4274ea..7f57280 100644 --- a/skills/devhelm-synthetics/SKILL.md +++ b/skills/devhelm-synthetics/SKILL.md @@ -174,7 +174,8 @@ monitors: ``` `code` is required for code types; `config.url` is not allowed on them; -`enabled:` is refused (use `devhelm monitors pause|resume`). `include` is +`enabled:` is optional: omit it and deploy leaves the pause state alone +(`devhelm monitors pause|resume`). `include` is only for files nothing imports (fixtures read at runtime). --- diff --git a/src/commands/deploy/index.ts b/src/commands/deploy/index.ts index 54fb3a9..c3ebab9 100644 --- a/src/commands/deploy/index.ts +++ b/src/commands/deploy/index.ts @@ -14,7 +14,6 @@ import { apply, buildStateV2, changesetToJson, formatPlan, processMovedBlocks, readState, resourceAddress, writeState, type ApplyResult, type Change, } from '../../lib/yaml/index.js' -import {removedPruneFlag} from '../plan.js' const MAX_LOCK_TTL_MINUTES = 60 @@ -30,6 +29,8 @@ export default class Deploy extends DevhelmCommand { '<%= config.bin %> deploy', '<%= config.bin %> deploy --yes --lock-timeout 5m', '<%= config.bin %> deploy --env production --prune --yes', + '<%= config.bin %> deploy --prune-org-cli --yes', + '<%= config.bin %> deploy --prune-all --yes', '<%= config.bin %> deploy --adopt "Checkout" --yes', '<%= config.bin %> deploy --acknowledge-rollback "Checkout" --yes', '<%= config.bin %> deploy --dry-run', @@ -42,9 +43,19 @@ export default class Deploy extends DevhelmCommand { file: fileFlag, env: Flags.string({description: 'Only monitors in this environment (slug)'}), 'no-test': Flags.boolean({description: 'Publish code without a validation run (PUT with code; untested)', default: false}), - prune: Flags.boolean({description: 'Delete monitors this project owns that are absent from the config', default: false}), - 'prune-org-cli': Flags.boolean({hidden: true, default: false}), - 'prune-all': Flags.boolean({hidden: true, default: false}), + prune: Flags.boolean({description: 'Delete resources tracked in .devhelm/state.json and monitors this project owns that are absent from the config', default: false}), + 'prune-org-cli': Flags.boolean({ + description: + 'Also delete CLI-managed resources elsewhere in the org that are absent from YAML. ' + + 'Use with caution in shared workspaces; widens --prune to the legacy org-wide CLI scope.', + default: false, + }), + 'prune-all': Flags.boolean({ + description: + 'Delete ALL resources not in YAML, including dashboard- and Terraform-managed ones. ' + + 'The most destructive option; intended for single-tenant workspaces or scripted teardowns.', + default: false, + }), adopt: Flags.string({description: 'Take over an existing monitor of that name (transfer to Git, this project); repeatable', multiple: true}), 'acknowledge-rollback': Flags.string({description: 'Re-publish over an emergency rollback of this monitor, for this run only; repeatable', multiple: true}), 'lock-timeout': Flags.string({description: 'How long to wait for a held deploy lock (e.g. 5m; 0 = fail at once)', default: '0'}), @@ -59,8 +70,6 @@ export default class Deploy extends DevhelmCommand { async run() { const {flags} = await this.parse(Deploy) - if (flags['prune-org-cli']) removedPruneFlag('--prune-org-cli') - if (flags['prune-all']) removedPruneFlag('--prune-all') const timeoutMs = parseDurationMs(flags.timeout, 'timeout') const lockTimeoutMs = parseDurationMs(flags['lock-timeout'], 'lock-timeout') const deadline = Date.now() + timeoutMs @@ -68,7 +77,14 @@ export default class Deploy extends DevhelmCommand { const files = configFiles(undefined, flags.file) if (!this.structured) this.progress('Fetching current state from API...') - const p = await runPipeline(client, {files, prune: flags.prune, env: flags.env, adopt: flags.adopt, details: true, applyMoved: false}) + const p = await runPipeline(client, { + files, + // The wider scopes imply --prune. + prune: flags.prune || flags['prune-org-cli'] || flags['prune-all'], + pruneOrgCli: flags['prune-org-cli'] || flags['prune-all'], + pruneAll: flags['prune-all'], + env: flags.env, + adopt: flags.adopt, details: true, applyMoved: false}) await this.loadMeta({org: p.config.org, workspace: p.config.workspace}) for (const w of p.warnings) this.addWarning(w) const changes = totalChanges(p.changeset) + p.bind.adopt.length diff --git a/src/commands/monitors/pause.ts b/src/commands/monitors/pause.ts index f4fe582..5f6158a 100644 --- a/src/commands/monitors/pause.ts +++ b/src/commands/monitors/pause.ts @@ -6,7 +6,7 @@ import {resolveMonitor} from '../../lib/monitors/resolve.js' export default class MonitorsPause extends DevhelmCommand { static legacyOutput = true - static description = 'Pause a monitor (an overlay: deploy never clears it)' + static description = 'Pause a monitor (deploy keeps it paused unless devhelm.yml sets `enabled`)' static examples = [ '<%= config.bin %> monitors pause Checkout --env production --reason "provider maintenance"', '<%= config.bin %> monitors pause 7f3c… --until 2h', diff --git a/src/commands/plan.ts b/src/commands/plan.ts index fa9579f..71930cf 100644 --- a/src/commands/plan.ts +++ b/src/commands/plan.ts @@ -16,6 +16,7 @@ export default class Plan extends DevhelmCommand { '<%= config.bin %> plan', '<%= config.bin %> plan --env production', '<%= config.bin %> plan --prune', + '<%= config.bin %> plan --prune-all', '<%= config.bin %> plan --detailed-exitcode', '<%= config.bin %> plan -o json', ] @@ -25,18 +26,20 @@ export default class Plan extends DevhelmCommand { output: projectOutputFlag, file: fileFlag, env: Flags.string({description: 'Only monitors in this environment (slug)'}), - prune: Flags.boolean({description: 'Include deletions of monitors this project owns that are absent from the config', default: false}), - 'prune-all': Flags.boolean({hidden: true, description: 'Removed: deleted resources other projects own', default: false}), + prune: Flags.boolean({description: 'Include deletions of resources tracked in .devhelm/state.json and monitors this project owns that are absent from the config', default: false}), + 'prune-all': Flags.boolean({ + description: 'Include deletions of ALL resources not in config, including those not managed by the CLI', + default: false, + }), 'detailed-exitcode': Flags.boolean({description: 'Exit 10 when the plan has changes (for CI)', default: false}), } async run() { const {flags} = await this.parse(Plan) - if (flags['prune-all']) removedPruneFlag('--prune-all') const client = this.client() const files = configFiles(undefined, flags.file) if (!this.structured) this.progress('Fetching current state from API...') - const p = await runPipeline(client, {files, prune: flags.prune, env: flags.env, details: true, applyMoved: false}) + const p = await runPipeline(client, {files, prune: flags.prune || flags['prune-all'], pruneAll: flags['prune-all'], env: flags.env, details: true, applyMoved: false}) await this.loadMeta({org: p.config.org, workspace: p.config.workspace}) for (const w of p.warnings) this.addWarning(w) for (const u of p.bind.unowned) this.addWarning(`${u.name} exists (${u.owner}) but this project does not own it; deploy needs --adopt "${u.name}"`) @@ -75,9 +78,3 @@ export default class Plan extends DevhelmCommand { }) } } - -export function removedPruneFlag(flag: string): never { - throw new (class extends Error { - oclif = {exit: EXIT_CODES.VALIDATION} - })(`${flag} was removed: its deletion scope crossed teams. --prune deletes only monitors this project owns (ownerProject == project).`) -} diff --git a/src/commands/state/pull.ts b/src/commands/state/pull.ts index 8337d07..8704e9c 100644 --- a/src/commands/state/pull.ts +++ b/src/commands/state/pull.ts @@ -15,8 +15,18 @@ import {isGitOwner} from '../../lib/yaml/project-context.js' type Schemas = components['schemas'] +/** + * Whether `state pull` records this monitor. With a `project:` key only + * the monitors this project owns (ownerProject == project); without one, + * every monitor (the 1.11 behaviour). + */ +export function pullsMonitor(raw: Schemas['MonitorDto'], project: string | undefined): boolean { + if (!project) return true + return isGitOwner(raw.owner?.type ?? raw.managedBy) && raw.owner?.project === project +} + export default class StatePull extends Command { - static description = 'Reconstruct the state file from the API: monitors this project owns, plus the other resources' + static description = 'Reconstruct the state file from the API (monitors are scoped to `project:` when the config sets it)' static examples = [ '<%= config.bin %> state pull', @@ -59,15 +69,14 @@ export default class StatePull extends Command { children?: Record }> = [] - // Monitors: only the ones this project owns (ownerProject == project). - // Pulling the whole org would turn `deploy --prune` into prune-all. + // Monitors: with `project:`, only the ones this project owns, so + // `deploy --prune` stays within the project. Without it, all of them. const project = existsSync(flags.file) ? loadProject([flags.file]).config?.project : undefined - if (!project) this.warn(`No "project:" in ${flags.file}; no monitors are pulled (they are scoped to a project)`) for (const handler of handlers) { for (const entry of refs.allEntries(handler.refType)) { if (handler.resourceType === 'monitor') { const raw = entry.raw as Schemas['MonitorDto'] - if (!project || !isGitOwner(raw.owner?.type ?? raw.managedBy) || raw.owner?.project !== project) continue + if (!pullsMonitor(raw, project)) continue } let children: Record | undefined if (handler.resourceType === 'statusPage') { diff --git a/src/lib/monitors/export.ts b/src/lib/monitors/export.ts index 00fd67b..2cec8fd 100644 --- a/src/lib/monitors/export.ts +++ b/src/lib/monitors/export.ts @@ -54,7 +54,7 @@ export function yamlEntryFor(m: MonitorDto, opts: {codePath?: string | {entrypoi /** Fields set on the monitor that the YAML entry does not carry. */ export function fidelityReport(m: MonitorDto): string[] { const out: string[] = [] - if (m.pausedAt || m.enabled === false) out.push('paused: an overlay, set with `devhelm monitors pause|resume`') + if (m.pausedAt || m.enabled === false) out.push('paused: not exported; set with `devhelm monitors pause|resume` or `enabled: false`') if (m.muted) out.push('muted: an overlay, set with `devhelm monitors mute|unmute`') if (m.quarantineUntil) out.push('quarantined: an overlay, set with `devhelm monitors quarantine|release-quarantine`') if (m.auth) out.push('auth: secret-backed auth is not exported; re-declare it with a secret reference') diff --git a/src/lib/project/pipeline.ts b/src/lib/project/pipeline.ts index c5888b4..0781ac2 100644 --- a/src/lib/project/pipeline.ts +++ b/src/lib/project/pipeline.ts @@ -20,6 +20,10 @@ import {formatIssue, loadProject, prepareCodeMonitors, type LoadedProject} from export interface PipelineOptions { files: string[] prune: boolean + /** `--prune-org-cli`: also delete CLI/Git-owned resources outside this config. */ + pruneOrgCli?: boolean + /** `--prune-all`: also delete resources other tools own. */ + pruneAll?: boolean adopt?: string[] env?: string /** Fetch changed file names and drift (plan output). */ @@ -86,7 +90,7 @@ export async function runPipeline(client: ApiClient, opts: PipelineOptions): Pro if (planRefs.errors.length > 0) throw issuesError('Validation failed', planRefs.errors.map((e) => `${e.path}: ${e.message}`), 'CONFIG_INVALID') const children = await prefetchChildSnapshots(config, refs, client) - let changeset = await diff(config, refs, {prune: opts.prune}, state, children) + let changeset = await diff(config, refs, {prune: opts.prune, pruneOrgCli: opts.pruneOrgCli, pruneAll: opts.pruneAll}, state, children) if (opts.env) { changeset = { ...changeset, diff --git a/src/lib/yaml/differ.ts b/src/lib/yaml/differ.ts index 81fb91b..79f7bfc 100644 --- a/src/lib/yaml/differ.ts +++ b/src/lib/yaml/differ.ts @@ -218,7 +218,6 @@ function diffSection( const address = resourceAddress(handler.resourceType as ResourceType, entry.refKey) const scope = classifyDeleteScope(handler, entry, stateAddresses, address, refs) - if (!scope) continue // Decide whether to include this delete based on the requested // prune flag(s). State-scoped deletes are the safe default and @@ -248,19 +247,22 @@ function classifyDeleteScope( stateAddresses: ReadonlySet, address: string, refs: ResolvedRefs, -): PruneScope | undefined { +): PruneScope { if (stateAddresses.has(address)) return 'state' - // Monitors are pruned only when this project owns them: Git-owned with - // `ownerProject == project` (doc 42 §6.3). Nothing else is ever a prune - // candidate — `--prune-org-cli` / `--prune-all` were removed because - // their deletion scope crossed teams. + // Monitors: this project's own (Git-owned, `ownerProject == project`, + // doc 42 §6.3) are covered by plain `--prune`. Other CLI/Git-owned + // monitors need `--prune-org-cli`; monitors other tools own (dashboard, + // Terraform, MCP, API) need `--prune-all`. if (handler.resourceType === 'monitor') { const project = refs.project.project const raw = entry.raw as {owner?: {type?: string; project?: string | null} | null; managedBy?: string} | undefined const type = raw?.owner?.type ?? raw?.managedBy ?? entry.managedBy ?? undefined if (project && isGitOwner(type) && raw?.owner?.project === project) return 'state' + return isGitOwner(type) ? 'org-cli' : 'org-all' } - return undefined + // Other resource types carry no owner on the API: `--prune-org-cli` + // deletes anything absent from YAML, `--prune-all` likewise. + return 'org-cli' } function memberKey(memberType: string, nameOrSlug: string): string { diff --git a/src/lib/yaml/handlers.ts b/src/lib/yaml/handlers.ts index f95e67d..db3ce43 100644 --- a/src/lib/yaml/handlers.ts +++ b/src/lib/yaml/handlers.ts @@ -707,11 +707,11 @@ const resourceGroupHandler = defineHandler> type MonitorSnapshot = Omit & { @@ -745,6 +745,7 @@ function monitorDesiredSnapshot(yaml: YamlMonitor, api: Schemas['MonitorDto'], r name: yaml.name, config: desiredMonitorConfig(yaml, api), frequencySeconds: yaml.frequencySeconds ?? api.frequencySeconds ?? null, + enabled: yaml.enabled ?? api.enabled ?? null, regions: yaml.regions !== undefined ? sortedIds(yaml.regions) : (api.regions ? sortedIds(api.regions) : null), @@ -792,6 +793,7 @@ function monitorCurrentSnapshot(api: Schemas['MonitorDto']): MonitorSnapshot { name: api.name ?? null, config: stripNullish(api.config) as MonitorSnapshot['config'], frequencySeconds: api.frequencySeconds ?? null, + enabled: api.enabled ?? null, regions: api.regions ? sortedIds(api.regions) : null, environmentId: api.environment?.id ?? null, assertions: apiAssertionsToSnapshot(api.assertions), diff --git a/src/lib/yaml/parser.ts b/src/lib/yaml/parser.ts index 5d979b8..6156848 100644 --- a/src/lib/yaml/parser.ts +++ b/src/lib/yaml/parser.ts @@ -182,10 +182,11 @@ function applyDefaults(config: DevhelmConfig): DevhelmConfig { function applyMonitorDefaults(monitor: YamlMonitor, defaults: YamlMonitorDefaults, runtimeId: string | undefined): YamlMonitor { const out: YamlMonitor = {...monitor} - const fill = (key: K) => { + const fill = (key: K) => { if (out[key] === undefined && defaults[key] !== undefined) out[key] = defaults[key] as YamlMonitor[K] } fill('frequencySeconds') + fill('enabled') fill('regions') fill('alertChannels') fill('incidentPolicy') diff --git a/src/lib/yaml/schema.ts b/src/lib/yaml/schema.ts index d36302b..a5157a4 100644 --- a/src/lib/yaml/schema.ts +++ b/src/lib/yaml/schema.ts @@ -441,6 +441,8 @@ export interface YamlMonitor { key?: string session?: YamlSession frequencySeconds?: number + /** Omitted = deploy leaves the current pause state alone. */ + enabled?: boolean regions?: string[] runParallel?: boolean fastRetryMaxAttempts?: number @@ -546,6 +548,7 @@ export interface YamlStatusPage { export interface YamlMonitorDefaults { frequencySeconds?: number + enabled?: boolean regions?: string[] alertChannels?: string[] incidentPolicy?: YamlIncidentPolicy diff --git a/src/lib/yaml/transform.ts b/src/lib/yaml/transform.ts index fc5ca13..951d4f4 100644 --- a/src/lib/yaml/transform.ts +++ b/src/lib/yaml/transform.ts @@ -212,6 +212,7 @@ export function toCreateMonitorRequest( body.ownerDeclaration = {address: ownerAddress(ctx.project, monitor.key ?? monitor.name) ?? null, label: monitor.name} } if (monitor.frequencySeconds !== undefined) body.frequencySeconds = monitor.frequencySeconds + if (monitor.enabled !== undefined) body.enabled = monitor.enabled if (monitor.regions !== undefined) body.regions = monitor.regions if (alertChannelIds) body.alertChannelIds = alertChannelIds if (monitor.tags) body.tags = tagsRequest(monitor.tags, refs) @@ -233,13 +234,14 @@ export function toCreateMonitorRequest( alertChannelIds, tags: monitor.tags ? tagsRequest(monitor.tags, refs) : undefined, } + if (monitor.enabled !== undefined) body.enabled = monitor.enabled if (ctx.project) body.ownerProject = ctx.project return body } /** - * Settings update. `enabled` is never sent: pause is an overlay deploy - * leaves alone (F14). For code monitors `managedBy` is never sent (code + * Settings update. `enabled` is sent only when the YAML sets it (null + * preserves the current pause state). For code monitors `managedBy` is never sent (code * monitors change owner through transfer) and the code half travels * separately (draft → test → publish), unless `--no-test` puts it on this * call (`withCode`). @@ -263,6 +265,7 @@ export function toUpdateMonitorRequest( const config = toMonitorConfig(monitor) if (config !== undefined) body.config = config as Schemas['UpdateMonitorRequest']['config'] if (monitor.frequencySeconds !== undefined) body.frequencySeconds = monitor.frequencySeconds + if (monitor.enabled !== undefined) body.enabled = monitor.enabled if (monitor.regions !== undefined) body.regions = monitor.regions if (monitor.environment) body.environmentId = refs.resolve('environments', monitor.environment) ?? null if (monitor.alertChannels) body.alertChannelIds = monitor.alertChannels.map((n) => refs.require('alertChannels', n)) @@ -285,6 +288,7 @@ export function toUpdateMonitorRequest( config: toMonitorConfig(monitor) as Schemas['UpdateMonitorRequest']['config'], managedBy: OWNER_TOOL, frequencySeconds: monitor.frequencySeconds ?? null, + enabled: monitor.enabled ?? null, regions: monitor.regions ?? null, environmentId: monitor.environment ? refs.resolve('environments', monitor.environment) ?? null diff --git a/src/lib/yaml/zod-schemas.ts b/src/lib/yaml/zod-schemas.ts index b3df2c1..cb11f80 100644 --- a/src/lib/yaml/zod-schemas.ts +++ b/src/lib/yaml/zod-schemas.ts @@ -394,11 +394,6 @@ const ResourceGroupSchema = z.object({ }).strict() -/** `enabled` was removed from declarative monitors (F14): pause is an overlay. */ -const ENABLED_REMOVED = - '"enabled" is no longer part of devhelm.yml: pausing is an overlay that deploy never changes. ' + - 'Remove the key and use `devhelm monitors pause ` / `devhelm monitors resume `' - const CodeSchema = z.union([ z.string().min(1), z.object({ @@ -417,7 +412,7 @@ const MonitorSchema = z.object({ key: z.string().min(1).max(200).regex(/^[A-Za-z0-9._-]+$/, 'must contain only letters, digits, ".", "_" and "-"').optional(), session: SessionSchema.optional(), frequencySeconds: z.number().int().min(MIN_FREQUENCY).max(MAX_FREQUENCY).optional(), - enabled: z.unknown().optional(), + enabled: z.boolean().optional(), regions: z.array(z.string()).optional(), runParallel: z.boolean().optional(), fastRetryMaxAttempts: z.number().int().min(0).max(10).optional(), @@ -430,9 +425,6 @@ const MonitorSchema = z.object({ auth: AuthSchema.nullable().optional(), incidentPolicy: IncidentPolicySchema.optional(), }).strict().superRefine((data, ctx) => { - if (data.enabled !== undefined) { - ctx.addIssue({code: z.ZodIssueCode.custom, path: ['enabled'], message: ENABLED_REMOVED}) - } const isCode = isCodeMonitorType(data.type) if (isCode) { if (data.code === undefined) { @@ -533,15 +525,11 @@ const StatusPageSchema = z.object({ const MonitorDefaultsSchema = z.object({ frequencySeconds: z.number().int().min(MIN_FREQUENCY).max(MAX_FREQUENCY).optional(), - enabled: z.unknown().optional(), + enabled: z.boolean().optional(), regions: z.array(z.string()).optional(), alertChannels: z.array(z.string()).optional(), incidentPolicy: IncidentPolicySchema.optional(), -}).strict().superRefine((data, ctx) => { - if (data.enabled !== undefined) { - ctx.addIssue({code: z.ZodIssueCode.custom, path: ['enabled'], message: ENABLED_REMOVED}) - } -}) +}).strict() const DefaultsSchema = z.object({ monitors: MonitorDefaultsSchema.optional(), diff --git a/test/commands/state-pull.test.ts b/test/commands/state-pull.test.ts new file mode 100644 index 0000000..985a59e --- /dev/null +++ b/test/commands/state-pull.test.ts @@ -0,0 +1,20 @@ +import {describe, it, expect} from 'vitest' +import {pullsMonitor} from '../../src/commands/state/pull.js' + +describe('state pull monitor scope', () => { + const mine = {owner: {type: 'GIT', project: 'acme/web'}, managedBy: 'GIT'} as never + const theirs = {owner: {type: 'GIT', project: 'acme/api'}, managedBy: 'GIT'} as never + const dashboard = {managedBy: 'DASHBOARD'} as never + + it('pulls only this project\'s monitors when project: is set', () => { + expect(pullsMonitor(mine, 'acme/web')).toBe(true) + expect(pullsMonitor(theirs, 'acme/web')).toBe(false) + expect(pullsMonitor(dashboard, 'acme/web')).toBe(false) + }) + + it('pulls every monitor when project: is absent', () => { + expect(pullsMonitor(mine, undefined)).toBe(true) + expect(pullsMonitor(theirs, undefined)).toBe(true) + expect(pullsMonitor(dashboard, undefined)).toBe(true) + }) +}) diff --git a/test/yaml/differ.test.ts b/test/yaml/differ.test.ts index 65d3b7a..b177800 100644 --- a/test/yaml/differ.test.ts +++ b/test/yaml/differ.test.ts @@ -236,14 +236,26 @@ describe('differ', () => { expect(changeset.deletes).toHaveLength(0) }) - it('the removed pruneAll scope no longer deletes foreign monitors', async () => { + it('pruneAll deletes foreign monitors and monitors with no owner', async () => { const refs = new ResolvedRefs() refs.set('monitors', 'dashboard-monitor', { id: 'mon-1', refKey: 'dashboard-monitor', managedBy: 'DASHBOARD', raw: {managedBy: 'DASHBOARD'}, }) refs.set('monitors', 'orphan', {id: 'mon-2', refKey: 'orphan', raw: {}}) const changeset = await diff({monitors: []}, refs, {prune: true, pruneAll: true}) - expect(changeset.deletes).toHaveLength(0) + expect(changeset.deletes.map((d) => [d.refKey, d.pruneScope])).toEqual([['dashboard-monitor', 'org-all'], ['orphan', 'org-all']]) + }) + + it('pruneOrgCli deletes CLI/Git-owned monitors of other projects but not foreign ones', async () => { + const refs = new ResolvedRefs() + refs.project = createProjectContext({project: 'acme/web'}) + refs.set('monitors', 'other', { + id: 'mon-1', refKey: 'other', managedBy: 'GIT', raw: {managedBy: 'GIT', owner: {type: 'GIT', project: 'acme/api'}}, + }) + refs.set('monitors', 'legacy', {id: 'mon-2', refKey: 'legacy', managedBy: 'CLI', raw: {managedBy: 'CLI'}}) + refs.set('monitors', 'dash', {id: 'mon-3', refKey: 'dash', managedBy: 'DASHBOARD', raw: {managedBy: 'DASHBOARD'}}) + const changeset = await diff({monitors: []}, refs, {prune: true, pruneOrgCli: true}) + expect(changeset.deletes.map((d) => [d.refKey, d.pruneScope])).toEqual([['other', 'org-cli'], ['legacy', 'org-cli']]) }) it('prune: omitted section (undefined) does not delete', async () => { @@ -254,11 +266,11 @@ describe('differ', () => { expect(changeset.deletes).toHaveLength(0) }) - it('tags outside state are never pruned, even with the removed pruneOrgCli', async () => { + it('pruneOrgCli deletes tags outside state', async () => { const refs = new ResolvedRefs() refs.set('tags', 'T', {id: '1', refKey: 'T', raw: {name: 'T'}}) const changeset = await diff({tags: []}, refs, {pruneOrgCli: true}) - expect(changeset.deletes).toHaveLength(0) + expect(changeset.deletes.map((d) => [d.refKey, d.pruneScope])).toEqual([['T', 'org-cli']]) }) it('does not delete without prune flag', async () => { @@ -307,7 +319,7 @@ describe('differ', () => { expect(cs.deletes).toHaveLength(0) }) - it('reports only state-tracked deletes', async () => { + it('tags each delete with its prune scope', async () => { const refs = new ResolvedRefs() refs.set('monitors', 'mine', { id: 'mon-1', refKey: 'mine', managedBy: 'CLI', raw: {managedBy: 'CLI'}, @@ -317,7 +329,7 @@ describe('differ', () => { }) const cs = await diff({monitors: []}, refs, {pruneOrgCli: true}, stateWith(['monitors.mine'])) const byKey = Object.fromEntries(cs.deletes.map((d) => [d.refKey, d.pruneScope])) - expect(byKey).toEqual({mine: 'state'}) + expect(byKey).toEqual({mine: 'state', theirs: 'org-cli'}) }) it('non-monitor resources outside state are NOT touched under plain --prune', async () => { @@ -646,7 +658,7 @@ describe('differ', () => { expect(changeset.updates[0].refKey).toBe('changed') }) - it('never plans an update for a paused monitor (pause is an overlay, F14)', async () => { + it('does not plan an update for a paused monitor when YAML omits enabled', async () => { const refs = new ResolvedRefs() refs.set('monitors', 'M', {id: 'mon-1', refKey: 'M', raw: { name: 'M', type: 'HTTP', enabled: false, @@ -662,6 +674,20 @@ describe('differ', () => { expect(changeset.updates).toHaveLength(0) }) + it('plans an update when YAML sets enabled to a different value', async () => { + const refs = new ResolvedRefs() + refs.set('monitors', 'M', {id: 'mon-1', refKey: 'M', raw: { + name: 'M', type: 'HTTP', enabled: false, + config: {url: 'https://x.com', method: 'GET'}, + }}) + const config: DevhelmConfig = { + monitors: [{name: 'M', type: 'HTTP', enabled: true, config: {url: 'https://x.com', method: 'GET'}}], + } + const changeset = await diff(config, refs) + expect(changeset.updates).toHaveLength(1) + expect(changeset.updates[0].attributeDiffs?.map((d) => d.field)).toContain('enabled') + }) + it('detects update when environment variables change', async () => { const refs = new ResolvedRefs() refs.set('environments', 'prod', {id: 'env-1', refKey: 'prod', raw: { diff --git a/test/yaml/transform.test.ts b/test/yaml/transform.test.ts index 3ee21cf..da6774e 100644 --- a/test/yaml/transform.test.ts +++ b/test/yaml/transform.test.ts @@ -301,6 +301,12 @@ describe('transforms', () => { }) describe('toCreateMonitorRequest', () => { + it('sends enabled only when the YAML sets it', () => { + const monitor: YamlMonitor = {name: 'M', type: 'HTTP', config: {url: 'https://x.com', method: 'GET'}} + expect('enabled' in toCreateMonitorRequest(monitor, emptyRefs())).toBe(false) + expect(toCreateMonitorRequest({...monitor, enabled: false}, emptyRefs()).enabled).toBe(false) + }) + it('transforms HTTP monitor', () => { const refs = refsWithChannels() const monitor: YamlMonitor = { @@ -419,6 +425,12 @@ describe('transforms', () => { }) describe('toUpdateMonitorRequest', () => { + it('sends enabled when set and null (preserve) when absent', () => { + const monitor: YamlMonitor = {name: 'M', type: 'HTTP', config: {url: 'https://x.com', method: 'GET'}} + expect(toUpdateMonitorRequest(monitor, emptyRefs()).enabled).toBeNull() + expect(toUpdateMonitorRequest({...monitor, enabled: true}, emptyRefs()).enabled).toBe(true) + }) + it('includes managedBy GIT (R13)', () => { const refs = emptyRefs() const monitor: YamlMonitor = { diff --git a/test/yaml/zod-schemas.test.ts b/test/yaml/zod-schemas.test.ts index 7e790f4..5c5fe20 100644 --- a/test/yaml/zod-schemas.test.ts +++ b/test/yaml/zod-schemas.test.ts @@ -22,10 +22,17 @@ describe('DevhelmConfigSchema', () => { expect(result.success).toBe(true) }) - it('refuses enabled in defaults.monitors and names the verbs', () => { - const result = DevhelmConfigSchema.safeParse({defaults: {monitors: {enabled: true}}}) + it('accepts enabled in defaults.monitors and on a monitor', () => { + const result = DevhelmConfigSchema.safeParse({ + defaults: {monitors: {enabled: true}}, + monitors: [{name: 'API', type: 'HTTP', enabled: false, config: {url: 'https://example.com', method: 'GET'}}], + }) + expect(result.success).toBe(true) + }) + + it('refuses a non-boolean enabled', () => { + const result = DevhelmConfigSchema.safeParse({defaults: {monitors: {enabled: 'yes'}}}) expect(result.success).toBe(false) - expect(JSON.stringify(result.error?.issues)).toContain('devhelm monitors pause') }) }) From e02d5dd750d62d89803f7a9a5200c5c12eac9380 Mon Sep 17 00:00:00 2001 From: caballeto Date: Mon, 26 Oct 2026 14:19:25 +0100 Subject: [PATCH 2/3] fix: say deploy changes pause only when enabled is set Co-Authored-By: Claude Opus 5.5 --- src/lib/project/pipeline.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/project/pipeline.ts b/src/lib/project/pipeline.ts index 0781ac2..b0b7c1d 100644 --- a/src/lib/project/pipeline.ts +++ b/src/lib/project/pipeline.ts @@ -139,7 +139,7 @@ export function codePlanLines(plans: CodeMonitorPlan[]): string[] { } if (p.action === 'update' && p.settingsChanged) what.push('settings') if (p.owner) what.push(`owner ${p.owner}`) - if (p.overlays.length > 0) what.push(`${p.overlays.join(', ')} (not changed by deploy)`) + if (p.overlays.length > 0) what.push(`${p.overlays.join(', ')} (deploy leaves these unless devhelm.yml sets enabled)`) out.push(` ${sym} ${p.name} ${what.join(' · ')}`) if (p.drift) { out.push(` ! live code ${p.drift.liveDigest.slice(0, 19)}… differs from the last Git deploy ${p.drift.gitDigest.slice(0, 19)}…${p.republishesRolledBack ? ` (rolled back); deploy refuses unless --acknowledge-rollback "${p.name}"` : ''}`) From fed8f69ea55bbfad5d1d6ee76144bb4988c3a8be Mon Sep 17 00:00:00 2001 From: caballeto Date: Mon, 26 Oct 2026 14:23:30 +0100 Subject: [PATCH 3/3] test: --prune-all is accepted again Co-Authored-By: Claude Opus 5.5 --- test/commands/project-deploy.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/commands/project-deploy.test.ts b/test/commands/project-deploy.test.ts index 7adbf62..edd489e 100644 --- a/test/commands/project-deploy.test.ts +++ b/test/commands/project-deploy.test.ts @@ -178,8 +178,8 @@ describe('devhelm deploy (code monitors)', () => { expect(files).toEqual(['synthetics/checkout.spec.ts', 'synthetics/fixtures/cart.json']) }, 90_000) - test('--prune-all is refused (removed)', async () => { + test('--prune-all is accepted, as on 1.11', async () => { await start([]) - expect((await runCli(['deploy', '--prune-all', '--yes'], {api, cwd: dir})).code).toBe(4) + expect((await runCli(['deploy', '--prune-all', '--yes'], {api, cwd: dir})).code).toBe(0) }, 90_000) })