@go-to-k/cdkd 0.284.53 → 0.284.55

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.
@@ -1,7 +1,7 @@
1
- import { $ as startInterruptWatch, $n as AwsClients, $t as shouldRetainResource, A as renderStatefulReason, An as getLegacyStateBucketName, Ar as isThrottlingError, At as redactSecretsForState, B as exportAliasCollisionScrubWarning, Bn as CFN_TEMPLATE_BODY_LIMIT, Bt as DiffCalculator, C as isFinalSnapshotError, Cn as runDockerForeground, Ct as TEMPLATE_SOURCED_RULES, D as extractDeploymentEventError, Dn as Synthesizer, Dr as withErrorHandling, Dt as isSingleDynamicReferenceToken, E as makeCanonicalizePropertiesFn, En as getDockerImageBySourceHash, Er as normalizeAwsError, Et as errorCauseChain, F as green, Fn as resolveStateBucketWithDefault, Ft as s3BucketDomainName, G as collectInlinePolicyNamesManagedBySiblings, Gn as expectedOwnerParam, Gt as TemplateParser, H as secretBearingStateKeyWarning, Hn as MIGRATE_TMP_PREFIX, Ht as describeTypeWithThrottleRetry, I as red, In as resolveStateBucketWithDefaultAndSource, It as s3BucketDualStackDomainName, J as findActionableSilentDrops, Jn as derivePartitionAndUrlSuffix, Jt as S3StateBackend, K as clearOnUpdateRemoval, Kn as PARTITION_TABLE, Kt as LockManager, L as yellow, Ln as resolveUseCdkBootstrapAssets, Lt as s3BucketRegionalDomainName, M as bold, Mn as resolveAutoAssetStorage, Mt as classifyReplaySecretRegion, N as cyan, Nn as resolveCaptureObservedState, Nt as producerRegionsFromState, O as MULTI_REGION_RECREATE_BLOCKED_TYPES, On as synthesisStatusMessage, Or as isMarkedNonRetryable, Ot as maskSecretsInError, P as gray, Pn as resolveSkipPrefix, Pt as s3BucketArn, Q as isInterruptedWaitError, Qn as resolveBucketRegion, Qt as importableOutputs, R as collectDeclaredOutputNames, Rn as stateBucketExistenceConfirmed, Rt as s3BucketWebsiteUrl, S as createPreDeleteFinalSnapshot, Sn as partitionSensitiveEnv, St as STATE_SOURCED_READBACK_RULES, T as unsupportedFinalSnapshotError, Tn as AssetManifestLoader, Tt as dynamicReferenceTokens, U as stateKeySecretExposure, Un as findLargeInlineResources, Ut as withRetry, V as isExportAliasCollision, Vn as CFN_TEMPLATE_URL_LIMIT, Vt as INTRINSIC_KEYS, W as IAMRoleProvider, Wn as uploadCfnTemplate, Wt as DagBuilder, X as beginCommandInterruptScope, Xn as processStackMessages, Xt as exportNamesCarriedFrom, Y as findSilentDropProperties, Yn as AssemblyReader, Yt as rebuildClientForBucketRegion, Z as endCommandInterruptScope, Zt as importableOutputKeys, _ as computeImplicitDeleteEdges, _n as buildDenyExternalAccessPolicy, _r as ProvisioningError, _t as replayWarn, a as DeploymentEventsStore, an as loadPublishableAssetManifest, at as carriesDynamicReference, b as buildFinalSnapshotIdentifier, bn as formatDockerLoginError, br as StackHasActiveImportsError, bt as requireConfigString, c as replayFailedOperations, cn as stripControlChars, cr as DeployCancelledError, ct as refStateLookupFromResource, d as updatePartialReason, dn as ensureAssetStorage, dr as LocalMigrateError, dt as resolveExplicitPhysicalId, en as AssetPublisher, er as getAwsClients, et as CloudControlProvider, f as UNSPECIFIED_SKIP_REASON, fn as getBootstrapMarkerKey, fr as LocalStartServiceError, ft as assertRegionMatch, g as IMPLICIT_DELETE_DEPENDENCIES, gn as validateContainerRepoName, gr as PartialFailureError, gt as readConfigString, h as maskingRetryLogger, hn as validateAssetBucketName, hr as NestedStackChildDirectDestroyError, ht as configStringRefusal, i as DeploymentEventsReader, in as createAssetRedirectResolver, ir as CdkdError, it as IntrinsicFunctionResolver, j as formatResourceLine, jn as resolveApp, jr as markNonRetryable, jt as scrubResourceRecord, k as isStatefulRecreateTargetSync, kn as getDefaultStateBucketName, kr as isRetryableTransientError, kt as maskSecretsInText, l as replayRollback, ln as AssetModeResolver, lr as DynamicReferenceRegionAmbiguousError, lt as WAFv2WebACLProvider, m as withResourceDeadline, mn as readBootstrapMarkerBody, mr as MissingCdkCliError, mt as configBooleanRefusal, n as DEFAULT_RESOURCE_WARN_AFTER_MS, nn as WorkGraph, nr as setAwsClients, nt as disableInstanceApiTermination, o as planFailedOps, on as rewriteTemplateAssetReferences, or as CrossAccountSecretRefusalError, ot as cfnRefValueFromPhysicalId, p as deleteSkipReason, pn as parseBootstrapMarker, pt as coerceCfnBoolean, q as ProviderRegistry, qn as canonicalizeRegion, qt as displaySafe, r as DeployEngine, rn as buildAssetRedirectMap, rt as isTerminationProtectionPropagationError, s as planRollback, sn as escapeRegExp, st as getAccountInfo, t as DEFAULT_RESOURCE_TIMEOUT_MS, tn as stringifyValue, tr as resetAwsClients, tt as slowCcOperationTimeoutMs, u as updatePartialMessage, un as BOOTSTRAP_MARKER_PREFIX, ur as LocalInvokeBuildError$1, ut as normalizeAwsTagsToCfn, v as ATOMIC_FINAL_SNAPSHOT_TYPES, vn as buildDockerImage, vr as ResourceTimeoutError, vt as requireConfigArray, w as refusesFinalSnapshot, wn as runDockerStreaming, wt as createSecretMasker, x as ccRoutedFinalSnapshotError, xn as getDockerCmd, xr as StackTerminationProtectionError, xt as STATE_SOURCED_CROSS_GENERATION_RULES, y as PRE_DELETE_SNAPSHOT_TYPES, yn as dockerSpawnEnvWithSensitive, yr as ResourceUpdateNotSupportedError, yt as requireConfigObject, z as collectPublishedOutputNames, zn as warnDeprecatedNoPrefixCliFlag, zt as applyRoleArnIfSet } from "./deploy-engine-DOP4V6eK.js";
1
+ import { $ as endCommandInterruptScope, $n as processStackMessages, $t as exportNamesCarriedFrom, A as renderStatefulReason, An as Synthesizer, Ar as withErrorHandling, At as isSingleDynamicReferenceToken, B as exportAliasCollisionScrubWarning, Bn as resolveUseCdkBootstrapAssets, Bt as s3BucketRegionalDomainName, C as isFinalSnapshotError, Cn as formatDockerLoginError, Cr as StackHasActiveImportsError, Ct as requireConfigString, D as extractDeploymentEventError, Dn as runDockerStreaming, Dt as createSecretMasker, E as makeCanonicalizePropertiesFn, En as runDockerForeground, Et as TEMPLATE_SOURCED_RULES, F as green, Fn as resolveAutoAssetStorage, Ft as classifyReplaySecretRegion, G as collectInlinePolicyNamesManagedBySiblings, Gn as MIGRATE_TMP_PREFIX, Gt as describeTypeWithThrottleRetry, H as secretBearingStateKeyWarning, Hn as warnDeprecatedNoPrefixCliFlag, Ht as applyRoleArnIfSet, I as red, In as resolveCaptureObservedState, It as producerRegionsFromState, J as findActionableSilentDrops, Jn as expectedOwnerParam, Jt as TemplateParser, K as clearOnUpdateRemoval, Kn as findLargeInlineResources, Kt as withRetry, L as yellow, Ln as resolveSkipPrefix, Lt as s3BucketArn, M as bold, Mn as getDefaultStateBucketName, Mr as isRetryableTransientError, Mt as maskSecretsInText, N as cyan, Nn as getLegacyStateBucketName, Nr as isThrottlingError, Nt as redactSecretsForState, O as MULTI_REGION_RECREATE_BLOCKED_TYPES, On as AssetManifestLoader, Ot as dynamicReferenceTokens, P as gray, Pn as resolveApp, Pr as markNonRetryable, Pt as scrubResourceRecord, Q as beginCommandInterruptScope, Qn as AssemblyReader, Qt as rebuildClientForBucketRegion, R as collectDeclaredOutputNames, Rn as resolveStateBucketWithDefault, Rt as s3BucketDomainName, S as createPreDeleteFinalSnapshot, Sn as dockerSpawnEnvWithSensitive, Sr as ResourceUpdateNotSupportedError, St as requireConfigObject, T as unsupportedFinalSnapshotError, Tn as partitionSensitiveEnv, Tt as STATE_SOURCED_READBACK_RULES, U as stateKeySecretExposure, Un as CFN_TEMPLATE_BODY_LIMIT, Ut as DiffCalculator, V as isExportAliasCollision, Vn as stateBucketExistenceConfirmed, Vt as s3BucketWebsiteUrl, W as IAMRoleProvider, Wn as CFN_TEMPLATE_URL_LIMIT, Wt as INTRINSIC_KEYS, X as CUSTOM_RESOURCE_RESPONSE_PREFIX, Xn as canonicalizeRegion, Xt as displaySafe, Y as findSilentDropProperties, Yn as PARTITION_TABLE, Yt as LockManager, Z as DEFAULT_STATE_PREFIX, Zn as derivePartitionAndUrlSuffix, Zt as S3StateBackend, _ as computeImplicitDeleteEdges, _n as readBootstrapMarkerBody, _r as MissingCdkCliError, _t as configBooleanRefusal, a as DeploymentEventsStore, an as WorkGraph, ar as setAwsClients, at as disableInstanceApiTermination, b as buildFinalSnapshotIdentifier, bn as buildDenyExternalAccessPolicy, br as ProvisioningError, bt as replayWarn, c as replayFailedOperations, cn as loadPublishableAssetManifest, ct as carriesDynamicReference, d as updatePartialReason, dn as stripControlChars, dr as DeployCancelledError, dt as refStateLookupFromResource, en as importableOutputKeys, et as interruptWatchListenerCount, f as UNSPECIFIED_SKIP_REASON, fn as AssetModeResolver, fr as DynamicReferenceRegionAmbiguousError, ft as WAFv2WebACLProvider, g as IMPLICIT_DELETE_DEPENDENCIES, gn as parseBootstrapMarker, gt as coerceCfnBoolean, h as maskingRetryLogger, hn as getBootstrapMarkerKey, hr as LocalStartServiceError, ht as assertRegionMatch, i as DeploymentEventsReader, in as stringifyValue, ir as resetAwsClients, it as slowCcOperationTimeoutMs, j as formatResourceLine, jn as synthesisStatusMessage, jr as isMarkedNonRetryable, jt as maskSecretsInError, k as isStatefulRecreateTargetSync, kn as getDockerImageBySourceHash, kr as normalizeAwsError, kt as errorCauseChain, l as replayRollback, ln as rewriteTemplateAssetReferences, lr as CrossAccountSecretRefusalError, lt as cfnRefValueFromPhysicalId, m as withResourceDeadline, mn as ensureAssetStorage, mr as LocalMigrateError, mt as resolveExplicitPhysicalId, n as DEFAULT_RESOURCE_WARN_AFTER_MS, nn as shouldRetainResource, nr as AwsClients, nt as startInterruptWatch, o as planFailedOps, on as buildAssetRedirectMap, ot as isTerminationProtectionPropagationError, p as deleteSkipReason, pn as BOOTSTRAP_MARKER_PREFIX, pr as LocalInvokeBuildError$1, pt as normalizeAwsTagsToCfn, q as ProviderRegistry, qn as uploadCfnTemplate, qt as DagBuilder, r as DeployEngine, rn as AssetPublisher, rr as getAwsClients, rt as CloudControlProvider, s as planRollback, sn as createAssetRedirectResolver, sr as CdkdError, st as IntrinsicFunctionResolver, t as DEFAULT_RESOURCE_TIMEOUT_MS, tn as importableOutputs, tr as resolveBucketRegion, tt as isInterruptedWaitError, u as updatePartialMessage, un as escapeRegExp, ut as getAccountInfo, v as ATOMIC_FINAL_SNAPSHOT_TYPES, vn as validateAssetBucketName, vr as NestedStackChildDirectDestroyError, vt as configStringRefusal, w as refusesFinalSnapshot, wn as getDockerCmd, wr as StackTerminationProtectionError, wt as STATE_SOURCED_CROSS_GENERATION_RULES, x as ccRoutedFinalSnapshotError, xn as buildDockerImage, xr as ResourceTimeoutError, xt as requireConfigArray, y as PRE_DELETE_SNAPSHOT_TYPES, yn as validateContainerRepoName, yr as PartialFailureError, yt as readConfigString, z as collectPublishedOutputNames, zn as resolveStateBucketWithDefaultAndSource, zt as s3BucketDualStackDomainName } from "./deploy-engine-B1tSPv8F.js";
2
2
  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";
3
- import { t as getCdkdVersion } from "./version-CaIqvSUG.js";
4
- import { t as ASGProvider } from "./asg-provider-Ciurx_UV.js";
3
+ import { t as getCdkdVersion } from "./version-DD5yHFTJ.js";
4
+ import { t as ASGProvider } from "./asg-provider-nddX-RVm.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,23 +77,6 @@ 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/state/state-prefix.ts
81
- /**
82
- * The default S3 key prefix for cdkd state.
83
- *
84
- * Homed in the STATE layer rather than in `src/cli/commands/state-file-keys.ts`
85
- * (which re-exports it, so its four existing importers are unchanged) because
86
- * `src/state/lock-contention-message.ts` needs it to decide whether a recovery
87
- * hint should spell `--state-prefix` at all, and a `src/state/**` module
88
- * importing from `src/cli/commands/**` inverts the layering — the CLI sits
89
- * ABOVE the state layer in the 7-layer architecture, not below it.
90
- *
91
- * Note this is only the DEFAULT. Other commands accept `--state-prefix`, so
92
- * whole-bucket listings deliberately do not scope to it.
93
- */
94
- const DEFAULT_STATE_PREFIX = "cdkd";
95
-
96
- //#endregion
97
80
  //#region src/cli/commands/state-file-keys.ts
98
81
  /**
99
82
  * Shared helpers for enumerating and describing cdkd state-bucket keys.
@@ -2072,6 +2055,15 @@ function createListCommand() {
2072
2055
  */
2073
2056
  function forwardSigtermToSigint() {
2074
2057
  beginCommandInterruptScope();
2058
+ try {
2059
+ return installSigtermForwarder();
2060
+ } catch (error) {
2061
+ endCommandInterruptScope();
2062
+ throw error;
2063
+ }
2064
+ }
2065
+ /** The registration half of {@link forwardSigtermToSigint}, after its scope is open. */
2066
+ function installSigtermForwarder() {
2075
2067
  const handler = () => {
2076
2068
  if (process.listenerCount("SIGINT") === 0) process.exit(143);
2077
2069
  process.emit("SIGINT", "SIGINT");
@@ -2082,6 +2074,117 @@ function forwardSigtermToSigint() {
2082
2074
  endCommandInterruptScope();
2083
2075
  };
2084
2076
  }
2077
+ /**
2078
+ * Register a command-scoped SIGINT handler for the destroy commands
2079
+ * (issue #2117).
2080
+ *
2081
+ * `cdkd deploy` has had one of these since issue #1348 (`deploy.ts`'s
2082
+ * `topLevelSigintHandler`). The destroy side had none, so the ONLY channel
2083
+ * carrying "the user interrupted" from `runDestroyForStack` back to the
2084
+ * `--all` loop was `DestroyRunResult.interrupted` — a boolean assigned ONCE,
2085
+ * inside the runner's `try`, after its level loop. Every instant where the
2086
+ * runner's `draining` flag flips AFTER that assignment is an instant where
2087
+ * `cdkd destroy --all` went on to delete the NEXT stack after the user asked
2088
+ * it to stop.
2089
+ *
2090
+ * The runner's outer `finally` re-syncs `result.interrupted ||= draining` to
2091
+ * cover the widest such window (renderer teardown, state flush, lock release),
2092
+ * and that line is marked TACTICAL in its own comment: it narrows the window
2093
+ * rather than removing it. Two gaps survive it, and neither is reachable from
2094
+ * inside the runner at all:
2095
+ *
2096
+ * 1. The runner removes its SIGINT listener in its inner `finally`, BEFORE the
2097
+ * re-sync line and its `return`, so from there on nothing of the runner's is
2098
+ * armed. This watch covers that stretch — by RECORDING once a graceful owner
2099
+ * is gone and the stack is done, and by QUITTING in the mirror-image window
2100
+ * at the START of a stack, where the runner has not armed yet and could not
2101
+ * act on a recorded flag (see `gracefulOwnerArmed` below).
2102
+ * 2. Everything the `--all` loop itself does between two stacks — the
2103
+ * `RUN_FINISHED` event, `eventRecorder.finalize`, `purgeEventsAfterDestroy`
2104
+ * — runs with no runner handler armed at all.
2105
+ *
2106
+ * A handler that lives for the whole command closes both, because it is armed
2107
+ * in every window rather than in the ones the runner happens to span. The two
2108
+ * flags answer different questions and both are kept: `result.interrupted` is
2109
+ * the PER-STACK outcome (it decides that stack's state preservation, its
2110
+ * summary line and its `--purge-events` skip), while this one is the
2111
+ * COMMAND-level "stop" the loop wants.
2112
+ *
2113
+ * Note on the counterpart contract: `deploy-engine.ts` removes its own SIGINT
2114
+ * handler before releasing its lock, which is safe only because `deploy.ts`'s
2115
+ * top-level handler outlives it. The destroy side now has the same property
2116
+ * for the same reason — `destroy-runner.ts`'s `process.removeListener('SIGINT',
2117
+ * sigintHandler)` is no longer the last handler standing, because this one is
2118
+ * disposed only in the command's outermost `finally`.
2119
+ *
2120
+ * @param opts.command the user-facing command name used in the force-quit
2121
+ * notice (`cdkd destroy` / `cdkd state destroy`).
2122
+ */
2123
+ function watchCommandInterrupt(opts) {
2124
+ let interrupted = false;
2125
+ let stacksInFlight = 0;
2126
+ /**
2127
+ * Is some OTHER listener going to handle this signal gracefully?
2128
+ *
2129
+ * Being inside `runStack` is NOT the same question, and assuming it was is a
2130
+ * regression this fix already shipped once: `runDestroyForStack` registers
2131
+ * its own handler about two hundred lines in, AFTER the strong-reference
2132
+ * pre-flight scan (an S3 LIST plus a GET per stack) and the per-stack
2133
+ * confirm prompt. Staying silent across that window swallowed the signal
2134
+ * outright — no notice, no exit, no escalation on a repeat — and because
2135
+ * this watch IS a listener, `interrupt-watch.ts`'s last-listener force-quit
2136
+ * could no longer fire there either. The runner then began with `draining`
2137
+ * false and destroyed the whole stack the user had just asked to stop, which
2138
+ * is issue #2117's own defect one window further in.
2139
+ *
2140
+ * Counting listeners answers the real question, and the two exclusions are
2141
+ * what make the count mean "graceful owner":
2142
+ *
2143
+ * - our own handler, which is not an answer to itself;
2144
+ * - `interrupt-watch.ts`'s shared handler, which is a LATCH rather than a
2145
+ * graceful owner — deferring to it is exactly the swallow above.
2146
+ *
2147
+ * A provider's transient wait listener (CustomResource / ACM / CloudFront /
2148
+ * Route53) does count, and that is correct rather than a false positive:
2149
+ * those exist only DURING a provider wait, which is strictly inside the
2150
+ * runner handler's own lifetime, so one cannot be present while the runner's
2151
+ * is absent.
2152
+ */
2153
+ function gracefulOwnerArmed() {
2154
+ return process.listeners("SIGINT").filter((l) => l !== handler).length - interruptWatchListenerCount() > 0;
2155
+ }
2156
+ const handler = () => {
2157
+ if (stacksInFlight > 0 && gracefulOwnerArmed()) {
2158
+ interrupted = true;
2159
+ return;
2160
+ }
2161
+ if (interrupted) {
2162
+ process.stderr.write("\nForce-quit.\n");
2163
+ process.exit(130);
2164
+ }
2165
+ interrupted = true;
2166
+ if (stacksInFlight > 0) {
2167
+ process.stderr.write(`\nInterrupted before ${opts.command} armed its per-stack teardown — quitting now. Nothing was deleted and no stack lock is held.\n`);
2168
+ process.exit(130);
2169
+ }
2170
+ process.stderr.write(`\nInterrupted — ${opts.command} will stop before starting another stack (press Ctrl-C again to force-quit)...\n`);
2171
+ };
2172
+ process.on("SIGINT", handler);
2173
+ return {
2174
+ interrupted: () => interrupted,
2175
+ async runStack(fn) {
2176
+ stacksInFlight += 1;
2177
+ try {
2178
+ return await fn();
2179
+ } finally {
2180
+ stacksInFlight -= 1;
2181
+ }
2182
+ },
2183
+ dispose: () => {
2184
+ process.removeListener("SIGINT", handler);
2185
+ }
2186
+ };
2187
+ }
2085
2188
 
2086
2189
  //#endregion
2087
2190
  //#region src/deployment/recreate-targets.ts
@@ -68228,7 +68331,14 @@ async function destroyCommand(stackArgs, options) {
68228
68331
  ...options.profile && { profile: options.profile }
68229
68332
  });
68230
68333
  setAwsClients(awsClients);
68231
- const unforwardSigterm = forwardSigtermToSigint();
68334
+ const interruptWatch = watchCommandInterrupt({ command: "cdkd destroy" });
68335
+ let unforwardSigterm;
68336
+ try {
68337
+ unforwardSigterm = forwardSigtermToSigint();
68338
+ } catch (error) {
68339
+ interruptWatch.dispose();
68340
+ throw error;
68341
+ }
68232
68342
  try {
68233
68343
  const stateConfig = {
68234
68344
  bucket: stateBucket,
@@ -68288,6 +68398,7 @@ async function destroyCommand(stackArgs, options) {
68288
68398
  let totalErrors = 0;
68289
68399
  let totalSkipped = 0;
68290
68400
  let interrupted = false;
68401
+ const runInterrupted = () => interrupted || interruptWatch.interrupted();
68291
68402
  let stackNames;
68292
68403
  if (options.all) stackNames = candidateStacks.map((s) => s.stackName);
68293
68404
  else if (stackPatterns.length > 0) stackNames = matchStacks(candidateStacks, stackPatterns).map((s) => s.stackName);
@@ -68364,6 +68475,7 @@ async function destroyCommand(stackArgs, options) {
68364
68475
  totalErrors++;
68365
68476
  continue;
68366
68477
  }
68478
+ if (runInterrupted()) break;
68367
68479
  const eventRecorder = startRunRecorder({
68368
68480
  backend: stateBackend,
68369
68481
  stackName,
@@ -68372,7 +68484,7 @@ async function destroyCommand(stackArgs, options) {
68372
68484
  });
68373
68485
  let destroyRunResult = "SUCCEEDED";
68374
68486
  try {
68375
- const result = await withNestedStackContext({
68487
+ const result = await interruptWatch.runStack(() => withNestedStackContext({
68376
68488
  stateBackend,
68377
68489
  lockManager,
68378
68490
  providerRegistry,
@@ -68411,7 +68523,7 @@ async function destroyCommand(stackArgs, options) {
68411
68523
  ...options.resourceWarnAfter?.perTypeMs && { resourceWarnAfterByType: options.resourceWarnAfter.perTypeMs },
68412
68524
  ...options.resourceTimeout?.perTypeMs && { resourceTimeoutByType: options.resourceTimeout.perTypeMs },
68413
68525
  eventRecorder
68414
- }));
68526
+ })));
68415
68527
  totalErrors += result.errorCount;
68416
68528
  totalSkipped += result.skippedCount;
68417
68529
  if (result.interrupted) interrupted = true;
@@ -68438,15 +68550,16 @@ async function destroyCommand(stackArgs, options) {
68438
68550
  await purgeEventsAfterDestroy(new DeploymentEventsReader(stateBackend), stackName, stackTargetRegion, {
68439
68551
  purgeEvents: options.purgeEvents,
68440
68552
  runResult: destroyRunResult,
68441
- interrupted
68553
+ interrupted: runInterrupted()
68442
68554
  }, logger);
68443
- if (interrupted) break;
68555
+ if (runInterrupted()) break;
68444
68556
  }
68445
68557
  if (totalErrors > 0) throw new PartialFailureError(`Destroy completed with ${totalErrors} resource error(s). State preserved — inspect 'cdkd state show <stack>' and re-run 'cdkd destroy' to retry. If the same resource keeps failing, 'cdkd state orphan <stack>' removes the state record without deleting AWS resources.`);
68446
- if (interrupted) throw new PartialFailureError(`Destroy interrupted by Ctrl-C. State preserved — re-run 'cdkd destroy' to finish.`);
68558
+ if (runInterrupted()) throw new PartialFailureError(`Destroy interrupted by Ctrl-C. State preserved — re-run 'cdkd destroy' to finish.`);
68447
68559
  if (totalSkipped > 0) throw new PartialFailureError(`Destroy skipped ${totalSkipped} entr${totalSkipped === 1 ? "y" : "ies"} cdkd could not address, so the underlying resources may still exist in AWS. A skipped nested stack counts as ONE entry and may cover several of its own resources — the per-stack summaries above give the exact breakdown. State preserved (the records are kept). Repair the physicalId in state.json and re-run 'cdkd destroy', or delete the resources by hand and drop the records with 'cdkd state orphan <stack>'.`);
68448
68560
  } finally {
68449
68561
  unforwardSigterm();
68562
+ interruptWatch.dispose();
68450
68563
  awsClients.destroy();
68451
68564
  }
68452
68565
  }
@@ -73014,7 +73127,14 @@ async function stateDestroyCommand(stackArgs, options) {
73014
73127
  registerAllProviders(providerRegistry);
73015
73128
  providerRegistry.setCustomResourceResponseBucket(setup.bucket);
73016
73129
  if (options.allowUnsupportedTypes?.length) providerRegistry.allowUnsupportedTypes(options.allowUnsupportedTypes);
73017
- const unforwardSigterm = forwardSigtermToSigint();
73130
+ const interruptWatch = watchCommandInterrupt({ command: "cdkd state destroy" });
73131
+ let unforwardSigterm;
73132
+ try {
73133
+ unforwardSigterm = forwardSigtermToSigint();
73134
+ } catch (error) {
73135
+ interruptWatch.dispose();
73136
+ throw error;
73137
+ }
73018
73138
  try {
73019
73139
  const stateRefs = await setup.stateBackend.listStacks();
73020
73140
  const knownStackNames = new Set(stateRefs.map((r) => r.stackName));
@@ -73050,6 +73170,7 @@ async function stateDestroyCommand(stackArgs, options) {
73050
73170
  let totalErrors = 0;
73051
73171
  let totalSkipped = 0;
73052
73172
  let interrupted = false;
73173
+ const runInterrupted = () => interrupted || interruptWatch.interrupted();
73053
73174
  for (const stackName of stackNames) {
73054
73175
  const refs = stateRefs.filter((r) => r.stackName === stackName);
73055
73176
  let targets;
@@ -73071,7 +73192,8 @@ async function stateDestroyCommand(stackArgs, options) {
73071
73192
  logger.warn(`No state found for stack ${stackName}${ref.region ? ` in ${ref.region}` : ""}, skipping`);
73072
73193
  continue;
73073
73194
  }
73074
- const result = await withNestedStackContext({
73195
+ if (runInterrupted()) break;
73196
+ const result = await interruptWatch.runStack(() => withNestedStackContext({
73075
73197
  stateBackend: setup.stateBackend,
73076
73198
  lockManager: setup.lockManager,
73077
73199
  providerRegistry,
@@ -73109,19 +73231,20 @@ async function stateDestroyCommand(stackArgs, options) {
73109
73231
  ...options.resourceTimeout?.globalMs !== void 0 && { resourceTimeoutMs: options.resourceTimeout.globalMs },
73110
73232
  ...options.resourceWarnAfter?.perTypeMs && { resourceWarnAfterByType: options.resourceWarnAfter.perTypeMs },
73111
73233
  ...options.resourceTimeout?.perTypeMs && { resourceTimeoutByType: options.resourceTimeout.perTypeMs }
73112
- }));
73234
+ })));
73113
73235
  totalErrors += result.errorCount;
73114
73236
  totalSkipped += result.skippedCount;
73115
73237
  if (result.interrupted) interrupted = true;
73116
- if (interrupted) break;
73238
+ if (runInterrupted()) break;
73117
73239
  }
73118
- if (interrupted) break;
73240
+ if (runInterrupted()) break;
73119
73241
  }
73120
73242
  if (totalErrors > 0) throw new PartialFailureError(`Destroy completed with ${totalErrors} resource error(s). State preserved — inspect 'cdkd state show <stack>' and re-run 'cdkd state destroy' to retry. If the same resource keeps failing, 'cdkd state orphan <stack>' removes the state record without deleting AWS resources.`);
73121
- if (interrupted) throw new PartialFailureError(`Destroy interrupted by Ctrl-C. State preserved — re-run 'cdkd state destroy' to finish.`);
73243
+ if (runInterrupted()) throw new PartialFailureError(`Destroy interrupted by Ctrl-C. State preserved — re-run 'cdkd state destroy' to finish.`);
73122
73244
  if (totalSkipped > 0) throw new PartialFailureError(`Destroy skipped ${totalSkipped} entr${totalSkipped === 1 ? "y" : "ies"} cdkd could not address, so the underlying resources may still exist in AWS. A skipped nested stack counts as ONE entry and may cover several of its own resources — the per-stack summaries above give the exact breakdown. State preserved (the records are kept). Repair the physicalId in state.json and re-run 'cdkd state destroy', or delete the resources by hand and drop the records with 'cdkd state orphan <stack>'.`);
73123
73245
  } finally {
73124
73246
  unforwardSigterm();
73247
+ interruptWatch.dispose();
73125
73248
  setup.dispose();
73126
73249
  }
73127
73250
  }
@@ -74705,9 +74828,19 @@ function looksLikeEcrHostWithForeignSuffix(imageUri) {
74705
74828
  *
74706
74829
  * - Names come from the region's bootstrap marker, never recomputed from
74707
74830
  * the naming convention (custom-name compatibility, issue #1011). No
74708
- * marker → the region is not opted in; friendly no-op. CDK bootstrap
74831
+ * marker → no ASSET storage in scope; since issue #2052 the run continues
74832
+ * to the state-bucket placeholder sweep rather than ending, and is a
74833
+ * friendly no-op only when neither side has anything. CDK bootstrap
74709
74834
  * storage (`cdk-hnb659fds-*`) is never touched — that stays `cdk gc`'s
74710
74835
  * job.
74836
+ * - Abandoned `custom-resource-responses/{requestId}.json` placeholders in
74837
+ * the STATE bucket are collected too (issue #2052). Two things make that
74838
+ * the one arm needing its own guard: it is gc's only DESTRUCTIVE call
74839
+ * against the state bucket, and it is ACCOUNT-scoped where every other arm
74840
+ * is region-scoped (the keys carry no region). It matches the producer's
74841
+ * own key shape rather than sweeping the prefix by age alone, so a stack
74842
+ * deployed with a colliding `--state-prefix` cannot have its `state.json`
74843
+ * collected.
74711
74844
  * - The reference scan lists EVERY state file in the WHOLE state bucket
74712
74845
  * (any `--state-prefix`), and a state file that fails to JSON-parse
74713
74846
  * aborts the whole run — deleting on partial knowledge is how a live
@@ -75162,12 +75295,98 @@ async function listS3Candidates(s3Client, bucket, accountId, refs, cutoffMs, log
75162
75295
  return candidates;
75163
75296
  }
75164
75297
  /**
75165
- * Describe the container repo's images (paginated) and pick the deletion
75166
- * candidates: an image is REFERENCED when any of its tags OR its digest is
75167
- * in the referenced set; candidates are unreferenced AND strictly older
75168
- * than the cutoff. Images with no `imagePushedAt` are kept (treated as
75169
- * new). A missing repo is an idempotent skip.
75170
- */
75298
+ * List abandoned custom-resource response placeholders (issue #2052).
75299
+ *
75300
+ * `CustomResourceProvider` PUTs an empty object at
75301
+ * `custom-resource-responses/{requestId}.json` before each invocation so the
75302
+ * handler has a pre-signed URL to write to. The happy paths clean up after
75303
+ * themselves — the issue #2033 replay path via `cleanupResponseObject`, and the
75304
+ * FAILED-response arm via `getCustomResourceResponse` / `pollS3Response` — but
75305
+ * three shapes leave the key behind and NOTHING collected them:
75306
+ *
75307
+ * - an interrupted deploy (Ctrl-C / SIGTERM / a cancelled CI job) between the
75308
+ * PUT and any cleanup;
75309
+ * - any throw on a path that does not reach a cleanup call;
75310
+ * - a LATE handler PUT, landing after cdkd stopped polling. This is the only
75311
+ * one with CONTENT: an empty body is a storage leak, but a real
75312
+ * CloudFormation `Data` payload sitting at a key nothing collects is also a
75313
+ * data-retention question.
75314
+ *
75315
+ * ## Why age alone is a sufficient staleness rule here
75316
+ *
75317
+ * The requirement is that a key belonging to an IN-FLIGHT concurrent run is
75318
+ * never collected, and two independent things already guarantee it:
75319
+ *
75320
+ * 1. gc refuses outright while ANY stack holds a lock (the lock guard in
75321
+ * `gcCommand`), and every deploy that can write one of these keys holds
75322
+ * one for its whole duration. A concurrent run does not get this far.
75323
+ * 2. `--older-than` (default 30d) is applied to each object's own
75324
+ * `LastModified`, which for a placeholder is the moment of the PUT that
75325
+ * opened the invocation.
75326
+ *
75327
+ * So this deliberately adds no clock of its own. The two are not ranked, and an
75328
+ * earlier version of this note wrongly said (1) was the one that mattered: for
75329
+ * a deploy that STARTS between the lock scan and this listing, (1) has already
75330
+ * run and only (2) covers it. They close different windows.
75331
+ *
75332
+ * ## What this widens, stated rather than implied
75333
+ *
75334
+ * This is gc's first DESTRUCTIVE call against the state bucket — until now it
75335
+ * only ever read from it. And unlike every other arm of this command it is
75336
+ * ACCOUNT-scoped, not region-scoped: placeholder keys carry no region, so
75337
+ * `cdkd gc --region us-east-1` collects ones written by deploys into any
75338
+ * region. Both are deliberate (the objects are cdkd's own transient scratch,
75339
+ * and a region-scoped sweep could never reach most of them), and both are why
75340
+ * the key-shape filter below is not optional.
75341
+ *
75342
+ * ## Scope
75343
+ *
75344
+ * Only the DEFAULT prefix. `ProviderRegistry` can be configured with another
75345
+ * `responsePrefix`, and nothing persists which one a past deploy used, so gc
75346
+ * cannot discover a non-default layout — under-collecting, which is the safe
75347
+ * direction, and the reason the constant is shared with the producer rather
75348
+ * than re-spelled here.
75349
+ */
75350
+ /**
75351
+ * The only key shape `CustomResourceProvider` can mint under the prefix.
75352
+ *
75353
+ * `getResponseKey` builds `{prefix}/{requestId}.json` from
75354
+ * `cdkd-${Date.now()}-${Math.random().toString(36).substring(7)}`.
75355
+ *
75356
+ * The trailing group is `*`, NOT `+`, and that is a measurement rather than
75357
+ * caution: `substring(7)` returns the EMPTY string whenever the base-36
75358
+ * rendering is shorter than eight characters (`(0.5).toString(36)` is `'0.i'`),
75359
+ * so `cdkd-1756180000000-.json` is a real key this provider writes. A `+` here
75360
+ * would skip exactly those and the sweep would silently under-collect — a
75361
+ * shape filter defeating the sweep it is meant to protect.
75362
+ *
75363
+ * Why filter at all: `--state-prefix` is free-form and unvalidated, so a stack
75364
+ * deployed with `--state-prefix custom-resource-responses` puts its
75365
+ * `state.json`, `lock.json`, `rollback-journal.json` and `deployments/*` INSIDE
75366
+ * this prefix. gc builds its own backend on `DEFAULT_STATE_PREFIX` and cannot
75367
+ * see that collision, so an age-only sweep would delete live state and orphan
75368
+ * every resource in the stack. Matching the producer's own shape is what keeps
75369
+ * this command's "bias every ambiguity toward NOT deleting" posture true on the
75370
+ * one path that deletes from the STATE bucket.
75371
+ */
75372
+ const RESPONSE_PLACEHOLDER_KEY = /^cdkd-\d+-[0-9a-z]*\.json$/;
75373
+ async function listResponsePlaceholderCandidates(stateBackend, cutoffMs, logger) {
75374
+ const objects = await stateBackend.listRawObjects(`${CUSTOM_RESOURCE_RESPONSE_PREFIX}/`);
75375
+ const candidates = [];
75376
+ for (const obj of objects) {
75377
+ const leaf = obj.key.slice(`${CUSTOM_RESOURCE_RESPONSE_PREFIX}/`.length);
75378
+ if (!RESPONSE_PLACEHOLDER_KEY.test(leaf)) {
75379
+ logger.debug(`gc: keeping ${obj.key} — not a cdkd response placeholder`);
75380
+ continue;
75381
+ }
75382
+ if (obj.lastModified.getTime() >= cutoffMs) {
75383
+ logger.debug(`gc: keeping ${obj.key} — newer than the --older-than cutoff`);
75384
+ continue;
75385
+ }
75386
+ candidates.push(obj);
75387
+ }
75388
+ return candidates;
75389
+ }
75171
75390
  async function listEcrCandidates(ecrClient, repositoryName, refs, cutoffMs, logger) {
75172
75391
  const candidates = [];
75173
75392
  let nextToken;
@@ -75235,7 +75454,7 @@ function formatAge(date) {
75235
75454
  async function promptGcConfirm(input) {
75236
75455
  const logger = getLogger();
75237
75456
  logger.warn("");
75238
- logger.warn(`cdkd gc will delete the following unreferenced assets in ${input.region}:`);
75457
+ logger.warn(`cdkd gc will delete the following (region: ${input.region}):`);
75239
75458
  for (const line of input.planLines) logger.warn(` - ${line}`);
75240
75459
  if (input.yes) return true;
75241
75460
  if (process.stdin.isTTY !== true) throw new CdkdError("The gc confirmation prompt cannot run in a non-interactive environment. Pass --yes / -y to confirm the deletion, or run the command from a real terminal.", "NON_INTERACTIVE_CONFIRM");
@@ -75351,32 +75570,47 @@ async function gcCommand(options) {
75351
75570
  }
75352
75571
  throw error;
75353
75572
  }
75354
- if (markerBody === null) {
75355
- const probed = rawMarkerKey === markerKey ? markerKey : `${markerKey}, ${rawMarkerKey}`;
75356
- logger.info(`No bootstrap marker for region '${region}' (${probed}) — the region is not opted in to cdkd asset storage; nothing to garbage-collect. (CDK bootstrap storage is 'cdk gc' territory.)`);
75357
- return;
75358
- }
75359
- const marker = parseBootstrapMarker(markerBody, resolvedMarkerKey);
75360
75573
  const lockKeys = await listAllLockKeys(stateBackend);
75361
75574
  if (lockKeys.length > 0) {
75362
75575
  const listing = lockKeys.map((k) => ` - ${describeStateKey(k, LOCK_FILE_SUFFIX, DEFAULT_STATE_PREFIX)} [${k}]`).join("\n");
75363
75576
  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");
75364
75577
  }
75365
- const refs = await scanReferencedAssets(stateBackend, marker, logger);
75366
75578
  const cutoffMs = Date.now() - options.olderThan;
75367
- const s3Candidates = await listS3Candidates(awsClients.s3, marker.assetBucket, accountId, refs, cutoffMs, logger);
75368
- const ecrCandidates = await listEcrCandidates(ecrClient, marker.containerRepo, refs, cutoffMs, logger);
75369
- if (s3Candidates.length === 0 && ecrCandidates.length === 0) {
75370
- logger.info(`Nothing to garbage-collect in region '${region}': every object in ${marker.assetBucket} / image in ${marker.containerRepo} is either referenced by a deployed stack or newer than the --older-than cutoff.`);
75579
+ const responseCandidates = await listResponsePlaceholderCandidates(stateBackend, cutoffMs, logger);
75580
+ if (markerBody === null) {
75581
+ const probed = rawMarkerKey === markerKey ? markerKey : `${markerKey}, ${rawMarkerKey}`;
75582
+ if (responseCandidates.length === 0) {
75583
+ logger.info(`No bootstrap marker for region '${region}' (${probed}) — the region is not opted in to cdkd asset storage; nothing to garbage-collect. (CDK bootstrap storage is 'cdk gc' territory.)`);
75584
+ return;
75585
+ }
75586
+ logger.info(`No bootstrap marker for region '${region}' (${probed}) — the region is not opted in to cdkd asset storage, so no assets are in scope. (CDK bootstrap storage is 'cdk gc' territory.) Continuing with the state bucket's abandoned custom-resource response placeholders.`);
75587
+ }
75588
+ const marker = markerBody === null ? null : parseBootstrapMarker(markerBody, resolvedMarkerKey);
75589
+ let s3Candidates = [];
75590
+ let ecrCandidates = [];
75591
+ if (marker !== null) {
75592
+ const refs = await scanReferencedAssets(stateBackend, marker, logger);
75593
+ s3Candidates = await listS3Candidates(awsClients.s3, marker.assetBucket, accountId, refs, cutoffMs, logger);
75594
+ ecrCandidates = await listEcrCandidates(ecrClient, marker.containerRepo, refs, cutoffMs, logger);
75595
+ }
75596
+ if (marker !== null && s3Candidates.length === 0 && ecrCandidates.length === 0 && responseCandidates.length === 0) {
75597
+ logger.info(`Nothing to garbage-collect in region '${region}': every object in ${marker.assetBucket} / image in ${marker.containerRepo} is either referenced by a deployed stack or newer than the --older-than cutoff, and no abandoned custom-resource response placeholder is older than it either.`);
75371
75598
  return;
75372
75599
  }
75373
75600
  const s3Bytes = s3Candidates.reduce((sum, c) => sum + c.size, 0);
75374
75601
  const ecrBytes = ecrCandidates.reduce((sum, c) => sum + c.size, 0);
75375
- const planLines = [...s3Candidates.map((c) => `s3://${marker.assetBucket}/${c.key} (${formatBytes(c.size)}, ${formatAge(c.lastModified)})`), ...ecrCandidates.map((c) => `${marker.containerRepo}${c.tags.length > 0 ? `:${c.tags.join(",")}` : ""}@${c.digest} (${formatBytes(c.size)}, ${formatAge(c.pushedAt)})`)];
75376
- const totals = `Total: ${s3Candidates.length} S3 object(s) (${formatBytes(s3Bytes)}) + ${ecrCandidates.length} ECR image(s) (${formatBytes(ecrBytes)}) = ${formatBytes(s3Bytes + ecrBytes)} reclaimable`;
75602
+ const responseBytes = responseCandidates.reduce((sum, c) => sum + c.size, 0);
75603
+ const assetBucket = marker !== null ? marker.assetBucket : "";
75604
+ const containerRepo = marker !== null ? marker.containerRepo : "";
75605
+ const planLines = [
75606
+ ...s3Candidates.map((c) => `s3://${assetBucket}/${c.key} (${formatBytes(c.size)}, ${formatAge(c.lastModified)})`),
75607
+ ...ecrCandidates.map((c) => `${containerRepo}${c.tags.length > 0 ? `:${c.tags.join(",")}` : ""}@${c.digest} (${formatBytes(c.size)}, ${formatAge(c.pushedAt)})`),
75608
+ ...responseCandidates.map((c) => `s3://${bucketName}/${c.key} (${formatBytes(c.size)}, ${formatAge(c.lastModified)}) [abandoned custom-resource response]`)
75609
+ ];
75610
+ const totals = `Total: ${s3Candidates.length} S3 object(s) (${formatBytes(s3Bytes)}) + ${ecrCandidates.length} ECR image(s) (${formatBytes(ecrBytes)}) + ${responseCandidates.length} custom-resource response placeholder(s) (${formatBytes(responseBytes)}) = ${formatBytes(s3Bytes + ecrBytes + responseBytes)} reclaimable`;
75377
75611
  if (options.dryRun) {
75378
75612
  logger.info("");
75379
- logger.info(`Dry run — the following unreferenced assets in ${region} would be deleted:`);
75613
+ logger.info(`Dry run — the following would be deleted in ${region}:`);
75380
75614
  for (const line of planLines) logger.info(` - ${line}`);
75381
75615
  logger.info(totals);
75382
75616
  logger.info("Dry run: nothing deleted. Re-run without --dry-run to delete.");
@@ -75390,15 +75624,23 @@ async function gcCommand(options) {
75390
75624
  logger.info("gc cancelled — nothing deleted.");
75391
75625
  return;
75392
75626
  }
75393
- if (s3Candidates.length > 0) {
75627
+ if (marker !== null && s3Candidates.length > 0) {
75394
75628
  await deleteS3Candidates(awsClients.s3, marker.assetBucket, accountId, s3Candidates);
75395
75629
  logger.info(`✓ Deleted ${s3Candidates.length} object(s) (${formatBytes(s3Bytes)}) from ${marker.assetBucket}`);
75396
75630
  }
75397
- if (ecrCandidates.length > 0) {
75631
+ if (marker !== null && ecrCandidates.length > 0) {
75398
75632
  await deleteEcrCandidates(ecrClient, marker.containerRepo, ecrCandidates);
75399
75633
  logger.info(`✓ Deleted ${ecrCandidates.length} image(s) (${formatBytes(ecrBytes)}) from ${marker.containerRepo}`);
75400
75634
  }
75401
- logger.info(`\n✓ gc completed: ${formatBytes(s3Bytes + ecrBytes)} reclaimed`);
75635
+ if (responseCandidates.length > 0) {
75636
+ try {
75637
+ await stateBackend.deleteRawObjects(responseCandidates.map((c) => c.key));
75638
+ } catch (deleteError) {
75639
+ throw new CdkdError(`Failed to delete ${responseCandidates.length} abandoned custom-resource response placeholder(s) from ${bucketName}: ${deleteError instanceof Error ? deleteError.message : String(deleteError)}`, "GC_DELETE_FAILED");
75640
+ }
75641
+ logger.info(`✓ Deleted ${responseCandidates.length} abandoned custom-resource response placeholder(s) (${formatBytes(responseBytes)}) from ${bucketName}`);
75642
+ }
75643
+ logger.info(`\n✓ gc completed: ${formatBytes(s3Bytes + ecrBytes + responseBytes)} reclaimed`);
75402
75644
  } finally {
75403
75645
  ecrClient.destroy();
75404
75646
  markerS3Client.destroy();
@@ -75409,7 +75651,7 @@ async function gcCommand(options) {
75409
75651
  * Create the `cdkd gc` command (upstream `cdk gc` parity naming).
75410
75652
  */
75411
75653
  function createGcCommand() {
75412
- const cmd = new Command("gc").description("Garbage-collect unreferenced objects / images from ONE region's cdkd-owned asset storage (asset bucket + container-asset ECR repo). References are collected from every cdkd state file; CDK bootstrap storage is never touched (use cdk gc for that).").option("--state-bucket <bucket>", "S3 bucket holding cdkd state (default: cdkd-state-{accountId})").addOption(new Option("--older-than <duration>", "Never delete an object / image newer than this age, even when unreferenced (protects in-flight publishes and recent rollback targets). Accepts <n>d / <n>h.").default(DEFAULT_OLDER_THAN_MS, "30d").argParser(parseOlderThan)).option("--dry-run", "Print the reclaim plan (per-item list + totals) without deleting", false).addOption(new Option("--region <region>", "Region whose cdkd asset storage to garbage-collect (defaults to AWS_REGION, then AWS_DEFAULT_REGION, then your AWS profile's region, then us-east-1)")).action(withErrorHandling(async (options) => {
75654
+ const cmd = new Command("gc").description("Garbage-collect unreferenced objects / images from ONE region's cdkd-owned asset storage (asset bucket + container-asset ECR repo), plus abandoned custom-resource response placeholders in the state bucket (account-wide). References are collected from every cdkd state file; CDK bootstrap storage is never touched (use cdk gc).").option("--state-bucket <bucket>", "S3 bucket holding cdkd state (default: cdkd-state-{accountId})").addOption(new Option("--older-than <duration>", "Never delete an object / image newer than this age, even when unreferenced (protects in-flight publishes and recent rollback targets). Accepts <n>d / <n>h.").default(DEFAULT_OLDER_THAN_MS, "30d").argParser(parseOlderThan)).option("--dry-run", "Print the reclaim plan (per-item list + totals) without deleting", false).addOption(new Option("--region <region>", "Region whose cdkd asset storage to garbage-collect (defaults to AWS_REGION, then AWS_DEFAULT_REGION, then your AWS profile's region, then us-east-1)")).action(withErrorHandling(async (options) => {
75413
75655
  await gcCommand(options);
75414
75656
  }));
75415
75657
  commonOptions.forEach((opt) => cmd.addOption(opt));
@@ -87262,4 +87504,4 @@ function buildProgram() {
87262
87504
 
87263
87505
  //#endregion
87264
87506
  export { buildProgram };
87265
- //# sourceMappingURL=program-9yMSf-LQ.js.map
87507
+ //# sourceMappingURL=program-CTreQYc-.js.map