@go-to-k/cdkd 0.237.0 → 0.238.1

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
@@ -2,7 +2,7 @@
2
2
  import { a as getLiveRenderer, c as PATTERN_B_RESOURCE_TYPES, d as generateResourceNameWithFallback, f as withSkipPrefix, i as runStackBuffered, n as getLogger, o as PATTERN_B_NAME_OPTIONS, p as withStackName, s as PATTERN_B_NAME_PROPERTIES, u as generateResourceName } from "./logger-BzO-joNR.js";
3
3
  import { A as withErrorHandling, C as StackHasActiveImportsError, S as ResourceUpdateNotSupportedError, _ as MissingCdkCliError, a as normalizeAwsTagsToCfn, b as ProvisioningError, f as LocalInvokeBuildError$1, i as matchesCdkPath, k as normalizeAwsError, l as CdkdError, m as LocalStartServiceError, n as isTerminationProtectionPropagationError, o as resolveExplicitPhysicalId, p as LocalMigrateError, r as CDK_PATH_TAG, s as assertRegionMatch, t as disableInstanceApiTermination, v as NestedStackChildDirectDestroyError, w as StackTerminationProtectionError, x as ResourceTimeoutError, y as PartialFailureError } from "./ec2-termination-protection-q-WGWgxa.js";
4
4
  import { a as setAwsClients, i as resetAwsClients, r as getAwsClients, t as AwsClients } from "./aws-clients-B15NAPbL.js";
5
- import { $ as getDockerCmd, A as DiffCalculator, B as buildAssetRedirectMap, C as findActionableSilentDrops, Ct as resolveBucketRegion, D as refStateLookupFromResource, E as cfnRefValueFromPhysicalId, F as rebuildClientForBucketRegion, G as BOOTSTRAP_MARKER_PREFIX, H as loadPublishableAssetManifest, I as shouldRetainResource, J as parseBootstrapMarker, K as ensureAssetStorage, L as AssetPublisher, M as TemplateParser, N as LockManager, O as WAFv2WebACLProvider, P as S3StateBackend, Q as formatDockerLoginError, R as stringifyValue, S as ProviderRegistry, T as IntrinsicFunctionResolver, U as rewriteTemplateAssetReferences, V as createAssetRedirectResolver, W as AssetModeResolver, X as validateContainerRepoName, Y as validateAssetBucketName, Z as buildDockerImage, _ as green, _t as CFN_TEMPLATE_URL_LIMIT, a as withRetry, at as synthesisStatusMessage, b as IAMRoleProvider, bt as uploadCfnTemplate, c as computeImplicitDeleteEdges, ct as resolveApp, d as isStatefulRecreateTargetSync, dt as resolveSkipPrefix, et as runDockerForeground, f as renderStatefulReason, ft as resolveStateBucketWithDefault, g as gray, gt as CFN_TEMPLATE_BODY_LIMIT, h as cyan, ht as warnDeprecatedNoPrefixCliFlag, i as withResourceDeadline, it as Synthesizer, j as DagBuilder, k as applyRoleArnIfSet, l as extractDeploymentEventError, lt as resolveAutoAssetStorage, m as bold, mt as resolveUseCdkBootstrapAssets, n as DEFAULT_RESOURCE_WARN_AFTER_MS, nt as AssetManifestLoader, o as isRetryableTransientError, ot as getDefaultStateBucketName, p as formatResourceLine, pt as resolveStateBucketWithDefaultAndSource, q as getBootstrapMarkerKey, r as DeployEngine, rt as getDockerImageBySourceHash, s as IMPLICIT_DELETE_DEPENDENCIES, st as getLegacyStateBucketName, t as DEFAULT_RESOURCE_TIMEOUT_MS, tt as runDockerStreaming, u as MULTI_REGION_RECREATE_BLOCKED_TYPES, ut as resolveCaptureObservedState, v as red, vt as MIGRATE_TMP_PREFIX, w as CloudControlProvider, x as collectInlinePolicyNamesManagedBySiblings, xt as AssemblyReader, y as yellow, yt as findLargeInlineResources, z as WorkGraph } from "./deploy-engine-CX6s1TcX.js";
5
+ import { $ as getDockerCmd, A as DiffCalculator, B as buildAssetRedirectMap, C as findActionableSilentDrops, Ct as resolveBucketRegion, D as refStateLookupFromResource, E as cfnRefValueFromPhysicalId, F as rebuildClientForBucketRegion, G as BOOTSTRAP_MARKER_PREFIX, H as loadPublishableAssetManifest, I as shouldRetainResource, J as parseBootstrapMarker, K as ensureAssetStorage, L as AssetPublisher, M as TemplateParser, N as LockManager, O as WAFv2WebACLProvider, P as S3StateBackend, Q as formatDockerLoginError, R as stringifyValue, S as ProviderRegistry, T as IntrinsicFunctionResolver, U as rewriteTemplateAssetReferences, V as createAssetRedirectResolver, W as AssetModeResolver, X as validateContainerRepoName, Y as validateAssetBucketName, Z as buildDockerImage, _ as green, _t as CFN_TEMPLATE_URL_LIMIT, a as withRetry, at as synthesisStatusMessage, b as IAMRoleProvider, bt as uploadCfnTemplate, c as computeImplicitDeleteEdges, ct as resolveApp, d as isStatefulRecreateTargetSync, dt as resolveSkipPrefix, et as runDockerForeground, f as renderStatefulReason, ft as resolveStateBucketWithDefault, g as gray, gt as CFN_TEMPLATE_BODY_LIMIT, h as cyan, ht as warnDeprecatedNoPrefixCliFlag, i as withResourceDeadline, it as Synthesizer, j as DagBuilder, k as applyRoleArnIfSet, l as extractDeploymentEventError, lt as resolveAutoAssetStorage, m as bold, mt as resolveUseCdkBootstrapAssets, n as DEFAULT_RESOURCE_WARN_AFTER_MS, nt as AssetManifestLoader, o as isRetryableTransientError, ot as getDefaultStateBucketName, p as formatResourceLine, pt as resolveStateBucketWithDefaultAndSource, q as getBootstrapMarkerKey, r as DeployEngine, rt as getDockerImageBySourceHash, s as IMPLICIT_DELETE_DEPENDENCIES, st as getLegacyStateBucketName, t as DEFAULT_RESOURCE_TIMEOUT_MS, tt as runDockerStreaming, u as MULTI_REGION_RECREATE_BLOCKED_TYPES, ut as resolveCaptureObservedState, v as red, vt as MIGRATE_TMP_PREFIX, w as CloudControlProvider, x as collectInlinePolicyNamesManagedBySiblings, xt as AssemblyReader, y as yellow, yt as findLargeInlineResources, z as WorkGraph } from "./deploy-engine-BxEZXkWw.js";
6
6
  import { t as expectedOwnerParam } from "./expected-bucket-owner-BjEfUnkA.js";
7
7
  import { t as ASGProvider } from "./asg-provider-Dbl1_mwT.js";
8
8
  import { AsyncLocalStorage } from "node:async_hooks";
@@ -35,7 +35,7 @@ import { AssociateVPCWithHostedZoneCommand, ChangeResourceRecordSetsCommand, Cha
35
35
  import { AddTagsCommand, CreateListenerCommand, CreateLoadBalancerCommand, CreateTargetGroupCommand, DeleteListenerCommand, DeleteLoadBalancerCommand, DeleteTargetGroupCommand, DescribeListenerAttributesCommand, DescribeListenersCommand, DescribeLoadBalancerAttributesCommand, DescribeLoadBalancersCommand, DescribeTagsCommand, DescribeTargetGroupsCommand, ElasticLoadBalancingV2Client, ModifyListenerAttributesCommand, ModifyListenerCommand, ModifyLoadBalancerAttributesCommand, ModifyTargetGroupCommand, RemoveTagsCommand, SetIpAddressTypeCommand, SetSecurityGroupsCommand, SetSubnetsCommand } from "@aws-sdk/client-elastic-load-balancing-v2";
36
36
  import { CreateAliasCommand, CreateKeyCommand, DeleteAliasCommand, DescribeKeyCommand, DisableKeyCommand, DisableKeyRotationCommand, EnableKeyCommand, EnableKeyRotationCommand, GetKeyPolicyCommand, GetKeyRotationStatusCommand, KMSClient, ListAliasesCommand, ListKeysCommand, ListResourceTagsCommand, NotFoundException as NotFoundException$2, PutKeyPolicyCommand, ScheduleKeyDeletionCommand, TagResourceCommand as TagResourceCommand$9, UntagResourceCommand as UntagResourceCommand$9, UpdateAliasCommand, UpdateKeyDescriptionCommand } from "@aws-sdk/client-kms";
37
37
  import { join as join$1 } from "path";
38
- import { CreateRepositoryCommand, DeleteLifecyclePolicyCommand, DeleteRepositoryCommand, DeleteRepositoryPolicyCommand, DescribeRepositoriesCommand, ECRClient, GetAuthorizationTokenCommand, GetLifecyclePolicyCommand, LifecyclePolicyNotFoundException, ListTagsForResourceCommand as ListTagsForResourceCommand$7, PutImageScanningConfigurationCommand, PutImageTagMutabilityCommand, PutLifecyclePolicyCommand, RepositoryNotFoundException, SetRepositoryPolicyCommand, TagResourceCommand as TagResourceCommand$10, UntagResourceCommand as UntagResourceCommand$10 } from "@aws-sdk/client-ecr";
38
+ import { BatchDeleteImageCommand, CreateRepositoryCommand, DeleteLifecyclePolicyCommand, DeleteRepositoryCommand, DeleteRepositoryPolicyCommand, DescribeImagesCommand as DescribeImagesCommand$1, DescribeRepositoriesCommand, ECRClient, GetAuthorizationTokenCommand, GetLifecyclePolicyCommand, LifecyclePolicyNotFoundException, ListTagsForResourceCommand as ListTagsForResourceCommand$7, PutImageScanningConfigurationCommand, PutImageTagMutabilityCommand, PutLifecyclePolicyCommand, RepositoryNotFoundException, SetRepositoryPolicyCommand, TagResourceCommand as TagResourceCommand$10, UntagResourceCommand as UntagResourceCommand$10 } from "@aws-sdk/client-ecr";
39
39
  import graphlib from "graphlib";
40
40
  import { AddTagsToResourceCommand as AddTagsToResourceCommand$1, CreateDBClusterCommand, CreateDBInstanceCommand, CreateDBProxyCommand, CreateDBProxyEndpointCommand, CreateDBSubnetGroupCommand, DBProxyEndpointNotFoundFault, DBProxyNotFoundFault, DBProxyTargetGroupNotFoundFault, DBProxyTargetNotFoundFault, DeleteDBClusterCommand, DeleteDBInstanceCommand, DeleteDBProxyCommand, DeleteDBProxyEndpointCommand, DeleteDBSubnetGroupCommand, DeregisterDBProxyTargetsCommand, DescribeDBClustersCommand, DescribeDBInstancesCommand, DescribeDBProxiesCommand, DescribeDBProxyEndpointsCommand, DescribeDBProxyTargetGroupsCommand, DescribeDBProxyTargetsCommand, DescribeDBSubnetGroupsCommand, ListTagsForResourceCommand as ListTagsForResourceCommand$8, ModifyDBClusterCommand, ModifyDBInstanceCommand, ModifyDBProxyCommand, ModifyDBProxyEndpointCommand, ModifyDBProxyTargetGroupCommand, ModifyDBSubnetGroupCommand, RDSClient, RegisterDBProxyTargetsCommand, RemoveTagsFromResourceCommand as RemoveTagsFromResourceCommand$1 } from "@aws-sdk/client-rds";
41
41
  import { AddTagsToResourceCommand as AddTagsToResourceCommand$2, CreateCacheClusterCommand, CreateCacheSubnetGroupCommand, DeleteCacheClusterCommand, DeleteCacheSubnetGroupCommand, DescribeCacheClustersCommand, DescribeCacheSubnetGroupsCommand, ElastiCacheClient, ListTagsForResourceCommand as ListTagsForResourceCommand$9, ModifyCacheClusterCommand, ModifyCacheSubnetGroupCommand, RemoveTagsFromResourceCommand as RemoveTagsFromResourceCommand$2 } from "@aws-sdk/client-elasticache";
@@ -610,6 +610,63 @@ const destroyOptions = [
610
610
  allowUnsupportedTypesOption
611
611
  ];
612
612
 
613
+ //#endregion
614
+ //#region src/cli/commands/state-file-keys.ts
615
+ /**
616
+ * Shared helpers for enumerating and describing cdkd state-bucket keys.
617
+ *
618
+ * Extracted from `bootstrap-destroy.ts` (issue #1010) so that `cdkd gc`
619
+ * (issue #1012) reuses the exact same whole-bucket state-file discovery
620
+ * instead of duplicating it — the "scan the WHOLE bucket, not just the
621
+ * default `cdkd/` prefix" rule was a review blocker once already (PR #1018)
622
+ * and must not drift between the two commands.
623
+ */
624
+ /**
625
+ * Every cdkd state file ends with this suffix, regardless of the
626
+ * `--state-prefix` it was deployed under (`{prefix}/{stack}/{region}/state.json`,
627
+ * or legacy `{prefix}/{stack}/state.json`).
628
+ */
629
+ const STATE_FILE_SUFFIX = "/state.json";
630
+ /**
631
+ * Every cdkd stack lock ends with this suffix — the lock lives next to the
632
+ * state file (`{prefix}/{stack}/{region}/lock.json`).
633
+ */
634
+ const LOCK_FILE_SUFFIX = "/lock.json";
635
+ /** `us-east-1` / `ap-northeast-1` / `us-gov-west-1` — a region-shaped segment. */
636
+ const REGION_SEGMENT = /^[a-z]{2}(-[a-z]+)+-\d+$/;
637
+ /**
638
+ * List every state file in the bucket — the WHOLE bucket, not just the
639
+ * default `cdkd/` prefix. Other commands accept `--state-prefix`, so live
640
+ * stack state may exist under ANY prefix in this bucket; scoping this
641
+ * listing to the default prefix would let reference scans and teardown
642
+ * guards silently miss those stacks and delete live data.
643
+ */
644
+ async function listAllStateKeys(stateBackend) {
645
+ return (await stateBackend.listRawKeys("")).filter((k) => k.endsWith(STATE_FILE_SUFFIX));
646
+ }
647
+ /**
648
+ * List every stack lock file in the bucket — same whole-bucket rule as
649
+ * {@link listAllStateKeys}. A lock under ANY prefix means a deploy /
650
+ * destroy may be in flight for that stack.
651
+ */
652
+ async function listAllLockKeys(stateBackend) {
653
+ return (await stateBackend.listRawKeys("")).filter((k) => k.endsWith(LOCK_FILE_SUFFIX));
654
+ }
655
+ /**
656
+ * `{prefix}/{stack}/{region}/state.json` → `stack (region)`; legacy
657
+ * `{prefix}/{stack}/state.json` → `stack`. The prefix is arbitrary (custom
658
+ * `--state-prefix` values included), so the stack/region pair is derived
659
+ * from the key's TAIL: the last segment is treated as a region only when
660
+ * it is region-shaped. Pass {@link LOCK_FILE_SUFFIX} to describe lock keys.
661
+ */
662
+ function describeStateKey(key, suffix = STATE_FILE_SUFFIX) {
663
+ const segments = key.slice(0, -suffix.length).split("/");
664
+ const last = segments[segments.length - 1] ?? key;
665
+ const secondLast = segments[segments.length - 2];
666
+ if (secondLast !== void 0 && REGION_SEGMENT.test(last)) return `${secondLast} (${last})`;
667
+ return last;
668
+ }
669
+
613
670
  //#endregion
614
671
  //#region src/cli/commands/bootstrap-destroy.ts
615
672
  /**
@@ -638,25 +695,6 @@ const destroyOptions = [
638
695
  /** S3 key prefix for state files — same fixed value the create side uses. */
639
696
  const STATE_PREFIX = "cdkd";
640
697
  /**
641
- * Every cdkd state file ends with this suffix, regardless of the
642
- * `--state-prefix` it was deployed under (`{prefix}/{stack}/{region}/state.json`,
643
- * or legacy `{prefix}/{stack}/state.json`).
644
- */
645
- const STATE_FILE_SUFFIX = "/state.json";
646
- /** `us-east-1` / `ap-northeast-1` / `us-gov-west-1` — a region-shaped segment. */
647
- const REGION_SEGMENT = /^[a-z]{2}(-[a-z]+)+-\d+$/;
648
- /**
649
- * List every state file in the bucket — the WHOLE bucket, not just the
650
- * default `cdkd/` prefix. Other commands accept `--state-prefix`, so live
651
- * stack state may exist under ANY prefix in this bucket; scoping this
652
- * listing to the default prefix would let the reference scan and the
653
- * `--include-state-bucket` guard silently miss those stacks and delete
654
- * live state.
655
- */
656
- async function listAllStateKeys(stateBackend) {
657
- return (await stateBackend.listRawKeys("")).filter((k) => k.endsWith(STATE_FILE_SUFFIX));
658
- }
659
- /**
660
698
  * Interactive confirmation for the teardown. Follows the repo's
661
699
  * destructive-prompt convention (see `recreate-confirm-prompt.ts`, the
662
700
  * same pattern family as `confirm-prompt.ts`): print the deletion plan as
@@ -709,20 +747,6 @@ async function scanStateReferences(stateBackend, names) {
709
747
  return referencing;
710
748
  }
711
749
  /**
712
- * `{prefix}/{stack}/{region}/state.json` → `stack (region)`; legacy
713
- * `{prefix}/{stack}/state.json` → `stack`. The prefix is arbitrary (custom
714
- * `--state-prefix` values included), so the stack/region pair is derived
715
- * from the key's TAIL: the last segment is treated as a region only when
716
- * it is region-shaped.
717
- */
718
- function describeStateKey(key) {
719
- const segments = key.slice(0, -11).split("/");
720
- const last = segments[segments.length - 1] ?? key;
721
- const secondLast = segments[segments.length - 2];
722
- if (secondLast !== void 0 && REGION_SEGMENT.test(last)) return `${secondLast} (${last})`;
723
- return last;
724
- }
725
- /**
726
750
  * Empty (all versions + delete markers) and delete an S3 bucket, with
727
751
  * `ExpectedBucketOwner` pinned on every call — deleting a foreign bucket
728
752
  * that squatted the predictable name would be catastrophic, so the same
@@ -1861,7 +1885,7 @@ const FLUSH_INTERVAL_MS = 2e3;
1861
1885
  const FLUSH_EVENT_THRESHOLD = 50;
1862
1886
  /** Build-time cdkd version, with a dev fallback for non-built contexts. */
1863
1887
  function getCdkdVersion() {
1864
- return "0.237.0";
1888
+ return "0.238.1";
1865
1889
  }
1866
1890
  /**
1867
1891
  * Generate a time-sortable unique run id, e.g.
@@ -39239,6 +39263,7 @@ async function computeStackDiff(currentState, template, region, stackName, state
39239
39263
  resources: currentState.resources,
39240
39264
  stateBackend,
39241
39265
  stackName,
39266
+ bestEffort: true,
39242
39267
  ...parameters && { parameters }
39243
39268
  });
39244
39269
  return diffCalculator.calculateDiff(currentState, template, resolveFn);
@@ -39275,6 +39300,7 @@ async function resolveChildStackParameters(parentStackRow, parentTemplate, paren
39275
39300
  resources: parentState.resources,
39276
39301
  stateBackend,
39277
39302
  stackName: parentStackName,
39303
+ bestEffort: true,
39278
39304
  ...parentParameters && { parameters: parentParameters }
39279
39305
  });
39280
39306
  } catch {}
@@ -39499,6 +39525,27 @@ function stripUnchangedValues(value, other) {
39499
39525
  return Object.keys(result).length > 0 ? result : value;
39500
39526
  }
39501
39527
  /**
39528
+ * Render one side of a property change for the diff output.
39529
+ *
39530
+ * A side whose WHOLE value is still a raw intrinsic could not be resolved
39531
+ * against current state — most commonly a `Ref` / `Fn::GetAtt` to a resource
39532
+ * this same deploy will CREATE (the CDK logical-id-churn dance: an
39533
+ * `AWS::ApiGateway::Deployment` hash rotation, a `fn.currentVersion` Lambda
39534
+ * Version). Rendering that via {@link stripUnchangedValues} collapsed it to
39535
+ * the literal string `undefined`, which reads as "this property is being
39536
+ * removed" (issue #1017). Instead, render the raw intrinsic compactly and —
39537
+ * on the NEW side — annotate that the value only exists after the deploy.
39538
+ * Everything else keeps the existing strip-unchanged rendering.
39539
+ */
39540
+ function renderDiffValue(own, opposite, indent, isNewSide) {
39541
+ if (isIntrinsic(own)) {
39542
+ const suffix = isNewSide ? " (known after deploy)" : "";
39543
+ return `${JSON.stringify(own)}${suffix}`;
39544
+ }
39545
+ const filtered = stripUnchangedValues(own, opposite);
39546
+ return (JSON.stringify(filtered, null, 2) ?? "undefined").replace(/\n/g, `\n${indent}`);
39547
+ }
39548
+ /**
39502
39549
  * Render one resource-change map into human-readable diff lines via `logFn`,
39503
39550
  * returning the per-type counts. Shared by the root stack block and every
39504
39551
  * nested-stack block.
@@ -39529,11 +39576,9 @@ function renderChangeLines(changes, logFn, ccApiRoutes) {
39529
39576
  if (change.propertyChanges && change.propertyChanges.length > 0) for (const propChange of change.propertyChanges) {
39530
39577
  const requiresReplace = propChange.requiresReplacement ? " [requires replacement]" : "";
39531
39578
  const propagated = propChange.replacementPropagated ? " [replacement propagated]" : "";
39532
- const oldFiltered = stripUnchangedValues(propChange.oldValue, propChange.newValue);
39533
- const newFiltered = stripUnchangedValues(propChange.newValue, propChange.oldValue);
39534
39579
  const indent = " ";
39535
- const oldStr = (JSON.stringify(oldFiltered, null, 2) ?? "undefined").replace(/\n/g, `\n${indent}`);
39536
- const newStr = (JSON.stringify(newFiltered, null, 2) ?? "undefined").replace(/\n/g, `\n${indent}`);
39580
+ const oldStr = renderDiffValue(propChange.oldValue, propChange.newValue, indent, false);
39581
+ const newStr = renderDiffValue(propChange.newValue, propChange.oldValue, indent, true);
39537
39582
  logFn(` - ${propChange.path}:${requiresReplace}${propagated}`);
39538
39583
  logFn(` old: ${oldStr}`);
39539
39584
  logFn(` new: ${newStr}`);
@@ -41215,6 +41260,495 @@ function createEventsPruneCommand() {
41215
41260
  })).option("--older-than <duration>", "Delete runs older than this duration (e.g. 24h, 90m)").option("--all", "Delete every recorded run and the index (full purge)", false).action(withErrorHandling((stack, _options, command) => eventsPruneCommand(stack, command.optsWithGlobals())));
41216
41261
  }
41217
41262
 
41263
+ //#endregion
41264
+ //#region src/cli/commands/gc.ts
41265
+ /**
41266
+ * `cdkd gc` — garbage-collect unreferenced objects / images from the
41267
+ * cdkd-owned asset storage of ONE region (issue #1012).
41268
+ *
41269
+ * cdkd-owned asset storage (issue #1002) is content-addressed and never
41270
+ * deleted on `cdkd destroy` (another stack or a future rollback may
41271
+ * reference the same hash), so the asset bucket / container-asset ECR repo
41272
+ * grow without bound — and `cdk gc` cannot reach them by design. cdkd can
41273
+ * gc them PRECISELY because its state files record exactly which assets
41274
+ * are in use.
41275
+ *
41276
+ * Safety posture (this command DELETES user data — every ambiguity is
41277
+ * biased toward NOT deleting):
41278
+ *
41279
+ * - Names come from the region's bootstrap marker, never recomputed from
41280
+ * the naming convention (custom-name compatibility, issue #1011). No
41281
+ * marker → the region is not opted in; friendly no-op. CDK bootstrap
41282
+ * storage (`cdk-hnb659fds-*`) is never touched — that stays `cdk gc`'s
41283
+ * job.
41284
+ * - The reference scan lists EVERY state file in the WHOLE state bucket
41285
+ * (any `--state-prefix`), and a state file that fails to JSON-parse
41286
+ * aborts the whole run — deleting on partial knowledge is how a live
41287
+ * asset gets deleted.
41288
+ * - Any stack lock in the bucket aborts the run: a deploy in flight may
41289
+ * have published assets whose state write has not landed yet.
41290
+ * - `--older-than` (default 30d) age-guards every deletion: an object /
41291
+ * image newer than the cutoff is kept even when unreferenced (protects
41292
+ * in-flight publishes and recent rollback targets). Missing timestamps
41293
+ * are treated as "new" (kept).
41294
+ * - Every S3 call pins `ExpectedBucketOwner`; a 403 on the asset bucket is
41295
+ * a foreign-bucket refusal like the create / teardown sides.
41296
+ */
41297
+ /** Default `--older-than` when the flag is not passed. */
41298
+ const DEFAULT_OLDER_THAN_MS = 720 * 60 * 60 * 1e3;
41299
+ /** S3 `DeleteObjects` accepts at most 1,000 keys per call. */
41300
+ const S3_DELETE_BATCH_SIZE = 1e3;
41301
+ /** ECR `BatchDeleteImage` accepts at most 100 image ids per call. */
41302
+ const ECR_DELETE_BATCH_SIZE = 100;
41303
+ /**
41304
+ * Parse the `--older-than` age guard: `<n>d` (days) or `<n>h` (hours),
41305
+ * decimals allowed (`1.5d`). Zero, negative, missing-unit, and
41306
+ * unknown-unit values are rejected at parse time — a zero / negative age
41307
+ * guard would disable the in-flight-publish protection entirely.
41308
+ *
41309
+ * Kept local (rather than extending `parseDuration` in `options.ts`):
41310
+ * the deploy-side duration grammar is seconds/minutes/hours for
41311
+ * per-resource deadlines, while gc ages are naturally days — mixing `30s`
41312
+ * into an age guard invites typos that all but disable it.
41313
+ */
41314
+ function parseOlderThan(value) {
41315
+ const match = /^(\d+(?:\.\d+)?)([dh])$/.exec(value.trim());
41316
+ if (!match) throw new Error(`Invalid --older-than "${value}": expected <number>d or <number>h (e.g. 30d, 12h)`);
41317
+ const num = Number(match[1]);
41318
+ if (!Number.isFinite(num) || num <= 0) throw new Error(`Invalid --older-than "${value}": must be greater than zero`);
41319
+ const multiplier = match[2] === "d" ? 1440 * 60 * 1e3 : 3600 * 1e3;
41320
+ return Math.round(num * multiplier);
41321
+ }
41322
+ /** Escape a literal string for embedding in a RegExp. */
41323
+ function escapeRegExp(value) {
41324
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
41325
+ }
41326
+ /**
41327
+ * Characters that terminate an S3 key extracted from a URL-shaped string.
41328
+ * Whitespace / quotes never appear in cdkd's content-addressed asset keys
41329
+ * (`<sha256>.zip` etc.); `?` strips query strings (pre-signed URLs).
41330
+ */
41331
+ const KEY_TERMINATORS = "[^\\s\"'?]";
41332
+ /**
41333
+ * cdkd's publishers write content-addressed keys (`<sha256>.<ext>`). A
41334
+ * second, name-independent pass collects every such token from every
41335
+ * scanned string — belt-and-braces against URL captures that ran through
41336
+ * an embedded separator (e.g. two `s3://` URIs joined by a comma yield ONE
41337
+ * over-long capture, leaving both real keys unprotected). Over-collection
41338
+ * only ever KEEPS more.
41339
+ */
41340
+ const CONTENT_HASH_KEY_RE = /\b[0-9a-f]{64}\.[A-Za-z0-9]+\b/g;
41341
+ /**
41342
+ * Try to decode a base64-looking string (e.g. `Fn::Base64`-resolved EC2 /
41343
+ * ASG UserData stored in state) so references INSIDE it are collected too —
41344
+ * an `aws s3 cp s3://<assetBucket>/<key>` in UserData is a long-lived
41345
+ * reference (every future scale-out fetches it at boot). Returns null for
41346
+ * strings that are not plausibly base64-encoded text; a binary decode is
41347
+ * rejected by the control-character check. Hex strings (valid base64
41348
+ * alphabet) decode to binary and are rejected the same way.
41349
+ */
41350
+ function tryDecodeBase64Text(value) {
41351
+ if (value.length < 24 || value.length % 4 !== 0) return null;
41352
+ if (!/^[A-Za-z0-9+/]+={0,2}$/.test(value)) return null;
41353
+ try {
41354
+ const decoded = Buffer.from(value, "base64").toString("utf8");
41355
+ if (/[\x00-\x08\x0e-\x1f\ufffd]/.test(decoded)) return null;
41356
+ return decoded;
41357
+ } catch {
41358
+ return null;
41359
+ }
41360
+ }
41361
+ /**
41362
+ * Per-(bucket, repo) reference extractors. Built once per run.
41363
+ *
41364
+ * Matched shapes (all carry the bucket / repo name verbatim):
41365
+ * - (bucket, key) location OBJECTS — any object carrying the asset bucket
41366
+ * name as a value (`{S3Bucket, S3Key}` Lambda Code, `{Bucket, Key}`
41367
+ * ApiGateway BodyS3Location / SFN DefinitionS3Location, ...) — handled
41368
+ * in the walk itself; every sibling string value is collected.
41369
+ * - `s3://<assetBucket>/<key>` URIs.
41370
+ * - `https://<assetBucket>.s3[.<region>].amazonaws.com/<key>`
41371
+ * (virtual-hosted style, region / dualstack variants included).
41372
+ * - `https://s3[.<region>].amazonaws.com/<assetBucket>/<key>` (path style).
41373
+ * - `<acct>.dkr.ecr.<region>.amazonaws.com/<containerRepo>:<tag>` and/or
41374
+ * `...@sha256:<digest>` image URIs. Account / region are matched
41375
+ * loosely on purpose: collecting a reference from another account's or
41376
+ * region's URI can only over-protect (keep more), never delete more.
41377
+ */
41378
+ function buildReferenceExtractors(marker) {
41379
+ const bucket = escapeRegExp(marker.assetBucket);
41380
+ const repo = escapeRegExp(marker.containerRepo);
41381
+ const s3UriRe = new RegExp(`s3://${bucket}/(${KEY_TERMINATORS}+)`, "g");
41382
+ const virtualHostedRe = new RegExp(`https://${bucket}\\.s3[^/\\s]*\\.amazonaws\\.com/(${KEY_TERMINATORS}+)`, "g");
41383
+ const pathStyleRe = new RegExp(`https://s3[^/\\s]*\\.amazonaws\\.com/${bucket}/(${KEY_TERMINATORS}+)`, "g");
41384
+ const ecrRe = new RegExp(`\\d{12}\\.dkr\\.ecr\\.[a-z0-9-]+\\.amazonaws\\.com/${repo}(?::([A-Za-z0-9_][A-Za-z0-9._-]{0,127}))?(?:@(sha256:[0-9a-f]{64}))?`, "g");
41385
+ const extractOnce = (value, refs) => {
41386
+ for (const re of [
41387
+ s3UriRe,
41388
+ virtualHostedRe,
41389
+ pathStyleRe
41390
+ ]) for (const match of value.matchAll(re)) if (match[1]) refs.s3Keys.add(match[1]);
41391
+ for (const match of value.matchAll(ecrRe)) {
41392
+ if (match[1]) refs.imageTags.add(match[1]);
41393
+ if (match[2]) refs.imageDigests.add(match[2]);
41394
+ }
41395
+ for (const match of value.matchAll(CONTENT_HASH_KEY_RE)) refs.s3Keys.add(match[0]);
41396
+ };
41397
+ return { extractFromString(value, refs) {
41398
+ extractOnce(value, refs);
41399
+ const decoded = tryDecodeBase64Text(value);
41400
+ if (decoded !== null) extractOnce(decoded, refs);
41401
+ } };
41402
+ }
41403
+ /**
41404
+ * Deep-walk a parsed state file and collect every reference to the target
41405
+ * region's asset bucket / container repo into `refs`.
41406
+ *
41407
+ * The walk covers the ENTIRE state document — a superset of the spec'd
41408
+ * `properties` / `observedProperties` / `attributes` / `outputs` fields —
41409
+ * because over-collection can only KEEP more (safe direction) and a future
41410
+ * state field carrying an asset reference is then protected automatically.
41411
+ * Unexpected value types are walked defensively (arrays / objects
41412
+ * recursed, non-strings ignored).
41413
+ */
41414
+ function collectAssetReferences(stateDocument, marker, refs) {
41415
+ const { extractFromString } = buildReferenceExtractors(marker);
41416
+ const walk = (value) => {
41417
+ if (typeof value === "string") {
41418
+ extractFromString(value, refs);
41419
+ return;
41420
+ }
41421
+ if (Array.isArray(value)) {
41422
+ for (const item of value) walk(item);
41423
+ return;
41424
+ }
41425
+ if (value !== null && typeof value === "object") {
41426
+ const values = Object.values(value);
41427
+ if (values.some((v) => v === marker.assetBucket)) {
41428
+ for (const v of values) if (typeof v === "string" && v !== marker.assetBucket) refs.s3Keys.add(v);
41429
+ }
41430
+ for (const item of values) walk(item);
41431
+ }
41432
+ };
41433
+ walk(stateDocument);
41434
+ }
41435
+ /**
41436
+ * Scan every state file in the state bucket and collect the referenced
41437
+ * asset keys / image tags / digests for the marker's bucket + repo.
41438
+ *
41439
+ * Fail safe: a state file that fails to JSON-parse aborts the whole run —
41440
+ * a reference we could not read is a reference we would otherwise delete.
41441
+ * A state key that disappeared between the listing and the read (destroy
41442
+ * completed concurrently) is skipped: its references are legitimately gone.
41443
+ */
41444
+ async function scanReferencedAssets(stateBackend, marker, logger) {
41445
+ const refs = {
41446
+ s3Keys: /* @__PURE__ */ new Set(),
41447
+ imageTags: /* @__PURE__ */ new Set(),
41448
+ imageDigests: /* @__PURE__ */ new Set()
41449
+ };
41450
+ const stateKeys = await listAllStateKeys(stateBackend);
41451
+ logger.info(`Scanning ${stateKeys.length} state file(s) for asset references...`);
41452
+ for (const key of stateKeys) {
41453
+ const body = await stateBackend.getRawObject(key);
41454
+ if (body === null) {
41455
+ logger.debug(`State file ${key} disappeared during the scan — skipping`);
41456
+ continue;
41457
+ }
41458
+ let parsed;
41459
+ try {
41460
+ parsed = JSON.parse(body);
41461
+ } catch (error) {
41462
+ const described = describeStateKey(key);
41463
+ const regionMatch = /^(\S+) \((\S+)\)$/.exec(described);
41464
+ 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);
41465
+ }
41466
+ collectAssetReferences(parsed, marker, refs);
41467
+ }
41468
+ logger.debug(`Referenced: ${refs.s3Keys.size} S3 key(s), ${refs.imageTags.size} image tag(s), ${refs.imageDigests.size} image digest(s)`);
41469
+ return refs;
41470
+ }
41471
+ /**
41472
+ * List the asset bucket (paginated, `ExpectedBucketOwner`) and pick the
41473
+ * deletion candidates: keys NOT referenced AND strictly older than the
41474
+ * cutoff. Objects with no `LastModified` are kept (treated as new).
41475
+ *
41476
+ * A missing bucket is an idempotent skip (nothing to gc there); a 403 is
41477
+ * a foreign-bucket refusal, mirroring the create / teardown sides.
41478
+ */
41479
+ async function listS3Candidates(s3Client, bucket, accountId, refs, cutoffMs, logger) {
41480
+ const candidates = [];
41481
+ let continuationToken;
41482
+ try {
41483
+ do {
41484
+ const response = await s3Client.send(new ListObjectsV2Command({
41485
+ Bucket: bucket,
41486
+ ExpectedBucketOwner: accountId,
41487
+ ...continuationToken && { ContinuationToken: continuationToken }
41488
+ }));
41489
+ for (const obj of response.Contents ?? []) {
41490
+ if (!obj.Key) continue;
41491
+ if (refs.s3Keys.has(obj.Key)) continue;
41492
+ if (!obj.LastModified || obj.LastModified.getTime() >= cutoffMs) continue;
41493
+ candidates.push({
41494
+ key: obj.Key,
41495
+ size: obj.Size ?? 0,
41496
+ lastModified: obj.LastModified
41497
+ });
41498
+ }
41499
+ continuationToken = response.IsTruncated ? response.NextContinuationToken : void 0;
41500
+ } while (continuationToken);
41501
+ } catch (error) {
41502
+ const err = error;
41503
+ if (err.name === "NoSuchBucket" || err.name === "NotFound") {
41504
+ logger.info(`Asset bucket ${bucket} does not exist — skipping`);
41505
+ return [];
41506
+ }
41507
+ if (err.$metadata?.httpStatusCode === 403) throw new CdkdError(`Asset bucket '${bucket}' exists but is not owned by account ${accountId} (or access is denied). Refusing to touch it.`, "ASSET_STORAGE_FOREIGN_BUCKET", error);
41508
+ throw normalizeAwsError(error, {
41509
+ bucket,
41510
+ operation: "ListObjectsV2"
41511
+ });
41512
+ }
41513
+ return candidates;
41514
+ }
41515
+ /**
41516
+ * Describe the container repo's images (paginated) and pick the deletion
41517
+ * candidates: an image is REFERENCED when any of its tags OR its digest is
41518
+ * in the referenced set; candidates are unreferenced AND strictly older
41519
+ * than the cutoff. Images with no `imagePushedAt` are kept (treated as
41520
+ * new). A missing repo is an idempotent skip.
41521
+ */
41522
+ async function listEcrCandidates(ecrClient, repositoryName, refs, cutoffMs, logger) {
41523
+ const candidates = [];
41524
+ let nextToken;
41525
+ try {
41526
+ do {
41527
+ const response = await ecrClient.send(new DescribeImagesCommand$1({
41528
+ repositoryName,
41529
+ ...nextToken && { nextToken }
41530
+ }));
41531
+ for (const image of response.imageDetails ?? []) {
41532
+ if (!image.imageDigest) continue;
41533
+ const tags = image.imageTags ?? [];
41534
+ if (refs.imageDigests.has(image.imageDigest) || tags.some((t) => refs.imageTags.has(t))) continue;
41535
+ if (!image.imagePushedAt || image.imagePushedAt.getTime() >= cutoffMs) continue;
41536
+ candidates.push({
41537
+ digest: image.imageDigest,
41538
+ tags,
41539
+ size: image.imageSizeInBytes ?? 0,
41540
+ pushedAt: image.imagePushedAt
41541
+ });
41542
+ }
41543
+ nextToken = response.nextToken;
41544
+ } while (nextToken);
41545
+ } catch (error) {
41546
+ if (error.name === "RepositoryNotFoundException") {
41547
+ logger.info(`Container-asset repository ${repositoryName} does not exist — skipping`);
41548
+ return [];
41549
+ }
41550
+ throw normalizeAwsError(error, { operation: "DescribeImages" });
41551
+ }
41552
+ return candidates;
41553
+ }
41554
+ /** `12345678` → `11.8 MiB` — human-readable byte count for the plan. */
41555
+ function formatBytes(bytes) {
41556
+ if (bytes < 1024) return `${bytes} B`;
41557
+ const units = [
41558
+ "KiB",
41559
+ "MiB",
41560
+ "GiB",
41561
+ "TiB"
41562
+ ];
41563
+ let value = bytes;
41564
+ let unit = "B";
41565
+ for (const u of units) {
41566
+ if (value < 1024) break;
41567
+ value /= 1024;
41568
+ unit = u;
41569
+ }
41570
+ return `${value.toFixed(1)} ${unit}`;
41571
+ }
41572
+ /** Age of a timestamp relative to now, in whole days (or hours under 1d). */
41573
+ function formatAge(date) {
41574
+ const ageMs = Date.now() - date.getTime();
41575
+ const days = Math.floor(ageMs / (1440 * 60 * 1e3));
41576
+ if (days >= 1) return `${days}d old`;
41577
+ return `${Math.max(0, Math.floor(ageMs / (3600 * 1e3)))}h old`;
41578
+ }
41579
+ /**
41580
+ * Interactive confirmation for the deletion. Follows the repo's
41581
+ * destructive-prompt convention (same pattern family as
41582
+ * `promptBootstrapDestroyConfirm`): print the plan as a WARN block,
41583
+ * `--yes` skips, a non-TTY stdin without `--yes` is a hard error (never
41584
+ * hang / never silently decline in CI), and the prompt defaults to NO.
41585
+ */
41586
+ async function promptGcConfirm(input) {
41587
+ const logger = getLogger();
41588
+ logger.warn("");
41589
+ logger.warn("cdkd gc will delete the following unreferenced assets:");
41590
+ for (const line of input.planLines) logger.warn(` - ${line}`);
41591
+ if (input.yes) return true;
41592
+ 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");
41593
+ const rl = readline.createInterface({
41594
+ input: process.stdin,
41595
+ output: process.stdout
41596
+ });
41597
+ try {
41598
+ const trimmed = (await rl.question("\nContinue? (y/N): ")).trim().toLowerCase();
41599
+ return trimmed === "y" || trimmed === "yes";
41600
+ } finally {
41601
+ rl.close();
41602
+ }
41603
+ }
41604
+ /**
41605
+ * Delete the S3 candidates via chunked `DeleteObjects` (1,000 keys per
41606
+ * call, `ExpectedBucketOwner`). Per-key `Errors` are surfaced as a hard
41607
+ * error so gc never reports success while objects remain.
41608
+ */
41609
+ async function deleteS3Candidates(s3Client, bucket, accountId, candidates) {
41610
+ const failures = [];
41611
+ for (let i = 0; i < candidates.length; i += S3_DELETE_BATCH_SIZE) {
41612
+ const chunk = candidates.slice(i, i + S3_DELETE_BATCH_SIZE);
41613
+ const response = await s3Client.send(new DeleteObjectsCommand({
41614
+ Bucket: bucket,
41615
+ ExpectedBucketOwner: accountId,
41616
+ Delete: {
41617
+ Objects: chunk.map((c) => ({ Key: c.key })),
41618
+ Quiet: true
41619
+ }
41620
+ }));
41621
+ for (const err of response.Errors ?? []) failures.push(`${err.Key ?? "<unknown>"} (${err.Code ?? "Error"}: ${err.Message ?? ""})`);
41622
+ }
41623
+ if (failures.length > 0) throw new CdkdError(`Failed to delete ${failures.length} object(s) from asset bucket '${bucket}': ` + failures.join("; "), "GC_DELETE_FAILED");
41624
+ }
41625
+ /**
41626
+ * Delete the ECR candidates via chunked `BatchDeleteImage` (100 image ids
41627
+ * per call), addressed by digest so every tag of the image goes with it.
41628
+ * Per-image `failures` are surfaced as a hard error.
41629
+ */
41630
+ async function deleteEcrCandidates(ecrClient, repositoryName, candidates) {
41631
+ const failures = [];
41632
+ for (let i = 0; i < candidates.length; i += ECR_DELETE_BATCH_SIZE) {
41633
+ const chunk = candidates.slice(i, i + ECR_DELETE_BATCH_SIZE);
41634
+ const response = await ecrClient.send(new BatchDeleteImageCommand({
41635
+ repositoryName,
41636
+ imageIds: chunk.map((c) => ({ imageDigest: c.digest }))
41637
+ }));
41638
+ for (const failure of response.failures ?? []) failures.push(`${failure.imageId?.imageDigest ?? "<unknown>"} (${failure.failureCode ?? "Error"}: ${failure.failureReason ?? ""})`);
41639
+ }
41640
+ if (failures.length > 0) throw new CdkdError(`Failed to delete ${failures.length} image(s) from repository '${repositoryName}': ` + failures.join("; "), "GC_DELETE_FAILED");
41641
+ }
41642
+ /**
41643
+ * `cdkd gc` implementation. See the module JSDoc for the safety posture.
41644
+ */
41645
+ async function gcCommand(options) {
41646
+ const logger = getLogger();
41647
+ if (options.verbose) logger.setLevel("debug");
41648
+ logger.info("Starting cdkd gc...");
41649
+ logger.debug("Options:", options);
41650
+ await applyRoleArnIfSet({
41651
+ roleArn: options.roleArn,
41652
+ region: options.region
41653
+ });
41654
+ const region = options.region || process.env["AWS_REGION"] || "us-east-1";
41655
+ const awsClients = new AwsClients({
41656
+ region,
41657
+ ...options.profile && { profile: options.profile }
41658
+ });
41659
+ setAwsClients(awsClients);
41660
+ const accountId = (await awsClients.sts.send(new GetCallerIdentityCommand({}))).Account;
41661
+ const bucketName = options.stateBucket ?? getDefaultStateBucketName(accountId);
41662
+ const markerS3Client = new S3Client({
41663
+ region,
41664
+ ...options.profile && { profile: options.profile }
41665
+ });
41666
+ const stateBackend = new S3StateBackend(markerS3Client, {
41667
+ bucket: bucketName,
41668
+ prefix: "cdkd"
41669
+ }, {
41670
+ region,
41671
+ ...options.profile && { profile: options.profile }
41672
+ });
41673
+ const ecrClient = new ECRClient({
41674
+ region,
41675
+ ...options.profile && { profile: options.profile }
41676
+ });
41677
+ try {
41678
+ const markerKey = getBootstrapMarkerKey(region);
41679
+ let markerBody;
41680
+ try {
41681
+ markerBody = await stateBackend.getRawObject(markerKey);
41682
+ } catch (error) {
41683
+ if (error.name === "NoSuchBucket") {
41684
+ logger.info(`State bucket '${bucketName}' does not exist — this account was never bootstrapped; nothing to garbage-collect.`);
41685
+ return;
41686
+ }
41687
+ throw error;
41688
+ }
41689
+ if (markerBody === null) {
41690
+ logger.info(`No bootstrap marker for region '${region}' (${markerKey}) — the region is not opted in to cdkd asset storage; nothing to garbage-collect. (CDK bootstrap storage is 'cdk gc' territory.)`);
41691
+ return;
41692
+ }
41693
+ const marker = parseBootstrapMarker(markerBody, markerKey);
41694
+ const lockKeys = await listAllLockKeys(stateBackend);
41695
+ if (lockKeys.length > 0) {
41696
+ const listing = lockKeys.map((k) => ` - ${describeStateKey(k, LOCK_FILE_SUFFIX)} [${k}]`).join("\n");
41697
+ 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");
41698
+ }
41699
+ const refs = await scanReferencedAssets(stateBackend, marker, logger);
41700
+ const cutoffMs = Date.now() - options.olderThan;
41701
+ const s3Candidates = await listS3Candidates(awsClients.s3, marker.assetBucket, accountId, refs, cutoffMs, logger);
41702
+ const ecrCandidates = await listEcrCandidates(ecrClient, marker.containerRepo, refs, cutoffMs, logger);
41703
+ if (s3Candidates.length === 0 && ecrCandidates.length === 0) {
41704
+ 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.`);
41705
+ return;
41706
+ }
41707
+ const s3Bytes = s3Candidates.reduce((sum, c) => sum + c.size, 0);
41708
+ const ecrBytes = ecrCandidates.reduce((sum, c) => sum + c.size, 0);
41709
+ 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)})`)];
41710
+ const totals = `Total: ${s3Candidates.length} S3 object(s) (${formatBytes(s3Bytes)}) + ${ecrCandidates.length} ECR image(s) (${formatBytes(ecrBytes)}) = ${formatBytes(s3Bytes + ecrBytes)} reclaimable`;
41711
+ if (options.dryRun) {
41712
+ logger.info("");
41713
+ logger.info("Dry run — the following unreferenced assets would be deleted:");
41714
+ for (const line of planLines) logger.info(` - ${line}`);
41715
+ logger.info(totals);
41716
+ logger.info("Dry run: nothing deleted. Re-run without --dry-run to delete.");
41717
+ return;
41718
+ }
41719
+ if (!await promptGcConfirm({
41720
+ planLines: [...planLines, totals],
41721
+ yes: options.yes
41722
+ })) {
41723
+ logger.info("gc cancelled — nothing deleted.");
41724
+ return;
41725
+ }
41726
+ if (s3Candidates.length > 0) {
41727
+ await deleteS3Candidates(awsClients.s3, marker.assetBucket, accountId, s3Candidates);
41728
+ logger.info(`✓ Deleted ${s3Candidates.length} object(s) (${formatBytes(s3Bytes)}) from ${marker.assetBucket}`);
41729
+ }
41730
+ if (ecrCandidates.length > 0) {
41731
+ await deleteEcrCandidates(ecrClient, marker.containerRepo, ecrCandidates);
41732
+ logger.info(`✓ Deleted ${ecrCandidates.length} image(s) (${formatBytes(ecrBytes)}) from ${marker.containerRepo}`);
41733
+ }
41734
+ logger.info(`\n✓ gc completed: ${formatBytes(s3Bytes + ecrBytes)} reclaimed`);
41735
+ } finally {
41736
+ ecrClient.destroy();
41737
+ markerS3Client.destroy();
41738
+ awsClients.destroy();
41739
+ }
41740
+ }
41741
+ /**
41742
+ * Create the `cdkd gc` command (upstream `cdk gc` parity naming).
41743
+ */
41744
+ function createGcCommand() {
41745
+ 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 env or us-east-1)")).action(withErrorHandling(async (options) => {
41746
+ await gcCommand(options);
41747
+ }));
41748
+ commonOptions.forEach((opt) => cmd.addOption(opt));
41749
+ return cmd;
41750
+ }
41751
+
41218
41752
  //#endregion
41219
41753
  //#region src/cli/cdk-path.ts
41220
41754
  /**
@@ -56378,6 +56912,7 @@ const SUBCOMMANDS = new Set([
56378
56912
  "diff",
56379
56913
  "drift",
56380
56914
  "destroy",
56915
+ "gc",
56381
56916
  "orphan",
56382
56917
  "import",
56383
56918
  "export",
@@ -56410,7 +56945,7 @@ function reorderArgs(argv) {
56410
56945
  async function main() {
56411
56946
  installPipeCloseHandler();
56412
56947
  const program = new Command();
56413
- program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.237.0");
56948
+ program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.238.1");
56414
56949
  program.addCommand(createBootstrapCommand());
56415
56950
  program.addCommand(createSynthCommand());
56416
56951
  program.addCommand(createListCommand());
@@ -56419,6 +56954,7 @@ async function main() {
56419
56954
  program.addCommand(createDriftCommand());
56420
56955
  program.addCommand(createDestroyCommand());
56421
56956
  program.addCommand(createEventsCommand());
56957
+ program.addCommand(createGcCommand());
56422
56958
  program.addCommand(createOrphanCommand());
56423
56959
  program.addCommand(createImportCommand());
56424
56960
  program.addCommand(createPublishAssetsCommand());