@go-to-k/cdkd 0.284.29 → 0.284.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
- import { $ as endCommandInterruptScope, $t as createAssetRedirectResolver, A as MULTI_REGION_RECREATE_BLOCKED_TYPES, An as resolveUseCdkBootstrapAssets, At as maskSecretsInText, B as collectDeclaredOutputNames, Bn as canonicalizeRegion, Bt as INTRINSIC_KEYS, C as ccRoutedFinalSnapshotError, Cn as getLegacyStateBucketName, Ct as STATE_SOURCED_READBACK_RULES, D as unsupportedFinalSnapshotError, Dn as resolveSkipPrefix, Dt as errorCauseChain, E as refusesFinalSnapshot, En as resolveCaptureObservedState, Et as dynamicReferenceTokens, F as cyan, Fn as MIGRATE_TMP_PREFIX, Ft as s3BucketDualStackDomainName, G as stateKeySecretExposure, Gn as resolveBucketRegion, Gt as LockManager, H as exportAliasCollisionScrubWarning, Hn as AssemblyReader, Ht as withRetry, I as gray, In as findLargeInlineResources, It as s3BucketRegionalDomainName, J as clearOnUpdateRemoval, Jn as resetAwsClients, Jt as shouldRetainResource, K as IAMRoleProvider, Kn as AwsClients, Kt as S3StateBackend, L as green, Ln as uploadCfnTemplate, Lt as s3BucketWebsiteUrl, M as renderStatefulReason, Mn as warnDeprecatedNoPrefixCliFlag, Mt as scrubResourceRecord, N as formatResourceLine, Nn as CFN_TEMPLATE_BODY_LIMIT, Nt as s3BucketArn, O as makeCanonicalizePropertiesFn, On as resolveStateBucketWithDefault, Ot as isSingleDynamicReferenceToken, P as bold, Pn as CFN_TEMPLATE_URL_LIMIT, Pt as s3BucketDomainName, Q as beginCommandInterruptScope, Qt as buildAssetRedirectMap, R as red, Rn as expectedOwnerParam, Rt as applyRoleArnIfSet, S as buildFinalSnapshotIdentifier, Sn as getDefaultStateBucketName, Sr as markNonRetryable, St as STATE_SOURCED_CROSS_GENERATION_RULES, T as isFinalSnapshotError, Tn as resolveAutoAssetStorage, Tt as createSecretMasker, U as isExportAliasCollision, Un as processStackMessages, Ut as DagBuilder, V as collectPublishedOutputNames, Vn as derivePartitionAndUrlSuffix, Vt as describeTypeWithThrottleRetry, W as secretBearingStateKeyWarning, Wt as TemplateParser, X as findActionableSilentDrops, Xt as stringifyValue, Y as ProviderRegistry, Yn as setAwsClients, Yt as AssetPublisher, Z as findSilentDropProperties, Zn as CdkdError, Zt as WorkGraph, _ as maskingRetryLogger, _n as runDockerStreaming, _r as normalizeAwsError, _t as readConfigString, a as DeploymentEventsStore, an as BOOTSTRAP_MARKER_PREFIX, ar as MissingCdkCliError, at as isTerminationProtectionPropagationError, b as ATOMIC_FINAL_SNAPSHOT_TYPES, bn as Synthesizer, br as isRetryableTransientError, bt as requireConfigObject, c as planRollback, cn as parseBootstrapMarker, cr as ProvisioningError, ct as getAccountInfo, d as replayRollback, dn as validateContainerRepoName, dr as StackHasActiveImportsError, dt as normalizeAwsTagsToCfn, en as loadPublishableAssetManifest, er as DeployCancelledError, et as isInterruptedWaitError, f as updatePartialMessage, fn as buildDenyExternalAccessPolicy, fr as StackTerminationProtectionError, ft as resolveExplicitPhysicalId, g as withResourceDeadline, gn as runDockerForeground, gt as configStringRefusal, h as deleteSkipReason, hn as getDockerCmd, ht as configBooleanRefusal, i as DeploymentEventsReader, in as AssetModeResolver, it as disableInstanceApiTermination, j as isStatefulRecreateTargetSync, jn as stateBucketExistenceConfirmed, jt as redactSecretsForState, k as extractDeploymentEventError, kn as resolveStateBucketWithDefaultAndSource, kt as maskSecretsInError, l as producerRegionsFromState, ln as readBootstrapMarkerBody, lr as ResourceTimeoutError, lt as refStateLookupFromResource, m as UNSPECIFIED_SKIP_REASON, mn as formatDockerLoginError, mt as coerceCfnBoolean, n as DEFAULT_RESOURCE_WARN_AFTER_MS, nn as escapeRegExp, nr as LocalMigrateError, nt as CloudControlProvider, o as classifyReplaySecretRegion, on as ensureAssetStorage, or as NestedStackChildDirectDestroyError, ot as IntrinsicFunctionResolver, p as updatePartialReason, pn as buildDockerImage, pt as assertRegionMatch, q as collectInlinePolicyNamesManagedBySiblings, qn as getAwsClients, qt as rebuildClientForBucketRegion, r as DeployEngine, rn as stripControlChars, rr as LocalStartServiceError, rt as slowCcOperationTimeoutMs, s as planFailedOps, sn as getBootstrapMarkerKey, sr as PartialFailureError, st as cfnRefValueFromPhysicalId, t as DEFAULT_RESOURCE_TIMEOUT_MS, tn as rewriteTemplateAssetReferences, tr as LocalInvokeBuildError$1, tt as startInterruptWatch, u as replayFailedOperations, un as validateAssetBucketName, ur as ResourceUpdateNotSupportedError, ut as WAFv2WebACLProvider, v as IMPLICIT_DELETE_DEPENDENCIES, vn as AssetManifestLoader, vr as withErrorHandling, vt as replayWarn, w as createPreDeleteFinalSnapshot, wn as resolveApp, wt as TEMPLATE_SOURCED_RULES, x as PRE_DELETE_SNAPSHOT_TYPES, xn as synthesisStatusMessage, xr as isThrottlingError, xt as requireConfigString, y as computeImplicitDeleteEdges, yn as getDockerImageBySourceHash, yr as isMarkedNonRetryable, yt as requireConfigArray, z as yellow, zn as PARTITION_TABLE, zt as DiffCalculator } from "./deploy-engine-CE-tOz2d.js";
2
+ import { $ as endCommandInterruptScope, $t as createAssetRedirectResolver, A as MULTI_REGION_RECREATE_BLOCKED_TYPES, An as resolveUseCdkBootstrapAssets, At as maskSecretsInText, B as collectDeclaredOutputNames, Bn as canonicalizeRegion, Bt as INTRINSIC_KEYS, C as ccRoutedFinalSnapshotError, Cn as getLegacyStateBucketName, Ct as STATE_SOURCED_READBACK_RULES, D as unsupportedFinalSnapshotError, Dn as resolveSkipPrefix, Dt as errorCauseChain, E as refusesFinalSnapshot, En as resolveCaptureObservedState, Et as dynamicReferenceTokens, F as cyan, Fn as MIGRATE_TMP_PREFIX, Ft as s3BucketDualStackDomainName, G as stateKeySecretExposure, Gn as resolveBucketRegion, Gt as LockManager, H as exportAliasCollisionScrubWarning, Hn as AssemblyReader, Ht as withRetry, I as gray, In as findLargeInlineResources, It as s3BucketRegionalDomainName, J as clearOnUpdateRemoval, Jn as resetAwsClients, Jt as shouldRetainResource, K as IAMRoleProvider, Kn as AwsClients, Kt as S3StateBackend, L as green, Ln as uploadCfnTemplate, Lt as s3BucketWebsiteUrl, M as renderStatefulReason, Mn as warnDeprecatedNoPrefixCliFlag, Mt as scrubResourceRecord, N as formatResourceLine, Nn as CFN_TEMPLATE_BODY_LIMIT, Nt as s3BucketArn, O as makeCanonicalizePropertiesFn, On as resolveStateBucketWithDefault, Ot as isSingleDynamicReferenceToken, P as bold, Pn as CFN_TEMPLATE_URL_LIMIT, Pt as s3BucketDomainName, Q as beginCommandInterruptScope, Qt as buildAssetRedirectMap, R as red, Rn as expectedOwnerParam, Rt as applyRoleArnIfSet, S as buildFinalSnapshotIdentifier, Sn as getDefaultStateBucketName, Sr as markNonRetryable, St as STATE_SOURCED_CROSS_GENERATION_RULES, T as isFinalSnapshotError, Tn as resolveAutoAssetStorage, Tt as createSecretMasker, U as isExportAliasCollision, Un as processStackMessages, Ut as DagBuilder, V as collectPublishedOutputNames, Vn as derivePartitionAndUrlSuffix, Vt as describeTypeWithThrottleRetry, W as secretBearingStateKeyWarning, Wt as TemplateParser, X as findActionableSilentDrops, Xt as stringifyValue, Y as ProviderRegistry, Yn as setAwsClients, Yt as AssetPublisher, Z as findSilentDropProperties, Zn as CdkdError, Zt as WorkGraph, _ as maskingRetryLogger, _n as runDockerStreaming, _r as normalizeAwsError, _t as readConfigString, a as DeploymentEventsStore, an as BOOTSTRAP_MARKER_PREFIX, ar as MissingCdkCliError, at as isTerminationProtectionPropagationError, b as ATOMIC_FINAL_SNAPSHOT_TYPES, bn as Synthesizer, br as isRetryableTransientError, bt as requireConfigObject, c as planRollback, cn as parseBootstrapMarker, cr as ProvisioningError, ct as getAccountInfo, d as replayRollback, dn as validateContainerRepoName, dr as StackHasActiveImportsError, dt as normalizeAwsTagsToCfn, en as loadPublishableAssetManifest, er as DeployCancelledError, et as isInterruptedWaitError, f as updatePartialMessage, fn as buildDenyExternalAccessPolicy, fr as StackTerminationProtectionError, ft as resolveExplicitPhysicalId, g as withResourceDeadline, gn as runDockerForeground, gt as configStringRefusal, h as deleteSkipReason, hn as getDockerCmd, ht as configBooleanRefusal, i as DeploymentEventsReader, in as AssetModeResolver, it as disableInstanceApiTermination, j as isStatefulRecreateTargetSync, jn as stateBucketExistenceConfirmed, jt as redactSecretsForState, k as extractDeploymentEventError, kn as resolveStateBucketWithDefaultAndSource, kt as maskSecretsInError, l as producerRegionsFromState, ln as readBootstrapMarkerBody, lr as ResourceTimeoutError, lt as refStateLookupFromResource, m as UNSPECIFIED_SKIP_REASON, mn as formatDockerLoginError, mt as coerceCfnBoolean, n as DEFAULT_RESOURCE_WARN_AFTER_MS, nn as escapeRegExp, nr as LocalMigrateError, nt as CloudControlProvider, o as classifyReplaySecretRegion, on as ensureAssetStorage, or as NestedStackChildDirectDestroyError, ot as IntrinsicFunctionResolver, p as updatePartialReason, pn as buildDockerImage, pt as assertRegionMatch, q as collectInlinePolicyNamesManagedBySiblings, qn as getAwsClients, qt as rebuildClientForBucketRegion, r as DeployEngine, rn as stripControlChars, rr as LocalStartServiceError, rt as slowCcOperationTimeoutMs, s as planFailedOps, sn as getBootstrapMarkerKey, sr as PartialFailureError, st as cfnRefValueFromPhysicalId, t as DEFAULT_RESOURCE_TIMEOUT_MS, tn as rewriteTemplateAssetReferences, tr as LocalInvokeBuildError$1, tt as startInterruptWatch, u as replayFailedOperations, un as validateAssetBucketName, ur as ResourceUpdateNotSupportedError, ut as WAFv2WebACLProvider, v as IMPLICIT_DELETE_DEPENDENCIES, vn as AssetManifestLoader, vr as withErrorHandling, vt as replayWarn, w as createPreDeleteFinalSnapshot, wn as resolveApp, wt as TEMPLATE_SOURCED_RULES, x as PRE_DELETE_SNAPSHOT_TYPES, xn as synthesisStatusMessage, xr as isThrottlingError, xt as requireConfigString, y as computeImplicitDeleteEdges, yn as getDockerImageBySourceHash, yr as isMarkedNonRetryable, yt as requireConfigArray, z as yellow, zn as PARTITION_TABLE, zt as DiffCalculator } from "./deploy-engine-DYdEWX-s.js";
3
3
  import { a as getLiveRenderer, c as PATTERN_B_RESOURCE_TYPES, d as generateResourceNameWithFallback, f as getCurrentStackName, h as withStackName, i as runStackBuffered, m as withSkipPrefix, n as getLogger, o as PATTERN_B_NAME_OPTIONS, s as PATTERN_B_NAME_PROPERTIES, u as generateResourceName } from "./logger-zRrlbaQt.js";
4
- import { t as ASGProvider } from "./asg-provider-DzbiUvJi.js";
4
+ import { t as ASGProvider } from "./asg-provider-DJvcY8lj.js";
5
5
  import { AsyncLocalStorage } from "node:async_hooks";
6
6
  import { createHash, randomBytes, randomUUID } from "node:crypto";
7
7
  import { CopyObjectCommand, CreateBucketCommand, DeleteBucketAnalyticsConfigurationCommand, DeleteBucketCommand, DeleteBucketCorsCommand, DeleteBucketEncryptionCommand, DeleteBucketIntelligentTieringConfigurationCommand, DeleteBucketInventoryConfigurationCommand, DeleteBucketLifecycleCommand, DeleteBucketMetricsConfigurationCommand, DeleteBucketOwnershipControlsCommand, DeleteBucketPolicyCommand, DeleteBucketReplicationCommand, DeleteBucketTaggingCommand, DeleteBucketWebsiteCommand, DeleteObjectsCommand, GetBucketAccelerateConfigurationCommand, GetBucketCorsCommand, GetBucketEncryptionCommand, GetBucketLifecycleConfigurationCommand, GetBucketLocationCommand, GetBucketLoggingCommand, GetBucketNotificationConfigurationCommand, GetBucketOwnershipControlsCommand, GetBucketPolicyCommand, GetBucketReplicationCommand, GetBucketTaggingCommand, GetBucketVersioningCommand, GetBucketWebsiteCommand, GetObjectCommand, GetObjectLockConfigurationCommand, GetPublicAccessBlockCommand, HeadBucketCommand, ListBucketAnalyticsConfigurationsCommand, ListBucketIntelligentTieringConfigurationsCommand, ListBucketInventoryConfigurationsCommand, ListBucketMetricsConfigurationsCommand, ListObjectVersionsCommand, ListObjectsV2Command, NoSuchBucket, PutBucketAccelerateConfigurationCommand, PutBucketAnalyticsConfigurationCommand, PutBucketCorsCommand, PutBucketEncryptionCommand, PutBucketIntelligentTieringConfigurationCommand, PutBucketInventoryConfigurationCommand, PutBucketLifecycleConfigurationCommand, PutBucketLoggingCommand, PutBucketMetricsConfigurationCommand, PutBucketNotificationConfigurationCommand, PutBucketOwnershipControlsCommand, PutBucketPolicyCommand, PutBucketReplicationCommand, PutBucketTaggingCommand, PutBucketVersioningCommand, PutBucketWebsiteCommand, PutObjectCommand, PutObjectLockConfigurationCommand, PutPublicAccessBlockCommand, S3Client, S3ServiceException } from "@aws-sdk/client-s3";
@@ -77,6 +77,162 @@ import { setTimeout as setTimeout$1 } from "node:timers/promises";
77
77
  import { mkdtemp, rm, writeFile } from "node:fs/promises";
78
78
  import { WebSocketServer } from "ws";
79
79
 
80
+ //#region src/cli/commands/state-file-keys.ts
81
+ /**
82
+ * Shared helpers for enumerating and describing cdkd state-bucket keys.
83
+ *
84
+ * Extracted from `bootstrap-destroy.ts` (issue #1010) so that `cdkd gc`
85
+ * (issue #1012) reuses the exact same whole-bucket state-file discovery
86
+ * instead of duplicating it — the "scan the WHOLE bucket, not just the
87
+ * default `cdkd/` prefix" rule was a review blocker once already (PR #1018)
88
+ * and must not drift between the two commands.
89
+ */
90
+ /**
91
+ * Every cdkd state file ends with this suffix, regardless of the
92
+ * `--state-prefix` it was deployed under (`{prefix}/{stack}/{region}/state.json`,
93
+ * or legacy `{prefix}/{stack}/state.json`).
94
+ */
95
+ const STATE_FILE_SUFFIX = "/state.json";
96
+ /**
97
+ * The prefix `cdkd bootstrap` writes under, and the one `gc` /
98
+ * `bootstrap --destroy` configure their own state backend with.
99
+ *
100
+ * Shared here rather than spelled per command for the same reason this module
101
+ * exists at all: `bootstrap-destroy.ts` and `gc.ts` each held their own copy,
102
+ * and it is now also the `knownPrefix` argument to {@link describeStateKey},
103
+ * where a copy that drifted from the backend's actual prefix would silently
104
+ * send every key back to the shape heuristic instead of the exact depth rule.
105
+ *
106
+ * Note this is only the DEFAULT. Other commands accept `--state-prefix`, so
107
+ * the whole-bucket listings deliberately do not scope to it.
108
+ */
109
+ const DEFAULT_STATE_PREFIX = "cdkd";
110
+ /**
111
+ * Segments left under a known prefix for the legacy `{prefix}/{stack}` layout.
112
+ *
113
+ * The twin of `S3StateBackend`'s `LEGACY_KEY_DEPTH`, which counts the
114
+ * `state.json` segment as well; this one counts what remains after the prefix
115
+ * and the suffix are stripped, so it is that value minus one. Fenced against it
116
+ * by a unit test rather than imported, because a value import from
117
+ * `s3-state-backend.js` breaks the suites that mock that module.
118
+ */
119
+ const LEGACY_SEGMENTS_UNDER_PREFIX = 1;
120
+ /**
121
+ * Every cdkd stack lock ends with this suffix — the lock lives next to the
122
+ * state file (`{prefix}/{stack}/{region}/lock.json`, or the legacy region-less
123
+ * `{prefix}/{stack}/lock.json` that `lock-manager.ts` still writes for a
124
+ * pre-v2 stack).
125
+ */
126
+ const LOCK_FILE_SUFFIX = "/lock.json";
127
+ /**
128
+ * `us-east-1` / `ap-northeast-1` / `us-gov-west-1` — a region-shaped segment.
129
+ *
130
+ * The first token is `[a-z]{2}` OR the literal `eusc` (issue #2001). Every
131
+ * region AWS ships has a two-letter first token except the European Sovereign
132
+ * Cloud partition's, which is four — `eusc-de-east-1` — and the old
133
+ * `^[a-z]{2}` rejected that whole partition. Enumerated from
134
+ * `aws-cdk-lib/region-info` and `@aws-sdk/util-endpoints`: 46 REAL regions,
135
+ * first tokens all two letters or `eusc`, none three. (A count of 53 includes
136
+ * the `aws-global` / `aws-iso-*-global` PSEUDO-regions, whose first token is
137
+ * `aws`; they carry no trailing `-<digits>`, so this pattern rejects them and
138
+ * so did the old one — no behavioural difference, but at 53 the "two letters
139
+ * or eusc" claim is false as stated.)
140
+ *
141
+ * **The exception is enumerated rather than widened, and that is the whole
142
+ * design.** The obvious fix — relaxing the bound to `{2,4}` — was written
143
+ * first and review measured what it cost: `api-prod-1`, `demo-app-1`,
144
+ * `dev-api-1`, `core-api-1`, `test-stack-2` all become region-shaped under it.
145
+ * Those are idiomatic STACK names, and in the legacy region-less layout a
146
+ * stack name sits exactly where a region would, so the pattern would report
147
+ * the key's PREFIX as the stack for each of them — the same wrong answer this
148
+ * issue is about, arriving from the other side and at a far higher rate. A
149
+ * length class cannot separate `eusc` from `demo`; only naming the partition
150
+ * can.
151
+ *
152
+ * The cost of enumerating is that a future partition with a new non-two-letter
153
+ * token needs this line updated. That is the same failure this issue fixed, it
154
+ * is a one-token change, and it fails in the recoverable direction (a region
155
+ * read as a stack name) rather than corrupting ordinary stack names.
156
+ *
157
+ * It stays SHAPE-based and deliberately does not reuse `isClientSafeRegion`
158
+ * from `intrinsic-function-resolver.ts`: that predicate is charset-based (its
159
+ * job is to keep a value inside a hostname label, so it accepts anything
160
+ * lowercase-alphanumeric) and would classify almost every stack name as a
161
+ * region. This one has the opposite job — telling a region segment apart from
162
+ * a stack name in the same position — so it must constrain the shape.
163
+ *
164
+ * A two-letter STACK name like `db-prod-1` is still misread in the legacy
165
+ * layout; that is pre-existing (`main` does the same) and is what the
166
+ * `knownPrefix` depth rule below repairs for callers that can supply one.
167
+ */
168
+ const REGION_SEGMENT = /^(?:[a-z]{2}|eusc)(-[a-z]+)+-\d+$/;
169
+ /**
170
+ * List every state file in the bucket — the WHOLE bucket, not just the
171
+ * default `cdkd/` prefix. Other commands accept `--state-prefix`, so live
172
+ * stack state may exist under ANY prefix in this bucket; scoping this
173
+ * listing to the default prefix would let reference scans and teardown
174
+ * guards silently miss those stacks and delete live data.
175
+ */
176
+ async function listAllStateKeys(stateBackend) {
177
+ return (await stateBackend.listRawKeys("")).filter((k) => k.endsWith(STATE_FILE_SUFFIX));
178
+ }
179
+ /**
180
+ * List every stack lock file in the bucket — same whole-bucket rule as
181
+ * {@link listAllStateKeys}. A lock under ANY prefix means a deploy /
182
+ * destroy may be in flight for that stack.
183
+ */
184
+ async function listAllLockKeys(stateBackend) {
185
+ return (await stateBackend.listRawKeys("")).filter((k) => k.endsWith(LOCK_FILE_SUFFIX));
186
+ }
187
+ /**
188
+ * `{prefix}/{stack}/{region}/state.json` → `stack (region)`; legacy
189
+ * `{prefix}/{stack}/state.json` → `stack`. Pass {@link LOCK_FILE_SUFFIX} to
190
+ * describe lock keys.
191
+ *
192
+ * The shape heuristic below cannot be made exact, and it errs in BOTH
193
+ * directions -- a region it fails to recognise is reported AS the stack name,
194
+ * while a region-shaped STACK name in the legacy layout makes it report the
195
+ * key's PREFIX as the stack. Neither is safe: `cdkd gc`'s corrupt-state abort
196
+ * turns whichever answer it gets into a `cdkd state show ...` recovery
197
+ * command, so a wrong one is handed to the user as a command to run.
198
+ *
199
+ * `knownPrefix` narrows it where a caller can: a key with exactly one segment
200
+ * under a prefix we know is the legacy `{stack}` layout by construction,
201
+ * whatever the stack is named, so no shape test is needed there at all. That
202
+ * repairs the pre-existing two-letter case (`cdkd/db-prod-1/state.json`, which
203
+ * `main` describes as `cdkd (db-prod-1)`).
204
+ *
205
+ * **It is a narrowing, not a safety net for the pattern above.** A first cut
206
+ * treated it as one — widening `REGION_SEGMENT` to `{2,4}` and relying on this
207
+ * rule to undo the damage — and review measured that the damage is repo-wide
208
+ * while this rule reaches only keys under the ONE prefix a caller names. The
209
+ * listing deliberately spans the whole bucket, so foreign and nested prefixes
210
+ * are in scope and unreachable from here; six shapes regressed against `main`
211
+ * that way. The pattern must be right on its own.
212
+ *
213
+ * **It deliberately does NOT claim the two-segment case.** A first cut did, on
214
+ * the reasoning that at that depth the last segment must be the region -- and
215
+ * review found `--state-prefix cdkd/team-a`, which nests inside the default
216
+ * prefix, so `cdkd/team-a/MyStack/state.json` is a LEGACY key two segments
217
+ * deep and would have been mis-split as `team-a (MyStack)` where `main` said
218
+ * `MyStack`. Depth cannot tell that apart from a real `{stack}/{region}`; only
219
+ * the shape of the tail can, so the heuristic keeps that case.
220
+ */
221
+ function describeStateKey(key, suffix = STATE_FILE_SUFFIX, knownPrefix) {
222
+ const body = key.slice(0, -suffix.length);
223
+ const prefix = knownPrefix?.replace(/\/+$/, "");
224
+ if (prefix !== void 0 && prefix !== "" && key.startsWith(`${prefix}/`)) {
225
+ const rest = body.slice(prefix.length + 1).split("/");
226
+ if (rest.length === LEGACY_SEGMENTS_UNDER_PREFIX && rest[0]) return rest[0];
227
+ }
228
+ const segments = body.split("/");
229
+ const last = segments[segments.length - 1] ?? key;
230
+ const secondLast = segments[segments.length - 2];
231
+ if (secondLast !== void 0 && secondLast !== "" && REGION_SEGMENT.test(last)) return `${secondLast} (${last})`;
232
+ return last;
233
+ }
234
+
235
+ //#endregion
80
236
  //#region src/cli/options.ts
81
237
  /**
82
238
  * Parse context key=value pairs from CLI arguments into a Record
@@ -152,7 +308,7 @@ const appOptions = [new Option("-a, --app <command>", "CDK app command (e.g., \"
152
308
  * --state-bucket is optional: falls back to CDKD_STATE_BUCKET env var,
153
309
  * then cdk.json context.cdkd.stateBucket
154
310
  */
155
- const stateOptions = [new Option("--state-bucket <bucket>", "S3 bucket for state storage. Falls back to CDKD_STATE_BUCKET env or cdk.json"), new Option("--state-prefix <prefix>", "S3 key prefix for state files").default("cdkd")];
311
+ const stateOptions = [new Option("--state-bucket <bucket>", "S3 bucket for state storage. Falls back to CDKD_STATE_BUCKET env or cdk.json"), new Option("--state-prefix <prefix>", "S3 key prefix for state files").default(DEFAULT_STATE_PREFIX)];
156
312
  /**
157
313
  * Stack options
158
314
  */
@@ -1025,91 +1181,9 @@ function regionNeedsReconciliation(effective) {
1025
1181
  return effective.region !== LEGACY_DEFAULT_REGION;
1026
1182
  }
1027
1183
 
1028
- //#endregion
1029
- //#region src/cli/commands/state-file-keys.ts
1030
- /**
1031
- * Shared helpers for enumerating and describing cdkd state-bucket keys.
1032
- *
1033
- * Extracted from `bootstrap-destroy.ts` (issue #1010) so that `cdkd gc`
1034
- * (issue #1012) reuses the exact same whole-bucket state-file discovery
1035
- * instead of duplicating it — the "scan the WHOLE bucket, not just the
1036
- * default `cdkd/` prefix" rule was a review blocker once already (PR #1018)
1037
- * and must not drift between the two commands.
1038
- */
1039
- /**
1040
- * Every cdkd state file ends with this suffix, regardless of the
1041
- * `--state-prefix` it was deployed under (`{prefix}/{stack}/{region}/state.json`,
1042
- * or legacy `{prefix}/{stack}/state.json`).
1043
- */
1044
- const STATE_FILE_SUFFIX = "/state.json";
1045
- /**
1046
- * Every cdkd stack lock ends with this suffix — the lock lives next to the
1047
- * state file (`{prefix}/{stack}/{region}/lock.json`).
1048
- */
1049
- const LOCK_FILE_SUFFIX = "/lock.json";
1050
- /** `us-east-1` / `ap-northeast-1` / `us-gov-west-1` — a region-shaped segment. */
1051
- const REGION_SEGMENT = /^[a-z]{2}(-[a-z]+)+-\d+$/;
1052
- /**
1053
- * List every state file in the bucket — the WHOLE bucket, not just the
1054
- * default `cdkd/` prefix. Other commands accept `--state-prefix`, so live
1055
- * stack state may exist under ANY prefix in this bucket; scoping this
1056
- * listing to the default prefix would let reference scans and teardown
1057
- * guards silently miss those stacks and delete live data.
1058
- */
1059
- async function listAllStateKeys(stateBackend) {
1060
- return (await stateBackend.listRawKeys("")).filter((k) => k.endsWith(STATE_FILE_SUFFIX));
1061
- }
1062
- /**
1063
- * List every stack lock file in the bucket — same whole-bucket rule as
1064
- * {@link listAllStateKeys}. A lock under ANY prefix means a deploy /
1065
- * destroy may be in flight for that stack.
1066
- */
1067
- async function listAllLockKeys(stateBackend) {
1068
- return (await stateBackend.listRawKeys("")).filter((k) => k.endsWith(LOCK_FILE_SUFFIX));
1069
- }
1070
- /**
1071
- * `{prefix}/{stack}/{region}/state.json` → `stack (region)`; legacy
1072
- * `{prefix}/{stack}/state.json` → `stack`. The prefix is arbitrary (custom
1073
- * `--state-prefix` values included), so the stack/region pair is derived
1074
- * from the key's TAIL: the last segment is treated as a region only when
1075
- * it is region-shaped. Pass {@link LOCK_FILE_SUFFIX} to describe lock keys.
1076
- */
1077
- function describeStateKey(key, suffix = STATE_FILE_SUFFIX) {
1078
- const segments = key.slice(0, -suffix.length).split("/");
1079
- const last = segments[segments.length - 1] ?? key;
1080
- const secondLast = segments[segments.length - 2];
1081
- if (secondLast !== void 0 && REGION_SEGMENT.test(last)) return `${secondLast} (${last})`;
1082
- return last;
1083
- }
1084
-
1085
1184
  //#endregion
1086
1185
  //#region src/cli/commands/bootstrap-destroy.ts
1087
1186
  /**
1088
- * `cdkd bootstrap --destroy` — teardown of cdkd-created account resources
1089
- * (issue #1010, the reverse of `cdkd bootstrap` / `ensureAssetStorage`).
1090
- *
1091
- * Scope per invocation is ONE region's asset storage (`--region`, same
1092
- * option the create side uses): empty + delete the asset bucket, force-
1093
- * delete the container-asset ECR repo, then delete the region's bootstrap
1094
- * marker LAST — the mirror image of the create side's marker-written-last
1095
- * ordering, so a crash mid-teardown leaves the region still consistently
1096
- * opted in (marker present → deploys keep hard-erroring at the missing
1097
- * resources with a re-bootstrap hint, never a silent legacy fallback).
1098
- *
1099
- * The asset bucket / repo NAMES are read from the region's bootstrap
1100
- * marker, NOT recomputed from the naming convention — the marker is the
1101
- * source of truth for names (design §4.1), which keeps this teardown
1102
- * compatible with custom asset-storage names (issue #1011).
1103
- *
1104
- * The state bucket is NOT deleted by default: it is the account's source
1105
- * of truth. `--include-state-bucket` opts in, and even then the deletion
1106
- * is refused while ANY stack state exists in the bucket or any OTHER
1107
- * region is still opted in to asset storage (deleting their markers would
1108
- * silently flip those regions back to legacy mode).
1109
- */
1110
- /** S3 key prefix for state files — same fixed value the create side uses. */
1111
- const STATE_PREFIX = "cdkd";
1112
- /**
1113
1187
  * Interactive confirmation for the teardown. Follows the repo's
1114
1188
  * destructive-prompt convention (see `recreate-confirm-prompt.ts`, the
1115
1189
  * same pattern family as `confirm-prompt.ts`): print the deletion plan as
@@ -1157,7 +1231,7 @@ async function scanStateReferences(stateBackend, names) {
1157
1231
  for (const key of stateKeys) {
1158
1232
  const body = await stateBackend.getRawObject(key);
1159
1233
  if (body === null) continue;
1160
- if (names.some((name) => body.includes(name))) referencing.push(describeStateKey(key));
1234
+ if (names.some((name) => body.includes(name))) referencing.push(describeStateKey(key, STATE_FILE_SUFFIX, DEFAULT_STATE_PREFIX));
1161
1235
  }
1162
1236
  return referencing;
1163
1237
  }
@@ -1332,7 +1406,7 @@ async function bootstrapDestroyCommand(options) {
1332
1406
  ...options.profile && { profile: options.profile }
1333
1407
  }), {
1334
1408
  bucket: bucketName,
1335
- prefix: STATE_PREFIX
1409
+ prefix: DEFAULT_STATE_PREFIX
1336
1410
  }, {
1337
1411
  region: effective.region,
1338
1412
  ...options.profile && { profile: options.profile }
@@ -1361,7 +1435,7 @@ async function bootstrapDestroyCommand(options) {
1361
1435
  });
1362
1436
  const stateBackend = new S3StateBackend(markerS3Client, {
1363
1437
  bucket: bucketName,
1364
- prefix: STATE_PREFIX
1438
+ prefix: DEFAULT_STATE_PREFIX
1365
1439
  }, {
1366
1440
  region,
1367
1441
  ...options.profile && { profile: options.profile }
@@ -1413,7 +1487,7 @@ async function bootstrapDestroyCommand(options) {
1413
1487
  if (options.includeStateBucket) {
1414
1488
  const stateKeys = await listAllStateKeys(stateBackend);
1415
1489
  if (stateKeys.length > 0) {
1416
- const listing = stateKeys.map((k) => ` - ${describeStateKey(k)} [${k}]`).join("\n");
1490
+ const listing = stateKeys.map((k) => ` - ${describeStateKey(k, STATE_FILE_SUFFIX, DEFAULT_STATE_PREFIX)} [${k}]`).join("\n");
1417
1491
  throw new CdkdError(`Refusing to delete state bucket '${bucketName}': ${stateKeys.length} stack(s) still have state in it:\n${listing}\nDestroy every stack ('cdkd destroy' / 'cdkd state destroy') before deleting the state bucket.`, "STATE_BUCKET_NOT_EMPTY");
1418
1492
  }
1419
1493
  if (markerSiblings.otherRegions.length > 0) throw new CdkdError(`Refusing to delete state bucket '${bucketName}': region(s) ${markerSiblings.otherRegions.join(", ")} are still opted in to cdkd asset storage (their bootstrap markers live in this bucket). Run 'cdkd bootstrap --destroy --region <r>' for each first.`, "STATE_BUCKET_HOLDS_MARKERS");
@@ -1509,7 +1583,7 @@ async function bootstrapCommand(options) {
1509
1583
  ...options.profile && { profile: options.profile }
1510
1584
  }), {
1511
1585
  bucket: bucketName,
1512
- prefix: "cdkd"
1586
+ prefix: DEFAULT_STATE_PREFIX
1513
1587
  }, {
1514
1588
  region: effective.region,
1515
1589
  ...options.profile && { profile: options.profile }
@@ -1610,7 +1684,7 @@ async function bootstrapCommand(options) {
1610
1684
  });
1611
1685
  const stateBackend = new S3StateBackend(markerS3Client, {
1612
1686
  bucket: bucketName,
1613
- prefix: "cdkd"
1687
+ prefix: DEFAULT_STATE_PREFIX
1614
1688
  }, {
1615
1689
  region,
1616
1690
  ...options.profile && { profile: options.profile }
@@ -64081,6 +64155,30 @@ function stripWalk(value) {
64081
64155
  //#endregion
64082
64156
  //#region src/cli/commands/drift.ts
64083
64157
  /**
64158
+ * Route one outcome to the handler for its variant.
64159
+ *
64160
+ * The mechanism issue [#2135](https://github.com/go-to-k/cdkd/issues/2135) asks
64161
+ * for, and the reason every consumer in this file goes through it rather than
64162
+ * filtering on `o.kind === '...'`: `handlers` is a MAPPED TYPE over
64163
+ * `DriftOutcome['kind']`, so it must name EVERY variant. Adding a member to the
64164
+ * union turns every call site that has not been updated into a compile error
64165
+ * ("property '<kind>' is missing"), instead of leaving it to fall through to
64166
+ * whatever it happened to do before. A `.filter()` predicate cannot do that —
64167
+ * it keeps compiling and silently stops matching the new state.
64168
+ *
64169
+ * A handler that deliberately does nothing with a variant still has to be
64170
+ * written out, which is the point: the author of the next variant has to decide,
64171
+ * per consumer, what it means there.
64172
+ *
64173
+ * Exported, with {@link DriftOutcome} and {@link NotComparedCause}, so
64174
+ * `tests/unit/cli/drift-outcome.test-d.ts` can fence the mapped type itself:
64175
+ * relaxing it to a `Partial` record would silently un-fence every consumer in
64176
+ * this file while changing no runtime behaviour, so no runtime test can see it.
64177
+ */
64178
+ function matchOutcome(outcome, handlers) {
64179
+ return handlers[outcome.kind](outcome);
64180
+ }
64181
+ /**
64084
64182
  * Distinguish "drift detected" (exit 1) from "command crashed" (exit 1
64085
64183
  * via the default handler) so the drift command can fail fast and the
64086
64184
  * top-level handler doesn't add a stack trace for the expected case.
@@ -64105,18 +64203,19 @@ var DriftDetectedError = class DriftDetectedError extends CdkdError {
64105
64203
  * `{{resolve:ssm-secure:...}}` token — a pre-existing population cdkd resolves
64106
64204
  * for nobody, which can never clear on a re-run, and which this change did not
64107
64205
  * create. Exiting non-zero for them would break `cdkd drift` in CI forever over
64108
- * an unrelated defect, so the exit reads `comparisonRefused` and the report
64109
- * reads `referencesUnresolved`. See that field's note.
64206
+ * an unrelated defect, so the exit reads the `refused` cause alone while the
64207
+ * report covers both. See `NotComparedCause` and `outcomeExitSignal`.
64110
64208
  *
64111
64209
  * WHY A NON-ZERO EXIT, and why it is a PRESERVATION rather than a new signal.
64112
64210
  * Pre-#2108 that population resolved the reference in the WRONG region, the
64113
64211
  * baseline could never equal what AWS held, and the resource was reported
64114
64212
  * `drifted` -- so `cdkd drift` exited 1 and every CI gate keyed on the exit code
64115
64213
  * fired. #2108 correctly stopped comparing against a foreign region's plaintext,
64116
- * which made the same population report `clean`; without this the command would
64117
- * exit 0 and print `no drift detected` for a stack whose secret-bearing
64118
- * properties were never looked at. Round 1 of that change surfaced the refusal
64119
- * in `--json` (`notCompared`) and in the human report, but the exit code -- the
64214
+ * which made the same population report `notCompared`; without this the
64215
+ * command would exit 0 and print `no drift detected` for a stack whose
64216
+ * secret-bearing properties were never looked at. Round 1 of that change
64217
+ * surfaced the refusal in `--json` (`notCompared`) and in the human report,
64218
+ * but the exit code -- the
64120
64219
  * signal most CI gates actually read -- still said "pass".
64121
64220
  *
64122
64221
  * WHY EXIT 2 RATHER THAN 1. `2` is this repo's established "work completed but
@@ -64153,32 +64252,56 @@ var DriftComparisonRefusedError = class DriftComparisonRefusedError extends Cdkd
64153
64252
  }
64154
64253
  };
64155
64254
  /**
64156
- * Every outcome on one report that was only PARTIALLY compared -- `drifted` or
64157
- * `clean`, carrying `referencesUnresolved` (issue #2108).
64255
+ * Every outcome on one report that was only PARTIALLY compared -- the
64256
+ * `notCompared` variant, plus a `drifted` one whose `notComparedCause` is set
64257
+ * (issues #2108 / #2135).
64258
+ *
64259
+ * ONE spelling, because TWO renderings must agree about the same run: the
64260
+ * `--json` `notCompared` roll-up and the human report's `PARTIALLY compared`
64261
+ * block (whose count is also what the `N of M fully checked` line subtracts).
64262
+ * A roll-up spelled per reader is how the payload and the human summary come to
64263
+ * disagree.
64158
64264
  *
64159
- * ONE spelling, because there are now THREE readers that must agree: the
64160
- * `--json` `notCompared` roll-up, the human report's `PARTIALLY compared` block,
64161
- * and the EXIT CODE. A predicate spelled per reader is how the payload and the
64162
- * exit code come to disagree about the same run.
64265
+ * The EXIT CODE deliberately does NOT read this see {@link outcomeExitSignal}
64266
+ * and {@link NotComparedCause} for why it is scoped to refusals alone.
64163
64267
  */
64164
64268
  function notComparedOutcomes(report) {
64165
- return report.outcomes.filter((o) => (o.kind === "drifted" || o.kind === "clean") && o.referencesUnresolved);
64269
+ return report.outcomes.flatMap((o) => matchOutcome(o, {
64270
+ drifted: (d) => d.notComparedCause === void 0 ? [] : [d],
64271
+ notCompared: (n) => [n],
64272
+ clean: () => [],
64273
+ unsupported: () => [],
64274
+ skipped: () => []
64275
+ }));
64166
64276
  }
64167
64277
  /**
64168
- * Every outcome whose comparison cdkd DELIBERATELY REFUSED (issue #2108) the
64169
- * strict subset of {@link notComparedOutcomes} that drives the EXIT CODE.
64170
- *
64171
- * Deliberately NOT the same predicate, and the asymmetry is the point: the
64172
- * REPORT covers everything that was not compared, because as information both
64173
- * populations matter equally, while the exit code covers only the population
64174
- * this change created. Driving it off `referencesUnresolved` would exit
64175
- * non-zero forever for every stack holding an `{{resolve:ssm-secure:...}}`
64176
- * reference permanent, unclearable, and unrelated to this issue. See the
64177
- * `comparisonRefused` field note, and issue
64178
- * [#2135](https://github.com/go-to-k/cdkd/issues/2135) for the structural fix.
64179
- */
64180
- function refusedComparisonOutcomes(report) {
64181
- return report.outcomes.filter((o) => (o.kind === "drifted" || o.kind === "clean") && o.comparisonRefused);
64278
+ * What one outcome contributes to the run's EXIT CODE (issues #2108 / #2135).
64279
+ *
64280
+ * The exit-code CONSUMER, kept as an exhaustive `matchOutcome` so a new outcome
64281
+ * variant cannot inherit `none` by omission reporting a resource cdkd never
64282
+ * compared as a pass is precisely the round-2 defect #2135 exists to make
64283
+ * impossible.
64284
+ *
64285
+ * `refused` is a STRICT SUBSET of what {@link notComparedOutcomes} rolls up, and
64286
+ * the asymmetry is the point: the REPORT covers everything that was not
64287
+ * compared, because as information both causes matter equally, while the exit
64288
+ * code covers only the population #2108 created. Exiting non-zero on
64289
+ * `unresolvedToken` would break `cdkd drift` in CI forever for every stack
64290
+ * holding an `{{resolve:ssm-secure:...}}` reference — permanent, unclearable,
64291
+ * and unrelated. See {@link NotComparedCause}.
64292
+ *
64293
+ * A `drifted` outcome reports `drifted` even when its own comparison was
64294
+ * refused: drift is the stronger, actionable signal and the caller ranks it
64295
+ * first, so a consumer gating on `1` loses nothing it gets today.
64296
+ */
64297
+ function outcomeExitSignal(outcome) {
64298
+ return matchOutcome(outcome, {
64299
+ drifted: () => "drifted",
64300
+ notCompared: (n) => n.notComparedCause === "refused" ? "refused" : "none",
64301
+ clean: () => "none",
64302
+ unsupported: () => "none",
64303
+ skipped: () => "none"
64304
+ });
64182
64305
  }
64183
64306
  /**
64184
64307
  * `cdkd drift [<stack>...]` command implementation.
@@ -64248,8 +64371,9 @@ async function driftCommand(stacks, options) {
64248
64371
  }
64249
64372
  if (options.json) writeJsonReport(reports);
64250
64373
  else writeHumanReport(reports);
64251
- const drifted = reports.some((r) => r.outcomes.some((o) => o.kind === "drifted"));
64252
- const anyRefused = reports.some((r) => refusedComparisonOutcomes(r).length > 0);
64374
+ const signals = reports.flatMap((r) => r.outcomes.map(outcomeExitSignal));
64375
+ const drifted = signals.includes("drifted");
64376
+ const anyRefused = signals.includes("refused");
64253
64377
  if (!options.accept && !options.revert) {
64254
64378
  if (drifted) throw new DriftDetectedError();
64255
64379
  if (anyRefused) throw new DriftComparisonRefusedError();
@@ -64953,14 +65077,17 @@ async function runDriftForStack(stackName, region, stateBackend, providerRegistr
64953
65077
  unionWalkObjects: useObserved,
64954
65078
  unorderedPaths
64955
65079
  }), secrets, secretResolutionFailed ? seededSecretPaths : secretPaths);
64956
- const referencesUnresolved = secretResolutionFailed || unresolvedTokens.size > 0;
64957
- const comparisonRefused = secretResolutionFailed;
64958
- if (reported.changes.length === 0) outcomes.push({
64959
- kind: "clean",
65080
+ const notComparedCause = secretResolutionFailed ? "refused" : unresolvedTokens.size > 0 ? "unresolvedToken" : void 0;
65081
+ if (reported.changes.length === 0) if (notComparedCause !== void 0) outcomes.push({
65082
+ kind: "notCompared",
64960
65083
  logicalId,
64961
65084
  resourceType: resource.resourceType,
64962
- referencesUnresolved,
64963
- comparisonRefused
65085
+ notComparedCause
65086
+ });
65087
+ else outcomes.push({
65088
+ kind: "clean",
65089
+ logicalId,
65090
+ resourceType: resource.resourceType
64964
65091
  });
64965
65092
  else outcomes.push({
64966
65093
  kind: "drifted",
@@ -64969,8 +65096,7 @@ async function runDriftForStack(stackName, region, stateBackend, providerRegistr
64969
65096
  ...reported,
64970
65097
  awsProperties: aws,
64971
65098
  secrets,
64972
- referencesUnresolved,
64973
- comparisonRefused
65099
+ notComparedCause
64974
65100
  });
64975
65101
  }
64976
65102
  return {
@@ -65067,7 +65193,13 @@ async function runAccept(reports, stateBackend, stateConfig, awsClients, options
65067
65193
  const lockManager = new LockManager(awsClients.s3, stateConfig);
65068
65194
  const owner = `${process.env["USER"] || "unknown"}@${process.env["HOSTNAME"] || "host"}:${process.pid}`;
65069
65195
  for (const report of reports) {
65070
- const driftedOutcomes = report.outcomes.filter((o) => o.kind === "drifted");
65196
+ const driftedOutcomes = report.outcomes.flatMap((o) => matchOutcome(o, {
65197
+ drifted: (d) => [d],
65198
+ clean: () => [],
65199
+ notCompared: () => [],
65200
+ unsupported: () => [],
65201
+ skipped: () => []
65202
+ }));
65071
65203
  if (driftedOutcomes.length === 0) continue;
65072
65204
  await lockManager.acquireLock(report.stackName, report.region, owner, "drift-accept");
65073
65205
  try {
@@ -65685,7 +65817,13 @@ async function runRevert(reports, providerRegistry, stateBackend, stateConfig, a
65685
65817
  let totalSucceeded = 0;
65686
65818
  let totalUnresolvable = 0;
65687
65819
  for (const report of reports) {
65688
- const driftedOutcomes = report.outcomes.filter((o) => o.kind === "drifted");
65820
+ const driftedOutcomes = report.outcomes.flatMap((o) => matchOutcome(o, {
65821
+ drifted: (d) => [d],
65822
+ clean: () => [],
65823
+ notCompared: () => [],
65824
+ unsupported: () => [],
65825
+ skipped: () => []
65826
+ }));
65689
65827
  if (driftedOutcomes.length === 0) continue;
65690
65828
  await lockManager.acquireLock(report.stackName, report.region, owner, "drift-revert");
65691
65829
  const narrowedByLogicalId = /* @__PURE__ */ new Map();
@@ -65836,7 +65974,13 @@ async function runRevert(reports, providerRegistry, stateBackend, stateConfig, a
65836
65974
  */
65837
65975
  function printAcceptPlan(reports) {
65838
65976
  for (const report of reports) {
65839
- const drifted = report.outcomes.filter((o) => o.kind === "drifted");
65977
+ const drifted = report.outcomes.flatMap((o) => matchOutcome(o, {
65978
+ drifted: (d) => [d],
65979
+ clean: () => [],
65980
+ notCompared: () => [],
65981
+ unsupported: () => [],
65982
+ skipped: () => []
65983
+ }));
65840
65984
  if (drifted.length === 0) continue;
65841
65985
  process.stdout.write(`\nPlan (--accept): update cdkd state for ${report.stackName} (${report.region}):\n`);
65842
65986
  for (const o of drifted) {
@@ -65859,7 +66003,13 @@ function printAcceptPlan(reports) {
65859
66003
  */
65860
66004
  function printRevertPlan(reports) {
65861
66005
  for (const report of reports) {
65862
- const drifted = report.outcomes.filter((o) => o.kind === "drifted");
66006
+ const drifted = report.outcomes.flatMap((o) => matchOutcome(o, {
66007
+ drifted: (d) => [d],
66008
+ clean: () => [],
66009
+ notCompared: () => [],
66010
+ unsupported: () => [],
66011
+ skipped: () => []
66012
+ }));
65863
66013
  if (drifted.length === 0) continue;
65864
66014
  process.stdout.write(`\nPlan (--revert): push cdkd state values back into AWS for ${report.stackName} (${report.region}):\n`);
65865
66015
  for (const o of drifted) {
@@ -65869,7 +66019,7 @@ function printRevertPlan(reports) {
65869
66019
  const stateResource = report.state.resources[o.logicalId];
65870
66020
  if (stateResource) {
65871
66021
  const preserved = findRevertPreservedTagKeys(o.changes, stateResource.observedProperties ?? stateResource.properties ?? {}, o.awsProperties);
65872
- const cannotMaskKeys = o.referencesUnresolved;
66022
+ const cannotMaskKeys = o.notComparedCause !== void 0;
65873
66023
  if (cannotMaskKeys && preserved.length > 0) process.stdout.write(` ! ${preserved.length} AWS-authored tag(s) will be preserved, but cdkd could not resolve this resource's dynamic reference(s), so their names are withheld — they come from the live readback and cannot be checked for secrets without them.\n`);
65874
66024
  if (!cannotMaskKeys && preserved.length > 0) {
65875
66025
  const tagWord = preserved.length === 1 ? "tag" : "tags";
@@ -65881,7 +66031,7 @@ function printRevertPlan(reports) {
65881
66031
  }
65882
66032
  if (stateResource && stateResource.observedProperties === void 0) {
65883
66033
  const unbaselined = findRevertUnbaselinedAwsKeys(o.changes, stateResource.properties ?? {}, o.awsProperties);
65884
- if (o.referencesUnresolved && unbaselined.length > 0) process.stdout.write(` ! ${unbaselined.length} AWS-authored value(s) will be left untouched, but cdkd could not resolve this resource's dynamic reference(s), so their paths are withheld — they come from the live readback and cannot be checked for secrets without them.\n`);
66034
+ if (o.notComparedCause !== void 0 && unbaselined.length > 0) process.stdout.write(` ! ${unbaselined.length} AWS-authored value(s) will be left untouched, but cdkd could not resolve this resource's dynamic reference(s), so their paths are withheld — they come from the live readback and cannot be checked for secrets without them.\n`);
65885
66035
  else if (unbaselined.length > 0) {
65886
66036
  const word = unbaselined.length === 1 ? "value" : "values";
65887
66037
  process.stdout.write(` ! this resource has no observed-capture baseline, so the revert pushes the raw TEMPLATE and LEAVES ${unbaselined.length} AWS-authored ${word} untouched:\n`);
@@ -65923,28 +66073,44 @@ async function confirmPrompt$5(prompt) {
65923
66073
  }
65924
66074
  function writeJsonReport(reports) {
65925
66075
  const payload = reports.map((r) => {
65926
- const drifted = r.outcomes.filter((o) => o.kind === "drifted").map((o) => ({
65927
- logicalId: o.logicalId,
65928
- type: o.resourceType,
65929
- changes: o.changes,
65930
- referencesUnresolved: o.referencesUnresolved
65931
- }));
65932
- const clean = r.outcomes.filter((o) => o.kind === "clean").map((o) => ({
65933
- logicalId: o.logicalId,
65934
- type: o.resourceType,
65935
- referencesUnresolved: o.referencesUnresolved
65936
- }));
66076
+ const drifted = [];
66077
+ const clean = [];
66078
+ const notSupported = [];
66079
+ const skipped = [];
66080
+ for (const o of r.outcomes) matchOutcome(o, {
66081
+ drifted: (d) => {
66082
+ drifted.push({
66083
+ logicalId: d.logicalId,
66084
+ type: d.resourceType,
66085
+ changes: d.changes,
66086
+ referencesUnresolved: d.notComparedCause !== void 0
66087
+ });
66088
+ },
66089
+ clean: (c) => {
66090
+ clean.push({
66091
+ logicalId: c.logicalId,
66092
+ type: c.resourceType,
66093
+ referencesUnresolved: false
66094
+ });
66095
+ },
66096
+ notCompared: () => {},
66097
+ unsupported: (u) => {
66098
+ notSupported.push({
66099
+ logicalId: u.logicalId,
66100
+ type: u.resourceType
66101
+ });
66102
+ },
66103
+ skipped: (sk) => {
66104
+ skipped.push({
66105
+ logicalId: sk.logicalId,
66106
+ type: sk.resourceType
66107
+ });
66108
+ }
66109
+ });
65937
66110
  const notCompared = notComparedOutcomes(r).map((o) => ({
65938
66111
  logicalId: o.logicalId,
65939
- type: o.resourceType
65940
- }));
65941
- const notSupported = r.outcomes.filter((o) => o.kind === "unsupported").map((o) => ({
65942
- logicalId: o.logicalId,
65943
- type: o.resourceType
65944
- }));
65945
- const skipped = r.outcomes.filter((o) => o.kind === "skipped").map((o) => ({
65946
- logicalId: o.logicalId,
65947
- type: o.resourceType
66112
+ type: o.resourceType,
66113
+ referencesUnresolved: true
65948
66114
  }));
65949
66115
  return {
65950
66116
  stack: r.stackName,
@@ -65960,10 +66126,27 @@ function writeJsonReport(reports) {
65960
66126
  }
65961
66127
  function writeHumanReport(reports) {
65962
66128
  for (const report of reports) {
65963
- const drifted = report.outcomes.filter((o) => o.kind === "drifted");
65964
- const unsupported = report.outcomes.filter((o) => o.kind === "unsupported");
65965
- const skippedCount = report.outcomes.filter((o) => o.kind === "skipped").length;
65966
- const inspected = report.outcomes.length - skippedCount;
66129
+ const drifted = [];
66130
+ const unsupported = [];
66131
+ let inspectedCount = 0;
66132
+ for (const o of report.outcomes) matchOutcome(o, {
66133
+ drifted: (d) => {
66134
+ drifted.push(d);
66135
+ inspectedCount += 1;
66136
+ },
66137
+ unsupported: (u) => {
66138
+ unsupported.push(u);
66139
+ inspectedCount += 1;
66140
+ },
66141
+ skipped: () => {},
66142
+ clean: () => {
66143
+ inspectedCount += 1;
66144
+ },
66145
+ notCompared: () => {
66146
+ inspectedCount += 1;
66147
+ }
66148
+ });
66149
+ const inspected = inspectedCount;
65967
66150
  const notCompared = notComparedOutcomes(report);
65968
66151
  const checked = inspected - notCompared.length;
65969
66152
  if (drifted.length === 0) if (notCompared.length > 0) process.stdout.write(`⚠ ${report.stackName} (${report.region}): no drift detected, but ${checked} of ${inspected} resource${inspected === 1 ? "" : "s"} fully checked (${notCompared.length} only partially compared, ${unsupported.length} unsupported)\n`);
@@ -71646,8 +71829,8 @@ async function rollbackCommand(stackArg, options) {
71646
71829
  try {
71647
71830
  await setup.lockManager.acquireLockWithRetry(stackName, region, void 0, "rollback");
71648
71831
  } catch (error) {
71649
- unforwardSigterm();
71650
71832
  process.removeListener("SIGINT", sigintHandler);
71833
+ unforwardSigterm();
71651
71834
  throw error;
71652
71835
  }
71653
71836
  try {
@@ -71792,11 +71975,14 @@ async function rollbackCommand(stackArg, options) {
71792
71975
  if (totalWarnings > 0) throw new PartialFailureError(`Rollback completed with ${totalWarnings} skipped/unrecoverable operation(s) (see warnings above).`);
71793
71976
  logger.info(`\nRollback of '${stackName}' (${region}) complete.`);
71794
71977
  } finally {
71795
- unforwardSigterm();
71796
- process.removeListener("SIGINT", sigintHandler);
71797
- await setup.lockManager.releaseLock(stackName, region).catch((err) => {
71798
- logger.warn(`Failed to release lock for '${stackName}' (${region}): ${err instanceof Error ? err.message : String(err)}`);
71799
- });
71978
+ try {
71979
+ await setup.lockManager.releaseLock(stackName, region).catch((err) => {
71980
+ logger.warn(`Failed to release lock for '${stackName}' (${region}): ${err instanceof Error ? err.message : String(err)}`);
71981
+ });
71982
+ } finally {
71983
+ process.removeListener("SIGINT", sigintHandler);
71984
+ unforwardSigterm();
71985
+ }
71800
71986
  }
71801
71987
  } finally {
71802
71988
  if (stackAwsClients) {
@@ -72919,7 +73105,7 @@ async function scanReferencedAssets(stateBackend, marker, logger) {
72919
73105
  try {
72920
73106
  parsed = JSON.parse(body);
72921
73107
  } catch (error) {
72922
- const described = describeStateKey(key);
73108
+ const described = describeStateKey(key, STATE_FILE_SUFFIX, DEFAULT_STATE_PREFIX);
72923
73109
  const regionMatch = /^(\S+) \((\S+)\)$/.exec(described);
72924
73110
  throw new CdkdError(`State file '${key}' is not valid JSON — aborting: gc must know every referenced asset before deleting anything, and this file's references are unreadable. Repair or remove the corrupt state file ('${regionMatch ? `cdkd state show ${regionMatch[1]} --stack-region ${regionMatch[2]}` : `cdkd state show ${described}`}' to inspect), then re-run.`, "GC_STATE_UNREADABLE", error);
72925
73111
  }
@@ -73124,7 +73310,7 @@ async function gcCommand(options) {
73124
73310
  });
73125
73311
  const stateBackend = new S3StateBackend(markerS3Client, {
73126
73312
  bucket: bucketName,
73127
- prefix: "cdkd"
73313
+ prefix: DEFAULT_STATE_PREFIX
73128
73314
  }, {
73129
73315
  region: effective.region,
73130
73316
  ...options.profile && { profile: options.profile }
@@ -73170,7 +73356,7 @@ async function gcCommand(options) {
73170
73356
  const marker = parseBootstrapMarker(markerBody, resolvedMarkerKey);
73171
73357
  const lockKeys = await listAllLockKeys(stateBackend);
73172
73358
  if (lockKeys.length > 0) {
73173
- const listing = lockKeys.map((k) => ` - ${describeStateKey(k, LOCK_FILE_SUFFIX)} [${k}]`).join("\n");
73359
+ const listing = lockKeys.map((k) => ` - ${describeStateKey(k, LOCK_FILE_SUFFIX, DEFAULT_STATE_PREFIX)} [${k}]`).join("\n");
73174
73360
  throw new CdkdError(`Refusing to gc while ${lockKeys.length} stack(s) hold an active lock (a deploy in flight may have published assets whose state write has not landed yet):\n${listing}\nWait for the operation(s) to finish — or clear a stale lock with 'cdkd force-unlock <stack>' — then re-run 'cdkd gc'.`, "GC_LOCKED");
73175
73361
  }
73176
73362
  const refs = await scanReferencedAssets(stateBackend, marker, logger);
@@ -84972,7 +85158,7 @@ async function promptConfirm(message) {
84972
85158
  * callers get the standard exit-code-2 routing.
84973
85159
  */
84974
85160
  function createMigrateCommand() {
84975
- return new Command("migrate").description("Adopt a plain (non-CDK) CloudFormation stack into a cdkd-managed CDK app. Generates new CDK code via upstream `cdk migrate`, builds a logical-ID mapping between the source CFn template and the synth template, writes cdkd state, and (optionally) retires the source CFn stack. AWS resources are never modified.").argument("[stack]", "Source CFn stack name. Alias for --from-cfn-stack.").addOption(new Option("--from-cfn-stack <name>", "Source CloudFormation stack name to adopt. Required (or pass positionally).")).addOption(new Option("--output-dir <dir>", "Directory to write the generated CDK app to. Defaults to <cwd>/<CfnStackName>.")).addOption(new Option("--language <choice>", "Generated code language. v1: typescript only.").choices(["typescript"]).default("typescript")).addOption(new Option("--region <region>", "AWS region. Defaults to AWS_REGION env / profile.")).addOption(new Option("--account <id>", "AWS account ID. Auto-detected via STS when omitted.")).addOption(new Option("--retire-cfn-stack", "After cdkd state is written, inject DeletionPolicy=Retain on every resource in the source CFn stack and DeleteStack. AWS resources stay; the CFn stack record is gone. Off by default.").default(false)).addOption(new Option("--filter <key=value>", "Pass-through to `cdk migrate --filter` for resource subsetting. Repeatable.").argParser((value, previous) => [...previous ?? [], value]).default([])).addOption(new Option("--skip-install", "Skip `npm install` after codegen.").default(false)).addOption(new Option("--skip-synth", "Skip `cdk synth` (does NOT write cdkd state). Mutually exclusive with --retire-cfn-stack.").default(false)).addOption(new Option("--dry-run", "Print the import plan without writing state or retiring the CFn stack. Mutually exclusive with --retire-cfn-stack.").default(false)).addOption(new Option("-y, --yes", "Auto-confirm the import + retirement prompts.").default(false)).addOption(new Option("--cdk-bin <path>", "Override the `cdk` binary path.")).addOption(new Option("--resource-mapping <file>", `Path to a JSON file of {sourceLogicalId: synthLogicalId} overrides. Same shape as the auto-written ${RESOURCE_MAPPING_FILENAME}.`)).addOption(new Option("--state-bucket <name>", "cdkd state bucket. Defaults to cdkd-state-<accountId>.")).addOption(new Option("--state-prefix <prefix>", "cdkd state prefix inside the bucket.").default("cdkd")).addOption(new Option("--profile <name>", "AWS profile name.")).addOption(new Option("--role-arn <arn>", "IAM role to assume before any AWS call.")).addOption(new Option("--verbose", "Enable debug-level logging.").default(false)).action(withErrorHandling(migrateCommandAction));
85161
+ return new Command("migrate").description("Adopt a plain (non-CDK) CloudFormation stack into a cdkd-managed CDK app. Generates new CDK code via upstream `cdk migrate`, builds a logical-ID mapping between the source CFn template and the synth template, writes cdkd state, and (optionally) retires the source CFn stack. AWS resources are never modified.").argument("[stack]", "Source CFn stack name. Alias for --from-cfn-stack.").addOption(new Option("--from-cfn-stack <name>", "Source CloudFormation stack name to adopt. Required (or pass positionally).")).addOption(new Option("--output-dir <dir>", "Directory to write the generated CDK app to. Defaults to <cwd>/<CfnStackName>.")).addOption(new Option("--language <choice>", "Generated code language. v1: typescript only.").choices(["typescript"]).default("typescript")).addOption(new Option("--region <region>", "AWS region. Defaults to AWS_REGION env / profile.")).addOption(new Option("--account <id>", "AWS account ID. Auto-detected via STS when omitted.")).addOption(new Option("--retire-cfn-stack", "After cdkd state is written, inject DeletionPolicy=Retain on every resource in the source CFn stack and DeleteStack. AWS resources stay; the CFn stack record is gone. Off by default.").default(false)).addOption(new Option("--filter <key=value>", "Pass-through to `cdk migrate --filter` for resource subsetting. Repeatable.").argParser((value, previous) => [...previous ?? [], value]).default([])).addOption(new Option("--skip-install", "Skip `npm install` after codegen.").default(false)).addOption(new Option("--skip-synth", "Skip `cdk synth` (does NOT write cdkd state). Mutually exclusive with --retire-cfn-stack.").default(false)).addOption(new Option("--dry-run", "Print the import plan without writing state or retiring the CFn stack. Mutually exclusive with --retire-cfn-stack.").default(false)).addOption(new Option("-y, --yes", "Auto-confirm the import + retirement prompts.").default(false)).addOption(new Option("--cdk-bin <path>", "Override the `cdk` binary path.")).addOption(new Option("--resource-mapping <file>", `Path to a JSON file of {sourceLogicalId: synthLogicalId} overrides. Same shape as the auto-written ${RESOURCE_MAPPING_FILENAME}.`)).addOption(new Option("--state-bucket <name>", "cdkd state bucket. Defaults to cdkd-state-<accountId>.")).addOption(new Option("--state-prefix <prefix>", "cdkd state prefix inside the bucket.").default(DEFAULT_STATE_PREFIX)).addOption(new Option("--profile <name>", "AWS profile name.")).addOption(new Option("--role-arn <arn>", "IAM role to assume before any AWS call.")).addOption(new Option("--verbose", "Enable debug-level logging.").default(false)).action(withErrorHandling(migrateCommandAction));
84976
85162
  }
84977
85163
 
84978
85164
  //#endregion
@@ -84995,7 +85181,7 @@ function createMigrateCommand() {
84995
85181
  */
84996
85182
  function buildProgram() {
84997
85183
  const program = new Command();
84998
- program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.284.29");
85184
+ program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.284.31");
84999
85185
  program.hook("preAction", (_thisCommand, actionCommand) => {
85000
85186
  const { profile } = actionCommand.optsWithGlobals();
85001
85187
  if (profile !== void 0) process.env["AWS_PROFILE"] = profile;