Skip to content
cdkd

Destroy data guards: non-empty S3 buckets and image-carrying ECR repositories

cdkd destroy (and cdkd state destroy) matches CloudFormation's fail-and-protect behavior for the two resource types whose delete API refuses by default while they still hold data:

Resource type Without an opt-in With the opt-in
AWS::S3::Bucket A non-empty bucket fails the destroy with an actionable "bucket is not empty" error (CloudFormation: DELETE_FAILED; Terraform: requires force_destroy). The bucket and every object survive. CDK autoDeleteObjects: true (the aws-cdk:auto-delete-objects tag) — cdkd auto-empties all object versions + delete markers before DeleteBucket, which also absorbs the race where objects (e.g. ALB access logs) land between the auto-delete custom resource's cleanup and the bucket deletion.
AWS::ECR::Repository A repository that still contains images fails the destroy with an actionable "still contains images" error (CloudFormation: DELETE_FAILED unless EmptyOnDelete: true; Terraform: requires force_delete). The repository and images survive. CDK emptyOnDelete: true (EmptyOnDelete: true in the template) or the legacy autoDeleteImages (aws-cdk:auto-delete-images tag) — cdkd deletes with force: true.
AWS::S3Express::DirectoryBucket A non-empty directory bucket fails the destroy with an actionable "is not empty" error (CloudFormation: DELETE_FAILED, live-A/B-verified 2026-08-03). The bucket and objects survive. Add the aws-cdk:auto-delete-objects tag with value true to the bucket's Tags (a handled property; CDK has no autoDeleteObjects sugar for directory buckets, so declare the tag explicitly on the L1). The opted-in auto-empty absorbs concurrent-write races with the same bounded empty-retry loop as the standard bucket. Alternatively empty the bucket manually (aws s3 rm s3://<bucket> --recursive — directory buckets have no versioning) and destroy again. --force-stateful-recreation replacement deletes are authorized as for the other types.

To destroy anyway without redeploying, empty the data first (aws s3 rm s3://<bucket> --recursive — for versioned buckets delete all object versions and delete markers; aws ecr batch-delete-image) and re-run the destroy.

Replacement deletes during cdkd deploy are governed by the existing stateful-recreation consent instead: passing --force-stateful-recreation (the flag whose documented meaning is "I accept a data-losing recreation") also authorizes the force-cleanup on the replaced resource's delete.

Related parity notes, verified against real CloudFormation (2026-08-02): CFn itself hard-deletes AWS::SecretsManager::Secret (no recovery window) and force-detaches out-of-band IAM role policy attachments on delete — cdkd's identical behavior for those types is parity, not a divergence.

DeletionPolicy: Snapshot: final snapshots on delete (--skip-final-snapshot)

CloudFormation creates a final snapshot before deleting a resource whose DeletionPolicy is Snapshot — and the CDK RDS L2 (DatabaseInstance / DatabaseCluster) defaults removalPolicy to SNAPSHOT, so plain CDK database stacks rely on it. cdkd matches this on every delete path: cdkd destroy, cdkd state destroy, the cdkd deploy DELETE of a resource removed from the template, the rollback of a CREATE (automatic after a failed deploy, or cdkd rollback), and — for UpdateReplacePolicy: Snapshot — the deploy engine's replacement / recreate deletes of the OLD resource.

Resource type How the final snapshot is created
AWS::RDS::DBInstance DeleteDBInstance(SkipFinalSnapshot=false, FinalDBSnapshotIdentifier=<generated>). CFn nuance matched: an instance that is a cluster member (DBClusterIdentifier set) is deleted without an instance-level snapshot — cluster-level snapshots cover it.
AWS::RDS::DBCluster DeleteDBCluster(SkipFinalSnapshot=false, FinalDBSnapshotIdentifier=<generated>)
AWS::Neptune::DBCluster DeleteDBCluster(...FinalDBSnapshotIdentifier) (Neptune SDK)
AWS::DocDB::DBCluster DeleteDBCluster(...FinalDBSnapshotIdentifier) (DocDB SDK)
AWS::ElastiCache::CacheCluster DeleteCacheCluster(FinalSnapshotIdentifier=<generated>) — Redis engine only; a Memcached cluster under Snapshot surfaces AWS's rejection, matching CFn's DELETE_FAILED
AWS::EC2::Volume Pre-delete CreateSnapshot (tagged cdkd:final-snapshot-of: <volumeId>), waited to completed, then the normal delete — the type is Cloud-Control-routed and DeleteVolume has no snapshot parameter. Idempotent: a destroy re-run reuses the tagged snapshot instead of creating a second one.
AWS::Redshift::Cluster Pre-delete CreateClusterSnapshot (<clusterId>-final-<ts>), waited to available, then a bounded wait for the CLUSTER itself to settle (the fresh snapshot leaves it busy and the delete would otherwise 400 with "There is an operation running on the Cluster"), then the CC-routed delete.
AWS::ElastiCache::ReplicationGroup Pre-delete ElastiCache CreateSnapshot, waited to available, then the CC-routed delete. The snapshot source depends on cluster mode: a cluster-mode-ENABLED (sharded) group is snapshotted by ReplicationGroupId, while the cluster-mode-DISABLED default must name its PRIMARY member cache cluster instead (AWS rejects the group form with "Please specify a cache cluster instead") — cdkd resolves this automatically. Redis only; Memcached / snapshot-incapable node types surface AWS's rejection, matching CFn's DELETE_FAILED.

Generated snapshot identifiers are deterministic and logged: <physicalId>-final-<utcTimestamp> (sanitized to the snapshot-identifier character rules).

--skip-final-snapshot (on cdkd deploy, cdkd destroy, cdkd state destroy, and cdkd rollback) is the explicit opt-out: delete WITHOUT the final snapshot (data loss — useful for dev/test stacks where the snapshot cost/latency is unwanted, and the escape hatch for the cc-api-routed refusal below).

Notes:

  • The recorded state.deletionPolicy (schema v5+) is what the destroy paths consult; pre-v5 state keeps the legacy plain-delete behavior until a redeploy records the attribute (cdkd destroy also falls back to the synth template's DeletionPolicy for pre-v5 state).
  • A Cloud-Control-routed resource of an atomic-parameter type (state records provisionedBy: cc-api — the silent-drop auto-routing) is refused: Cloud Control's DeleteResource has no final-snapshot parameter, so cdkd cannot honor the policy on that route. Snapshot manually, then re-run with --skip-final-snapshot.
  • UpdateReplacePolicy: Snapshot is honored on the deploy engine's replacement / recreate delete sites with the same mechanism matrix; the --force-stateful-recreation stateful guard still applies first where the replacement is data-losing. Failure handling differs by site, deliberately: the delete-first / recreate paths surface a snapshot failure as a resource failure (their delete is load-bearing for the re-create), while the post-replacement CLEANUP delete keeps its warn-and-continue policy for a TRANSIENT snapshot failure — it skips the delete, so the old resource is leaked with a warning rather than deleted un-snapshotted. A REFUSAL (a type / route cdkd cannot snapshot) always fails the resource, matching CloudFormation failing the update.
  • Rolling back a COMPLETED CREATE (the automatic rollback after a failed deploy, or cdkd rollback) IS a delete, so DeletionPolicy: Snapshot applies to it: cdkd creates the final snapshot and then deletes, using the same mechanism matrix as the table above, and REFUSES (counting the op as a rollback failure, so the journal is kept for a re-run) any shape it cannot snapshot — pass --skip-final-snapshot to cdkd rollback to delete without it. Previously such a resource was ORPHANED — dropped from state and left running in AWS — which silently handed you an untracked, billing resource. DeletionPolicy: Retain still orphans (that is what the policy asks for).
  • cdkd rollback --revert-failed's delete of a resource whose CREATE FAILED mid-flight applies the SAME policy matrix: Retain leaves it in AWS, Snapshot snapshots then deletes (refusing what it cannot snapshot), RetainExceptOnCreate / Delete / absent delete plainly. That branch previously read no policy at all. It only engages when AWS actually provisioned the resource — the action requires a recorded physical id AND a matching state record — so the policy is never applied to a resource that never existed. A refusal here is recoverable rather than final: the op stays in the journal, so once a half-created resource settles into a snapshot-capable state (an RDS instance rejects a final-snapshot delete while creating) a re-run completes it, and --skip-final-snapshot is the opt-out if you would rather drop the data.
  • On a ROLLBACK's delete-of-the-NEW-resource (reversing a replacement, i.e. UpdateReplacePolicy), only the atomic SDK-routed types get a final snapshot — the other shapes deliberately keep the plain delete (that delete is load-bearing for same-name re-creation).
  • Snapshot reuse across a re-run resumes only an IN-FLIGHT snapshot for the name-keyed APIs (Redshift / ElastiCache): their identifiers are user-chosen and reusable, so adopting an already-available snapshot could hand you a PREVIOUS generation's data. A re-run after the snapshot completed therefore creates a second (timestamped, non-colliding) one. EC2 Volume reuses a completed snapshot safely — its tag is keyed on an AWS-generated volume id that is never reused.
  • Final snapshots are billed AWS resources that survive the destroy by design — delete them manually when no longer needed.

--remove-protection: bypass deletion protection on destroy

cdkd destroy --remove-protection and cdkd state destroy --remove-protection flip every protection flag off in-place before each provider's delete API call so the destroy proceeds without an intermediate edit / redeploy / console click. Covers stack-level terminationProtection (the bypass logs a WARN line naming the stack — cdkd state destroy already ignores terminationProtection because the flag is a CDK property surfaced via synth, so the flag is effectively a no-op there for that part) AND resource-level protection on the following types:

Resource type Protection field Bypass call
AWS::Logs::LogGroup DeletionProtectionEnabled PutLogGroupDeletionProtection(deletionProtectionEnabled=false)
AWS::RDS::DBInstance DeletionProtection ModifyDBInstance(DeletionProtection=false, ApplyImmediately=true)
AWS::RDS::DBCluster DeletionProtection ModifyDBCluster(DeletionProtection=false, ApplyImmediately=true)
AWS::DocDB::DBCluster DeletionProtection ModifyDBCluster(DeletionProtection=false, ApplyImmediately=true) (DocDB SDK) — DocDB DBInstance has no DeletionProtection field, so no per-instance bypass; cluster-level covers the common case
AWS::Neptune::DBCluster DeletionProtection ModifyDBCluster(DeletionProtection=false, ApplyImmediately=true) (Neptune SDK)
AWS::Neptune::DBInstance DeletionProtection ModifyDBInstance(DeletionProtection=false, ApplyImmediately=true) (Neptune SDK)
AWS::DynamoDB::Table DeletionProtectionEnabled UpdateTable(DeletionProtectionEnabled=false) then DescribeTable poll until ACTIVE
AWS::EC2::Instance DisableApiTermination ModifyInstanceAttribute(DisableApiTermination={Value:false})
AWS::ElasticLoadBalancingV2::LoadBalancer attribute deletion_protection.enabled ModifyLoadBalancerAttributes([{Key: 'deletion_protection.enabled', Value: 'false'}])
AWS::Cognito::UserPool DeletionProtection (ACTIVE / INACTIVE) UpdateUserPool(DeletionProtection='INACTIVE')
AWS::AutoScaling::AutoScalingGroup DeletionProtection (none / prevent-force-deletion / prevent-all-deletion) UpdateAutoScalingGroup(DeletionProtection='none') followed by DeleteAutoScalingGroup(ForceDelete=true) so AWS terminates running instances as part of the delete
AWS::DSQL::Cluster DeletionProtectionEnabled Cloud Control UpdateResource patch ([{op: add, path: /DeletionProtectionEnabled, value: false}]), waited to completion, then DeleteResource — the generic CC-routed protection flip; more CC-routed types with a top-level protection property can join the registry in src/provisioning/cc-protection-properties.ts once live-verified
AWS::NeptuneGraph::Graph DeletionProtection Same generic CC patch flip (value: false) then DeleteResource
AWS::SMSVOICE::ProtectConfiguration DeletionProtectionEnabled Same generic CC patch flip (value: false) then DeleteResource
AWS::VerifiedPermissions::PolicyStore DeletionProtection ({Mode: ENABLED|DISABLED}) Same generic CC patch flip with value: {Mode: DISABLED} then DeleteResource
AWS::EKS::Cluster DeletionProtection Same generic CC patch flip (value: false) then DeleteResource
AWS::RDS::GlobalCluster DeletionProtection Same generic CC patch flip (value: false) then DeleteResource
AWS::DocDB::GlobalCluster DeletionProtection Same generic CC patch flip (value: false) then DeleteResource

Behavior:

  • The flip-off call is idempotent — providers always issue it when the flag is set, regardless of whether the resource currently has protection on. AWS accepts the no-op (already- disabled) case without error.
  • A failure of the flip-off itself (NotFound / similar) is logged at debug; the actual delete API call still runs and surfaces its own error message.
  • On the two DynamoDB types (AWS::DynamoDB::Table and AWS::DynamoDB::GlobalTable), a flip that is followed by a terminal delete failure — or by a Ctrl-C landing after the flip — is compensated: cdkd re-enables DeletionProtectionEnabled before reporting the failure, so a destroy that did not happen does not leave a live table with its guard stripped. Four limits are deliberate: it only ever restores a guard cdkd itself turned off in this run (a table whose protection was already disabled beforehand, or one whose pre-flip read failed, is left alone); it does not fire BETWEEN retries — the destroy loop re-enters delete() for a retryable failure and flips the guard off again, so compensating there would only toggle it back and forth, and what counts is how the delete ENDS (a throttle followed by a terminal refusal IS compensated); it does not fire once AWS has ACCEPTED the DeleteTable, because a failure after that point is a wait giving up on a table that is already being deleted; and it is best-effort — the delete failure stays the reported outcome, and a re-enable that itself fails is reported as a separate ERROR line naming the table plus the aws dynamodb update-table --deletion-protection-enabled command that restores it by hand. A re-enable that fails with ResourceNotFoundException is reported at warn instead, because cdkd cannot tell which case it is: DynamoDB returns that error both for a table that is gone and for one whose status is merely not ACTIVE. So the line drops the claim that the table is LIVE — which would be false in the first case — while staying visible, and names a describe-table check before the restore command. Every other re-enable failure keeps the ERROR line.
  • The compensation survives a long retry sequence. It latches per table for the duration of the destroy, and that latch's reuse window is sliding — every re-entry restarts it — so what is bounded is idle time since the last attempt rather than the total lifetime of the sequence. A --resource-timeout set past the window used to let an accumulation of attempts age out its own latch mid-flight, after which the next attempt read the guard as already off (cdkd having turned it off itself) and compensated nothing. This is not unconditional. acquire runs once per attempt, so what the window measures is the previous attempt's own duration plus the loop backoff. That is normally comfortable — the 26-minute delete allowance covers the WHOLE retry sequence rather than one attempt, and the window adds a 4-minute margin sized for that backoff — so what can still age out a successor is an attempt that OVERRUNS the allowance (a floor poll granted at zero remaining, an unpriced teardown), not one that merely spends it.
  • A terminal delete failure releases the latch once the compensation above has run — UNLESS that compensating re-enable itself failed, in which case the latch is kept so a later delete can retry the re-enable cdkd still owes. Retention exists so a re-entered delete keeps the same record, and only a retryable failure is re-entered; holding it past a terminal one left flippedOffByThisRun true for a full sliding window after the last attempt, during which a second destroy of the same table in the same process could inherit it and issue an UpdateTable (DeletionProtectionEnabled: true) nobody asked for. The release runs after the compensation reads the record, so the re-enable above is unaffected. Two failures still RETAIN the latch. A retryable one — the attempt-cap case below included — does, because a re-entry is exactly what the record is for. So does a terminal one whose compensating re-enable itself FAILED: there the guard really is off and cdkd is the one that turned it off, so the latch is kept and a later destroy of the same table in the same process retries the re-enable cdkd still owes rather than finding the guard already down and leaving it there.
  • Two cases it deliberately does NOT reach, both leaving the guard off: a retryable failure that exhausts the destroy loop's own attempt cap (cdkd cannot see which attempt is the last), and a per-resource --resource-timeout firing, which rejects without cancelling the delete, so the provider never observes a failure to compensate.
  • This is per-PR-level: a single --remove-protection covers every protection-bearing type listed above. There is no per- type variant. If you need finer control, run a stack-only destroy and clean up the rest manually.
  • The interactive confirmation prompt is updated when the flag is set: About to destroy N resources from stack "X", REMOVING DELETION PROTECTION on K of them. Continue? (y/N). The default flips from Y/n to y/N. --yes / -y / -f skips the prompt.
  • RDS / Cognito gating change: prior to this flag, the RDS DBInstance / DBCluster providers always issued ModifyDB{Instance,Cluster} with DeletionProtection: false before destroy, and the Cognito UserPool provider always issued DescribeUserPool + (if ACTIVE) UpdateUserPool (DeletionProtection='INACTIVE') before destroy. Both implicit behaviors are now gated on --remove-protection to match the other types — destroying an RDS or Cognito UserPool resource whose deletion protection was set externally (console / AWS CLI) without --remove-protection will surface AWS's InvalidParameterCombination / InvalidParameterException error rather than silently succeed.
  • Protection types not in the table above (CloudFront Distributions, S3 bucket retention, etc.) are out of scope — the list is curated to the cases where AWS exposes a synchronous "flip protection off" API call.
# Stack with terminationProtection: true OR a protected DynamoDB / RDS / Logs / EC2 / LB
cdkd destroy MyStack --remove-protection
cdkd destroy --all --remove-protection -y

# CDK-app-free counterpart — the resource-level flip applies the same way;
# stack-level terminationProtection is already ignored by `state destroy`.
cdkd state destroy MyStack --remove-protection -y

Interrupting a destroy (Ctrl-C / SIGTERM)

cdkd destroy and cdkd state destroy shut down gracefully. The first Ctrl-C stops scheduling new deletes; the deletes already in flight are awaited, the state file is flushed to its minimal preserved form, and the stack lock is released. The command then exits non-zero (Destroy interrupted by Ctrl-C. State preserved) so CI sees that the teardown did not complete — re-running cdkd destroy picks up the remaining resources. A second Ctrl-C force-quits without waiting for the in-flight call. Which recovery command it prints depends on whether a per-stack teardown had armed its own handler yet, which is not the same as whether a stack is "running":

  • the exact region-qualified cdkd force-unlock <stack> --stack-region ... once that stack's teardown owns the signal, i.e. it can name the lock;
  • a hedged cdkd force-unlock <stack-name> otherwise — both between two stacks (where the finished stack has already released its lock, unless that release itself failed) and on the FIRST signal inside a stack that has not yet armed its teardown, the window between the loop dispatching the stack and the runner registering its handler. Nothing at either point knows which lock to name.

CI cancellation delivers SIGTERM rather than Ctrl-C, and it is routed through the identical path.

Under --all, the interrupt also stops the run before the next stack starts — including a signal that lands between two stacks, or while the interrupted stack was finishing its own teardown, which are windows the per-stack teardown cannot observe at all. Previously a Ctrl-C there was either swallowed, letting --all delete the next stack, or taken by a last-resort force-quit that exited 130 with the lock stranded.

The non-zero exit means work was left undone, not merely "a signal arrived". A Ctrl-C that lands in the tail of the run — after the last (or only) stack has already been destroyed, while cdkd is finalizing its event record — leaves nothing to re-run, so that case exits 0.

cdkd state destroy --all prompts once for the whole batch before it touches anything. A signal at that prompt cancels it and exits 130 without reading, locking or deleting anything.

The prompt is interactive-only: when stdin is not a TTY — a piped, redirected or CI run — the command refuses before creating the prompt and exits non-zero, naming --yes as the way through. Nothing is read, locked or deleted. This is the same non-interactive rule cdkd gc and cdkd bootstrap --destroy follow. Use --yes / -y to confirm the batch non-interactively; that flag short-circuits above the check, so it never consults stdin at all.

The PER-STACK destroy prompt is interactive-only too

Upgrade note — this is a behaviour change, and it adds an exit-code contract. The per-stack confirmation — the Are you sure you want to destroy stack "X" ... prompt that cdkd destroy <stack>, cdkd destroy --all and cdkd state destroy <stack> all share — now follows the same rule as the batch prompt above: on a non-TTY stdin it refuses before creating the prompt, throwing CdkdError with the code NON_INTERACTIVE_CONFIRM and exiting exit code 1. Note this is NOT the same guarantee the batch prompt above carries. That one promises nothing is read, locked or deleted; here the refusal is preceded by the strong-reference scan, which READS other stacks' state records. Nothing is locked and nothing is deleted on the path that refuses.

A stack whose state record holds ZERO resources never reaches the refusal at all: that branch returns earlier, having taken the lock and deleted the record, and a non-interactive run of it still succeeds. The two are mutually exclusive, so this is not a case of the refusal deleting something first.

Before this change that prompt had no non-TTY guard at all, and rl.question never settles once stdin is at EOF. A CI job running cdkd destroy MyStack without --yes therefore hung until its own timeout rather than failing — no signal is delivered on EOF, so nothing woke it. Measured on Node 24.15.0, the version .node-version pins (and 24.19 before it): echo y | resolved, while printf 'y' | (no trailing newline) and < /dev/null both hung indefinitely.

What breaks: a pipeline that answered the prompt with printf 'y\n' | cdkd destroy MyStack succeeded before (piped stdin does settle rl.question when the input ends in a newline) and now exits 1. Pass --yes / -y — or -f / --force on cdkd destroy — which short-circuits above the check and never consults stdin at all. Refusing rather than auto-confirming is the deliberate choice for a destroy: cdkd deploy assumes "yes" on a non-TTY because a deploy is recoverable, whereas silently answering "yes" for an absent operator here would delete every resource in the stack.

Not affected: nested-stack children, which are destroyed as part of their parent's cascade and never prompt separately, and cdkd state destroy --all, whose per-stack prompts are already skipped once the batch prompt above is answered.

A stack whose deletes were interrupted keeps its state.json and its deployment-event history — the events are the post-mortem for the retry, which is why --purge-events below is skipped on an interrupted run.

Every other mutating confirmation prompt is interactive-only too

Upgrade note — this is a behaviour change on nine more prompts. rl.question never settles once stdin is at EOF, and EOF delivers no signal, so a command that prompted without a non-TTY guard hung until its own timeout in CI rather than failing. That was closed first for the destroy prompts above; these nine were the rest of the class:

Command Prompt Flag that avoids it
cdkd rollback Roll back '<stack>' (<region>)? --force (or -y / --yes)
cdkd state orphan Remove state for <refs> from s3://...? -y / --yes, or -f / --force
cdkd state refresh-observed Refresh observedProperties for N stack(s)...? -y / --yes
cdkd orphan Orphan N resource(s) from cdkd state...? -y / --yes, or -f / --force
cdkd import Write state for <stack> with N resource(s)? -y / --yes
cdkd export the rollback-journal override, the migration confirm, and the nested-stack tree-wide confirm -y / --yes
cdkd drift --accept / --revert Update cdkd state...? / Push cdkd state values back into AWS...? -y / --yes
cdkd import --migrate-from-cloudformation, cdkd migrate --retire-cfn-stack Set DeletionPolicy=Retain ... then delete the stack? -y / --yes
cdkd state migrate Copy N object(s) from <bucket> -> <bucket>...? -y / --yes
cdkd events prune (the tenth) Prune deployment-event history for <stack> (<region>): <scope>? -y / --yes

On a non-TTY stdin each now refuses before creating the prompt, throwing CdkdError with the code NON_INTERACTIVE_CONFIRM and exiting 1. The message names the command and the flag that avoids the prompt.

No partial mutation survives the refusal. That is the guarantee, and it is worth stating precisely rather than as "nothing has happened yet", because for two of the nine something already has:

  • A state lock IS held at the prompt on four of them. cdkd orphan, cdkd import, cdkd export (its migration and nested-tree prompts) and cdkd rollback acquire the stack's lock before building the plan they are about to ask you to confirm. Every one of them releases it in a finally, so the refusal releases it on the way out and no lock is leaked — a re-run with the flag is not blocked by the run that refused. No lock is held at cdkd drift's two prompts or at cdkd export's rollback-journal override (all three acquire after the prompt), nor at either cdkd state prompt (they only READ lock state), cdkd state migrate, or the CloudFormation retirement.
  • The CloudFormation retirement has already written, in two senses. cdkd state is written before it is reached at all — it is the last step of cdkd import --migrate-from-cloudformation / cdkd migrate --retire-cfn-stack — so a refusal leaves the resources recorded in cdkd state while the CloudFormation stack is still live. Its refusal message says so, and names both commands. Separately, for a nested stack whose child templates exceed CloudFormation's 51,200-byte inline limit, those child bodies have already been uploaded to cdkd-migrate-tmp/ in the state bucket by the time the prompt fires; they are deleted on the refusing path, exactly as they are when you answer n.

Every other prompt refuses after read-only work only — the plan it was about to ask you to confirm, and nothing else.

What breaks: a pipeline that answered one of these with printf 'y\n' | cdkd import ... succeeded before (piped stdin does settle rl.question when the input ends in a newline) and now exits 1. Pass the flag from the table, which short-circuits above the check and never consults stdin at all.

Refusing rather than auto-confirming is uniform here because every one of the nine guards a mutation — a rollback replay, a state-record removal, an observed-property refresh, an orphan, an import, an export-then-delete-state, a drift accept/revert, a CloudFormation stack retirement, a state-bucket migration. There is no read-only command in the set, so there was no case for the cdkd deploy treatment. cdkd deploy's asset-storage auto-create prompt remains the one deliberate exception (it assumes "yes" on a non-TTY), because a deploy is recoverable.

All TEN now share ONE implementation — confirmOrRefuse in src/cli/commands/confirm-prompt.ts — nine folded here and cdkd events prune folded last (see below), so the guard cannot be missed by the next prompt added. Each site keeps its own prompt wording and its own refusal message; nothing about the interactive experience changed, including the two prompts that render (y/N): where the other seven render [y/N].

cdkd events prune was the tenth, and it now follows the same contract. It had carried a non-TTY guard since it shipped, so it never hung and it pruned nothing without a TTY — but the guard sat at its CALLER rather than in a helper, and it refused by logging a line and returning, which exits 0. A CI job branching on exit 1 to detect "cdkd wanted a confirmation" could not see it, and could not tell "cdkd refused" from "cdkd pruned nothing". It now throws NON_INTERACTIVE_CONFIRM and exits 1 like the other nine, and its own copy of the byte-identical helper — the last one — is gone.

BREAKING for cdkd events prune specifically: a non-interactive run without --yes used to exit 0 and is now exit 1. Nothing was pruned in either case, so the remedy is the same as for the other nine — pass -y / --yes.

--purge-events: also delete deployment-event history on destroy

By default cdkd destroy removes state.json / lock.json but keeps the stack's deployment-event history (the deployments/ store) as post-mortem context — so the state bucket does not return fully empty after a teardown. cdkd destroy <stack> --purge-events opts into purging that history too, so the bucket returns to empty:

cdkd destroy MyStack --purge-events -y
  • The purge runs only after a clean, non-interrupted destroy of that stack. On a failed / interrupted destroy the events are kept — they are exactly the post-mortem you want when retrying.
  • "Interrupted" here is the PER-STACK question — was this stack left with work to re-run — matching what the exit code asks of the run as a whole. A Ctrl-C that lands after this stack was fully destroyed does not keep its events: there is no retry to post-mortem, and the run reports success, so suppressing the purge there would report a clean teardown while silently leaving the history behind.
  • Best-effort: a purge failure logs a warning but never fails the already-successful destroy.
  • state destroy does NOT take this flag; for an already-destroyed stack (or the CDK-app-free path) use the equivalent cdkd events prune <stack> --all.
  • Per-stack: when destroying multiple stacks, each clean stack's history is purged independently.

Last updated: