@go-to-k/cdkd 0.284.6 → 0.284.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
- import { $ as IntrinsicFunctionResolver, $n as StackHasActiveImportsError, $t as validateContainerRepoName, A as formatResourceLine, An as AssemblyReader, At as describeTypeWithThrottleRetry, B as isExportAliasCollision, Bt as WorkGraph, C as refusesFinalSnapshot, Cn as MIGRATE_TMP_PREFIX, Ct as s3BucketDomainName, D as MULTI_REGION_RECREATE_BLOCKED_TYPES, Dn as PARTITION_TABLE, Dt as applyRoleArnIfSet, E as extractDeploymentEventError, En as expectedOwnerParam, Et as s3BucketWebsiteUrl, F as red, Fn as getAwsClients, Ft as S3StateBackend, G as clearOnUpdateRemoval, Gn as LocalStartServiceError, Gt as escapeRegExp, H as stateKeySecretExposure, Hn as DeployCancelledError, Ht as createAssetRedirectResolver, I as yellow, In as resetAwsClients, It as rebuildClientForBucketRegion, J as findSilentDropProperties, Jn as NestedStackChildDirectDestroyError, Jt as BOOTSTRAP_MARKER_PREFIX, K as ProviderRegistry, Kt as stripControlChars, L as collectDeclaredOutputNames, Ln as setAwsClients, Lt as shouldRetainResource, M as cyan, Mt as DagBuilder, N as gray, Nn as resolveBucketRegion, Nt as TemplateParser, O as isStatefulRecreateTargetSync, On as canonicalizeRegion, Ot as DiffCalculator, P as green, Pn as AwsClients, Pt as LockManager, Q as isTerminationProtectionPropagationError, Qn as ResourceUpdateNotSupportedError, Qt as validateAssetBucketName, R as collectPublishedOutputNames, Rt as AssetPublisher, S as isFinalSnapshotError, Sn as CFN_TEMPLATE_URL_LIMIT, St as s3BucketArn, T as makeCanonicalizePropertiesFn, Tn as uploadCfnTemplate, Tt as s3BucketRegionalDomainName, U as IAMRoleProvider, Un as LocalInvokeBuildError$1, Ut as loadPublishableAssetManifest, V as secretBearingStateKeyWarning, Vt as buildAssetRedirectMap, W as collectInlinePolicyNamesManagedBySiblings, Wn as LocalMigrateError, Wt as rewriteTemplateAssetReferences, X as slowCcOperationTimeoutMs, Xn as ProvisioningError, Xt as getBootstrapMarkerKey, Y as CloudControlProvider, Yn as PartialFailureError, Yt as ensureAssetStorage, Z as disableInstanceApiTermination, Zn as ResourceTimeoutError, Zt as parseBootstrapMarker, _ as ATOMIC_FINAL_SNAPSHOT_TYPES, _n as resolveStateBucketWithDefaultAndSource, _t as TEMPLATE_SOURCED_RULES, a as DeploymentEventsStore, an as runDockerStreaming, ar as normalizeAwsError, at as resolveExplicitPhysicalId, b as ccRoutedFinalSnapshotError, bn as warnDeprecatedNoPrefixCliFlag, bt as redactSecretsForState, c as replayFailedOperations, cn as Synthesizer, cr as isRetryableTransientError, ct as configBooleanRefusal, d as updatePartialReason, dn as getLegacyStateBucketName, dt as replayWarn, en as buildDenyExternalAccessPolicy, er as StackTerminationProtectionError, et as cfnRefValueFromPhysicalId, f as UNSPECIFIED_SKIP_REASON, fn as resolveApp, ft as requireConfigArray, g as computeImplicitDeleteEdges, gn as resolveStateBucketWithDefault, gt as STATE_SOURCED_READBACK_RULES, h as IMPLICIT_DELETE_DEPENDENCIES, hn as resolveSkipPrefix, ht as STATE_SOURCED_CROSS_GENERATION_RULES, i as DeploymentEventsReader, in as runDockerForeground, it as normalizeAwsTagsToCfn, j as bold, jn as processStackMessages, jt as withRetry, k as renderStatefulReason, kn as derivePartitionAndUrlSuffix, kt as INTRINSIC_KEYS, l as replayRollback, ln as synthesisStatusMessage, lr as isThrottlingError, lt as configStringRefusal, m as withResourceDeadline, mn as resolveCaptureObservedState, mt as requireConfigString, n as DEFAULT_RESOURCE_WARN_AFTER_MS, nn as formatDockerLoginError, nt as refStateLookupFromResource, o as planFailedOps, on as AssetManifestLoader, or as withErrorHandling, ot as assertRegionMatch, p as deleteSkipReason, pn as resolveAutoAssetStorage, pt as requireConfigObject, q as findActionableSilentDrops, qn as MissingCdkCliError, qt as AssetModeResolver, r as DeployEngine, rn as getDockerCmd, rt as WAFv2WebACLProvider, s as planRollback, sn as getDockerImageBySourceHash, sr as isMarkedNonRetryable, st as coerceCfnBoolean, t as DEFAULT_RESOURCE_TIMEOUT_MS, tn as buildDockerImage, tt as getAccountInfo, u as updatePartialMessage, un as getDefaultStateBucketName, ur as markNonRetryable, ut as readConfigString, v as PRE_DELETE_SNAPSHOT_TYPES, vn as resolveUseCdkBootstrapAssets, vt as createSecretMasker, w as unsupportedFinalSnapshotError, wn as findLargeInlineResources, wt as s3BucketDualStackDomainName, x as createPreDeleteFinalSnapshot, xn as CFN_TEMPLATE_BODY_LIMIT, xt as scrubResourceRecord, y as buildFinalSnapshotIdentifier, yn as stateBucketExistenceConfirmed, yt as maskSecretsInText, z as exportAliasCollisionScrubWarning, zn as CdkdError, zt as stringifyValue } from "./deploy-engine-Ce7kNQp2.js";
2
+ import { $ as IntrinsicFunctionResolver, $n as StackHasActiveImportsError, $t as validateContainerRepoName, A as formatResourceLine, An as AssemblyReader, At as describeTypeWithThrottleRetry, B as isExportAliasCollision, Bt as WorkGraph, C as refusesFinalSnapshot, Cn as MIGRATE_TMP_PREFIX, Ct as s3BucketDomainName, D as MULTI_REGION_RECREATE_BLOCKED_TYPES, Dn as PARTITION_TABLE, Dt as applyRoleArnIfSet, E as extractDeploymentEventError, En as expectedOwnerParam, Et as s3BucketWebsiteUrl, F as red, Fn as getAwsClients, Ft as S3StateBackend, G as clearOnUpdateRemoval, Gn as LocalStartServiceError, Gt as escapeRegExp, H as stateKeySecretExposure, Hn as DeployCancelledError, Ht as createAssetRedirectResolver, I as yellow, In as resetAwsClients, It as rebuildClientForBucketRegion, J as findSilentDropProperties, Jn as NestedStackChildDirectDestroyError, Jt as BOOTSTRAP_MARKER_PREFIX, K as ProviderRegistry, Kt as stripControlChars, L as collectDeclaredOutputNames, Ln as setAwsClients, Lt as shouldRetainResource, M as cyan, Mt as DagBuilder, N as gray, Nn as resolveBucketRegion, Nt as TemplateParser, O as isStatefulRecreateTargetSync, On as canonicalizeRegion, Ot as DiffCalculator, P as green, Pn as AwsClients, Pt as LockManager, Q as isTerminationProtectionPropagationError, Qn as ResourceUpdateNotSupportedError, Qt as validateAssetBucketName, R as collectPublishedOutputNames, Rt as AssetPublisher, S as isFinalSnapshotError, Sn as CFN_TEMPLATE_URL_LIMIT, St as s3BucketArn, T as makeCanonicalizePropertiesFn, Tn as uploadCfnTemplate, Tt as s3BucketRegionalDomainName, U as IAMRoleProvider, Un as LocalInvokeBuildError$1, Ut as loadPublishableAssetManifest, V as secretBearingStateKeyWarning, Vt as buildAssetRedirectMap, W as collectInlinePolicyNamesManagedBySiblings, Wn as LocalMigrateError, Wt as rewriteTemplateAssetReferences, X as slowCcOperationTimeoutMs, Xn as ProvisioningError, Xt as getBootstrapMarkerKey, Y as CloudControlProvider, Yn as PartialFailureError, Yt as ensureAssetStorage, Z as disableInstanceApiTermination, Zn as ResourceTimeoutError, Zt as parseBootstrapMarker, _ as ATOMIC_FINAL_SNAPSHOT_TYPES, _n as resolveStateBucketWithDefaultAndSource, _t as TEMPLATE_SOURCED_RULES, a as DeploymentEventsStore, an as runDockerStreaming, ar as normalizeAwsError, at as resolveExplicitPhysicalId, b as ccRoutedFinalSnapshotError, bn as warnDeprecatedNoPrefixCliFlag, bt as redactSecretsForState, c as replayFailedOperations, cn as Synthesizer, cr as isRetryableTransientError, ct as configBooleanRefusal, d as updatePartialReason, dn as getLegacyStateBucketName, dt as replayWarn, en as buildDenyExternalAccessPolicy, er as StackTerminationProtectionError, et as cfnRefValueFromPhysicalId, f as UNSPECIFIED_SKIP_REASON, fn as resolveApp, ft as requireConfigArray, g as computeImplicitDeleteEdges, gn as resolveStateBucketWithDefault, gt as STATE_SOURCED_READBACK_RULES, h as IMPLICIT_DELETE_DEPENDENCIES, hn as resolveSkipPrefix, ht as STATE_SOURCED_CROSS_GENERATION_RULES, i as DeploymentEventsReader, in as runDockerForeground, it as normalizeAwsTagsToCfn, j as bold, jn as processStackMessages, jt as withRetry, k as renderStatefulReason, kn as derivePartitionAndUrlSuffix, kt as INTRINSIC_KEYS, l as replayRollback, ln as synthesisStatusMessage, lr as isThrottlingError, lt as configStringRefusal, m as withResourceDeadline, mn as resolveCaptureObservedState, mt as requireConfigString, n as DEFAULT_RESOURCE_WARN_AFTER_MS, nn as formatDockerLoginError, nt as refStateLookupFromResource, o as planFailedOps, on as AssetManifestLoader, or as withErrorHandling, ot as assertRegionMatch, p as deleteSkipReason, pn as resolveAutoAssetStorage, pt as requireConfigObject, q as findActionableSilentDrops, qn as MissingCdkCliError, qt as AssetModeResolver, r as DeployEngine, rn as getDockerCmd, rt as WAFv2WebACLProvider, s as planRollback, sn as getDockerImageBySourceHash, sr as isMarkedNonRetryable, st as coerceCfnBoolean, t as DEFAULT_RESOURCE_TIMEOUT_MS, tn as buildDockerImage, tt as getAccountInfo, u as updatePartialMessage, un as getDefaultStateBucketName, ur as markNonRetryable, ut as readConfigString, v as PRE_DELETE_SNAPSHOT_TYPES, vn as resolveUseCdkBootstrapAssets, vt as createSecretMasker, w as unsupportedFinalSnapshotError, wn as findLargeInlineResources, wt as s3BucketDualStackDomainName, x as createPreDeleteFinalSnapshot, xn as CFN_TEMPLATE_BODY_LIMIT, xt as scrubResourceRecord, y as buildFinalSnapshotIdentifier, yn as stateBucketExistenceConfirmed, yt as maskSecretsInText, z as exportAliasCollisionScrubWarning, zn as CdkdError, zt as stringifyValue } from "./deploy-engine-36yWG1OT.js";
3
3
  import { a as getLiveRenderer, c as PATTERN_B_RESOURCE_TYPES, d as generateResourceNameWithFallback, h as withStackName, i as runStackBuffered, m as withSkipPrefix, n as getLogger, o as PATTERN_B_NAME_OPTIONS, s as PATTERN_B_NAME_PROPERTIES, u as generateResourceName } from "./logger-zRrlbaQt.js";
4
- import { t as ASGProvider } from "./asg-provider-B9L-88MC.js";
4
+ import { t as ASGProvider } from "./asg-provider-jYyUCsx_.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";
@@ -995,12 +995,66 @@ async function deleteContainerRepo(containerRepo, region, profile, logger) {
995
995
  ecrClient.destroy();
996
996
  }
997
997
  }
998
- /**
999
- * List OTHER regions that still have a bootstrap marker in the state
1000
- * bucket (i.e. are still opted in to cdkd asset storage).
1001
- */
1002
- async function listOtherBootstrapRegions(stateBackend, region) {
1003
- return (await stateBackend.listRawKeys(BOOTSTRAP_MARKER_PREFIX)).filter((k) => k.endsWith(".json")).map((k) => k.slice(BOOTSTRAP_MARKER_PREFIX.length, -5)).filter((r) => r.length > 0 && r !== region);
998
+ /** The region spelling a bootstrap marker key carries, e.g. `Us-East-1`. */
999
+ function markerRegionOfKey(markerKey) {
1000
+ return markerKey.slice(BOOTSTRAP_MARKER_PREFIX.length, -5);
1001
+ }
1002
+ /**
1003
+ * Partition the state bucket's bootstrap markers, relative to the ONE this run
1004
+ * resolved, into the two kinds that must not be silently destroyed with the
1005
+ * bucket:
1006
+ *
1007
+ * - `otherRegions` — markers for a DIFFERENT region, still opted in.
1008
+ * - `sameRegionKeys` — markers for THIS region under a different SPELLING of
1009
+ * its name. The teardown deletes exactly one key, so every other spelling
1010
+ * survives, naming asset storage this run did not touch.
1011
+ *
1012
+ * Both come from ONE listing, and the second kind is why the listing rather
1013
+ * than a per-key probe is the source: a probe is only as deep as the keys it
1014
+ * guesses (`us-east-1` / `US-EAST-1`), while a region can hold a marker under
1015
+ * any spelling (`Us-East-1`), and the listing sees all of them.
1016
+ *
1017
+ * `region` arrives CANONICAL (issue #1995) while the key segments are whatever
1018
+ * `cdkd bootstrap` actually wrote, which is not folded (issue #1820) — so the
1019
+ * two sides are compared through {@link canonicalizeRegion} rather than
1020
+ * directly. A raw compare re-introduces this command's own bug in a different
1021
+ * flag: with a marker at `cdkd-bootstrap/US-EAST-1.json`,
1022
+ * `--destroy --region US-EAST-1 --include-state-bucket` would list `US-EAST-1`
1023
+ * as an "other" region, refuse with `STATE_BUCKET_HOLDS_MARKERS` naming the
1024
+ * very region being torn down, and tell the user to run the command they just
1025
+ * ran. It aborts BEFORE the teardown, so the bucket and repo survive too.
1026
+ *
1027
+ * Other regions are deduplicated by the folded name, so a region bootstrapped
1028
+ * twice under two spellings is reported once rather than twice.
1029
+ *
1030
+ * The comparison folds but the REPORTED value stays the RAW key segment, and
1031
+ * that asymmetry is load-bearing. The error tells the user to run
1032
+ * `cdkd bootstrap --destroy --region <r>` for each name printed, and only the
1033
+ * raw spelling actually reaches the marker: `--region eu-west-1` against a
1034
+ * marker at `cdkd-bootstrap/EU-WEST-1.json` probes the canonical key (absent),
1035
+ * finds `rawMarkerKey === markerKey`, skips the second probe, and reports
1036
+ * "nothing to delete". Printing the folded name would hand the user a command
1037
+ * that cannot work.
1038
+ */
1039
+ async function listBootstrapMarkerSiblings(stateBackend, region, resolvedMarkerKey) {
1040
+ const segments = (await stateBackend.listRawKeys(BOOTSTRAP_MARKER_PREFIX)).filter((k) => k.endsWith(".json")).map((k) => {
1041
+ const raw = k.slice(BOOTSTRAP_MARKER_PREFIX.length, -5);
1042
+ return {
1043
+ key: k,
1044
+ raw,
1045
+ folded: canonicalizeRegion(raw)
1046
+ };
1047
+ }).filter((e) => e.raw.length > 0);
1048
+ const byCanonical = /* @__PURE__ */ new Map();
1049
+ for (const { raw, folded } of segments) {
1050
+ if (folded === region) continue;
1051
+ if (!byCanonical.has(folded)) byCanonical.set(folded, raw);
1052
+ }
1053
+ const sameRegionKeys = segments.filter((e) => e.folded === region && e.key !== resolvedMarkerKey).map((e) => e.key);
1054
+ return {
1055
+ otherRegions: [...byCanonical.values()],
1056
+ sameRegionKeys
1057
+ };
1004
1058
  }
1005
1059
  /**
1006
1060
  * `cdkd bootstrap --destroy` implementation. Dispatched from the bootstrap
@@ -1013,14 +1067,16 @@ async function bootstrapDestroyCommand(options) {
1013
1067
  logger.debug("Options:", options);
1014
1068
  await applyRoleArnIfSet({
1015
1069
  roleArn: options.roleArn,
1016
- region: options.region
1070
+ region: canonicalizeRegion(options.region)
1017
1071
  });
1072
+ const rawRegion = options.region || process.env["AWS_REGION"] || "us-east-1";
1073
+ const region = canonicalizeRegion(rawRegion);
1074
+ const clientRegion = canonicalizeRegion(options.region || process.env["AWS_REGION"]) || void 0;
1018
1075
  const awsClients = new AwsClients({
1019
- ...options.region && { region: options.region },
1076
+ ...clientRegion !== void 0 && { region: clientRegion },
1020
1077
  ...options.profile && { profile: options.profile }
1021
1078
  });
1022
1079
  setAwsClients(awsClients);
1023
- const region = options.region || process.env["AWS_REGION"] || "us-east-1";
1024
1080
  const accountId = (await awsClients.sts.send(new GetCallerIdentityCommand({}))).Account;
1025
1081
  const bucketName = options.stateBucket ?? getDefaultStateBucketName(accountId);
1026
1082
  const markerS3Client = new S3Client({
@@ -1036,9 +1092,18 @@ async function bootstrapDestroyCommand(options) {
1036
1092
  });
1037
1093
  try {
1038
1094
  const markerKey = getBootstrapMarkerKey(region);
1095
+ const rawMarkerKey = getBootstrapMarkerKey(rawRegion);
1039
1096
  let markerBody;
1097
+ let resolvedMarkerKey = markerKey;
1040
1098
  try {
1041
1099
  markerBody = await stateBackend.getRawObject(markerKey);
1100
+ if (markerBody === null && rawMarkerKey !== markerKey) {
1101
+ markerBody = await stateBackend.getRawObject(rawMarkerKey);
1102
+ if (markerBody !== null) {
1103
+ resolvedMarkerKey = rawMarkerKey;
1104
+ logger.debug(`Bootstrap marker found at the un-folded key '${rawMarkerKey}' (none at '${markerKey}') — an upper-cased region was used at 'cdkd bootstrap' time.`);
1105
+ }
1106
+ }
1042
1107
  } catch (error) {
1043
1108
  if (error.name === "NoSuchBucket") {
1044
1109
  logger.info(`State bucket '${bucketName}' does not exist — this account/region was never bootstrapped; nothing to delete.`);
@@ -1046,14 +1111,29 @@ async function bootstrapDestroyCommand(options) {
1046
1111
  }
1047
1112
  throw error;
1048
1113
  }
1114
+ let markerSiblings = {
1115
+ otherRegions: [],
1116
+ sameRegionKeys: []
1117
+ };
1118
+ let markerListingSucceeded = true;
1119
+ try {
1120
+ markerSiblings = await listBootstrapMarkerSiblings(stateBackend, region, resolvedMarkerKey);
1121
+ } catch (error) {
1122
+ markerListingSucceeded = false;
1123
+ if (options.includeStateBucket) throw new CdkdError(`Cannot verify which bootstrap markers the state bucket '${bucketName}' still holds (listing '${BOOTSTRAP_MARKER_PREFIX}' failed), and --include-state-bucket deletes every one of them. Grant s3:ListBucket on this bucket and re-run.`, "STATE_BUCKET_MARKER_SCAN_FAILED", error);
1124
+ logger.warn(`Could not list bootstrap markers under '${BOOTSTRAP_MARKER_PREFIX}' (${error instanceof Error ? error.message : String(error)}). Continuing — this only means a marker for this region under another spelling of its name would not be reported.`);
1125
+ }
1049
1126
  let marker;
1050
1127
  if (markerBody === null) {
1051
- logger.info(`No bootstrap marker for region '${region}' (${markerKey}) — asset storage is not opted in for this region (or was already destroyed); nothing to delete.`);
1128
+ const probed = rawMarkerKey === markerKey ? markerKey : `${markerKey}, ${rawMarkerKey}`;
1129
+ logger.info(`No bootstrap marker for region '${region}' (${probed}) — asset storage is not opted in for this region (or was already destroyed); nothing to delete.`);
1130
+ if (markerSiblings.sameRegionKeys.length > 0) for (const siblingKey of markerSiblings.sameRegionKeys) logger.warn(`A bootstrap marker for this region DOES exist at '${siblingKey}', under a different spelling of the region name — its asset bucket and ECR repository are still alive. Re-run 'cdkd bootstrap --destroy --region ${markerRegionOfKey(siblingKey)}' to tear it down.`);
1052
1131
  if (!options.includeStateBucket) {
1053
- logger.info(`If the asset bucket / ECR repo still exist without a marker, re-run 'cdkd bootstrap --region ${region}' to recreate the marker, then destroy again.`);
1132
+ if (!markerListingSucceeded) logger.warn(`The bootstrap-marker listing could not be made, so it is NOT known whether this region has a marker under another spelling of its name. Check 's3://${bucketName}/${BOOTSTRAP_MARKER_PREFIX}' before re-bootstrapping — re-running 'cdkd bootstrap' while one exists writes a SECOND marker with DEFAULT names, and the next destroy would then tear down that storage while the storage the existing marker names survives.`);
1133
+ else if (markerSiblings.sameRegionKeys.length === 0) logger.info(`If the asset bucket / ECR repo still exist without a marker, re-run 'cdkd bootstrap --region ${region}' to recreate the marker, then destroy again.`);
1054
1134
  return;
1055
1135
  }
1056
- } else marker = parseBootstrapMarker(markerBody, markerKey);
1136
+ } else marker = parseBootstrapMarker(markerBody, resolvedMarkerKey);
1057
1137
  if (marker && !options.force) {
1058
1138
  logger.info("Scanning stack state for references to the asset storage...");
1059
1139
  const referencing = await scanStateReferences(stateBackend, [marker.assetBucket, marker.containerRepo]);
@@ -1065,14 +1145,19 @@ async function bootstrapDestroyCommand(options) {
1065
1145
  const listing = stateKeys.map((k) => ` - ${describeStateKey(k)} [${k}]`).join("\n");
1066
1146
  throw new CdkdError(`Refusing to delete state bucket '${bucketName}': ${stateKeys.length} stack(s) still have state in it:\n${listing}\nDestroy every stack ('cdkd destroy' / 'cdkd state destroy') before deleting the state bucket.`, "STATE_BUCKET_NOT_EMPTY");
1067
1147
  }
1068
- const otherRegions = await listOtherBootstrapRegions(stateBackend, region);
1069
- if (otherRegions.length > 0) throw new CdkdError(`Refusing to delete state bucket '${bucketName}': region(s) ${otherRegions.join(", ")} are still opted in to cdkd asset storage (their bootstrap markers live in this bucket). Run 'cdkd bootstrap --destroy --region <r>' for each first.`, "STATE_BUCKET_HOLDS_MARKERS");
1148
+ if (markerSiblings.otherRegions.length > 0) throw new CdkdError(`Refusing to delete state bucket '${bucketName}': region(s) ${markerSiblings.otherRegions.join(", ")} are still opted in to cdkd asset storage (their bootstrap markers live in this bucket). Run 'cdkd bootstrap --destroy --region <r>' for each first.`, "STATE_BUCKET_HOLDS_MARKERS");
1149
+ if (markerSiblings.sameRegionKeys.length > 0) {
1150
+ const canonicalStillPresent = markerBody !== null && resolvedMarkerKey === markerKey;
1151
+ const listing = markerSiblings.sameRegionKeys.map((k) => canonicalStillPresent ? ` - ${k}` : ` - ${k} (cdkd bootstrap --destroy --region ${markerRegionOfKey(k)})`).join("\n");
1152
+ const remedy = canonicalStillPresent ? `Run 'cdkd bootstrap --destroy --region ${region}' WITHOUT --include-state-bucket ONCE: it tears down '${resolvedMarkerKey}' and then prints the exact --region spelling for each marker still standing. Follow those, then re-run with --include-state-bucket. (No per-marker spelling is given here because '${resolvedMarkerKey}' still exists and would be resolved first — and simply repeating this same command would not clear the rest, since once it is gone this region's canonical key no longer resolves anything.)` : "Run the command shown against each marker above, then re-run with --include-state-bucket.";
1153
+ throw new CdkdError(`Refusing to delete state bucket '${bucketName}': region '${region}' has ${markerSiblings.sameRegionKeys.length} further bootstrap marker(s) under a different spelling of its name, which this teardown does not delete:\n${listing}\n${remedy}\nDestroy each one first — deleting the state bucket now would remove those markers while the asset storage they name survives, with no record of its names.`, "STATE_BUCKET_HOLDS_SIBLING_MARKERS");
1154
+ }
1070
1155
  }
1071
1156
  const planLines = [];
1072
1157
  if (marker) {
1073
1158
  planLines.push(`Asset bucket: s3://${marker.assetBucket} (region ${region}, all contents)`);
1074
1159
  planLines.push(`Container-asset ECR repository: ${marker.containerRepo} (region ${region}, all images)`);
1075
- planLines.push(`Bootstrap marker: s3://${bucketName}/${markerKey} (deleted last)`);
1160
+ planLines.push(`Bootstrap marker: s3://${bucketName}/${resolvedMarkerKey} (deleted last)`);
1076
1161
  }
1077
1162
  if (options.includeStateBucket) planLines.push(`State bucket: s3://${bucketName} (ALL contents, including all versions)`);
1078
1163
  if (!await promptBootstrapDestroyConfirm({
@@ -1085,8 +1170,10 @@ async function bootstrapDestroyCommand(options) {
1085
1170
  if (marker) {
1086
1171
  await emptyAndDeleteBucket(awsClients.s3, marker.assetBucket, accountId, "Asset bucket", logger);
1087
1172
  await deleteContainerRepo(marker.containerRepo, region, options.profile, logger);
1088
- await stateBackend.deleteRawObjects([markerKey]);
1089
- logger.info(`✓ Deleted bootstrap marker (${markerKey})`);
1173
+ await stateBackend.deleteRawObjects([resolvedMarkerKey]);
1174
+ logger.info(`✓ Deleted bootstrap marker (${resolvedMarkerKey})`);
1175
+ for (const siblingKey of markerSiblings.sameRegionKeys) logger.warn(`A further bootstrap marker for this region remains at '${siblingKey}' (this region was bootstrapped under more than one spelling of its name). It names asset storage that was NOT destroyed by this run — re-run 'cdkd bootstrap --destroy --region ${markerRegionOfKey(siblingKey)}' to tear that one down too.`);
1176
+ if (!markerListingSucceeded) logger.warn(`The bootstrap-marker listing could not be made, so a marker under another spelling of this region may still be live. Check 's3://${bucketName}/${BOOTSTRAP_MARKER_PREFIX}'.`);
1090
1177
  logger.info(`\ncdkd asset storage is now OFF for region ${region}: future deploys in this region fall back to legacy mode (CDK bootstrap destinations) unless the region is bootstrapped again.`);
1091
1178
  }
1092
1179
  if (options.includeStateBucket) {
@@ -58636,6 +58723,18 @@ function containsSecretDynamicReference(value) {
58636
58723
  if (value !== null && typeof value === "object") return Object.values(value).some(containsSecretDynamicReference);
58637
58724
  return false;
58638
58725
  }
58726
+ /**
58727
+ * Does this template carry a secret-bearing dynamic reference ANYWHERE?
58728
+ *
58729
+ * Exported for the DELETED nested-child path (issue #1948 review): that node
58730
+ * diffs its state against an EMPTY template, so it can compute nothing about
58731
+ * secrets from its own input and would render a pre-GHSA child's whole stored
58732
+ * bag in full. The caller passes the PARENT's answer instead — see
58733
+ * `diff-recursive.ts`'s `buildDeletedSubtree`.
58734
+ */
58735
+ function templateHasSecretDynamicReference(template) {
58736
+ return containsSecretDynamicReference(template);
58737
+ }
58639
58738
  /** Structural equality, mirroring the deploy engine's `outputMapsEqual` leaf rule. */
58640
58739
  function valuesEqual(a, b) {
58641
58740
  if (a === b) return true;
@@ -58685,13 +58784,21 @@ function valuesEqual(a, b) {
58685
58784
  * resolver runs with `skipDynamicReferences`, so a `{{resolve:...}}` reference
58686
58785
  * is never resolved to plaintext in the first place and the bag already holds
58687
58786
  * the expression that state stores post-redaction.
58787
+ *
58788
+ * `storedOutputs` is the bag currently in state. It is consulted for exactly
58789
+ * one decision — the LITERAL `Export.Name` of a secret-bearing stack, see issue
58790
+ * #1942 at that branch — and never merged into the result: everything else here
58791
+ * previews what the template says, and reading state anywhere else would make
58792
+ * the preview agree with the past instead of with the next deploy.
58688
58793
  */
58689
- async function resolveTemplateOutputs(template, resolveFn, conditions) {
58794
+ async function resolveTemplateOutputs(template, resolveFn, conditions, storedOutputs) {
58690
58795
  const logger = getLogger().child("OutputsDiff");
58691
- const outputs = {};
58796
+ const outputs = Object.create(null);
58692
58797
  const exportNames = /* @__PURE__ */ new Set();
58693
58798
  const failedKeys = /* @__PURE__ */ new Set();
58694
58799
  const secretSourceKeys = /* @__PURE__ */ new Set();
58800
+ const declaredKeys = /* @__PURE__ */ new Set();
58801
+ const templateHasSecretReference = containsSecretDynamicReference(template);
58695
58802
  let resolutionFailed = false;
58696
58803
  const publishedOutputNames = collectPublishedOutputNames(template.Outputs ?? {}, conditions);
58697
58804
  if (!template.Outputs) return {
@@ -58699,6 +58806,8 @@ async function resolveTemplateOutputs(template, resolveFn, conditions) {
58699
58806
  exportNames,
58700
58807
  failedKeys,
58701
58808
  secretSourceKeys,
58809
+ declaredKeys,
58810
+ templateHasSecretReference,
58702
58811
  resolutionFailed
58703
58812
  };
58704
58813
  /**
@@ -58712,6 +58821,8 @@ async function resolveTemplateOutputs(template, resolveFn, conditions) {
58712
58821
  if (typeof output.Export?.Name === "string") failedKeys.add(output.Export.Name);
58713
58822
  };
58714
58823
  for (const [outputKey, output] of Object.entries(template.Outputs)) {
58824
+ declaredKeys.add(outputKey);
58825
+ if (typeof output.Export?.Name === "string") declaredKeys.add(output.Export.Name);
58715
58826
  if (!containsSecretDynamicReference(output.Value)) continue;
58716
58827
  secretSourceKeys.add(outputKey);
58717
58828
  if (typeof output.Export?.Name === "string") secretSourceKeys.add(output.Export.Name);
@@ -58751,11 +58862,15 @@ async function resolveTemplateOutputs(template, resolveFn, conditions) {
58751
58862
  }
58752
58863
  if (typeof exportName === "string" && !isUnresolvedValue(exportName, exportSourceUsedSub)) if (declaredExportIsIntrinsic && isSecretDynamicReference(exportName)) logger.debug(`Diff skipping export alias of ${stripControlChars(outputKey)} — the name carries a secret reference`);
58753
58864
  else if (isExportAliasCollision(exportName, outputKey, publishedOutputNames)) logger.debug(`Diff skipping export alias ${stripControlChars(exportName)} of ${stripControlChars(outputKey)} — collides with an output name`);
58754
- else if (!declaredExportIsIntrinsic && secretSourceKeys.size > 0) {
58755
- logger.debug(`Diff cannot decide the export alias of ${stripControlChars(outputKey)} — a literal name may contain a resolved secret`);
58865
+ else if (!declaredExportIsIntrinsic && secretSourceKeys.size > 0) if (storedOutputs !== void 0 && Object.prototype.hasOwnProperty.call(storedOutputs, exportName)) {
58866
+ outputs[exportName] = value;
58867
+ exportNames.add(exportName);
58868
+ } else {
58869
+ logger.debug(`Diff cannot decide the export alias of ${stripControlChars(outputKey)} — a literal name may contain a resolved secret and state does not hold that key`);
58756
58870
  failedKeys.add(exportName);
58757
58871
  resolutionFailed = true;
58758
- } else {
58872
+ }
58873
+ else {
58759
58874
  outputs[exportName] = value;
58760
58875
  exportNames.add(exportName);
58761
58876
  }
@@ -58767,6 +58882,8 @@ async function resolveTemplateOutputs(template, resolveFn, conditions) {
58767
58882
  exportNames,
58768
58883
  failedKeys,
58769
58884
  secretSourceKeys,
58885
+ declaredKeys,
58886
+ templateHasSecretReference,
58770
58887
  resolutionFailed
58771
58888
  };
58772
58889
  }
@@ -58796,24 +58913,73 @@ async function resolveTemplateOutputs(template, resolveFn, conditions) {
58796
58913
  * {@link isSecretDynamicReference} (this resolver runs with
58797
58914
  * `skipDynamicReferences`) while the stored side is not; and
58798
58915
  * - `secretSourceKeys` — the template itself declares the key's value as such a
58799
- * reference. This one reaches cases the first cannot: a
58800
- * condition-skipped secret output, or one deleted from the template, has NO
58801
- * desired side at all and would otherwise print in full as a REMOVE row.
58916
+ * reference. This one reaches a case the first cannot: a condition-skipped
58917
+ * secret output has NO desired side at all and would otherwise print in full
58918
+ * as a REMOVE row.
58802
58919
  *
58803
58920
  * A hit on either makes the WHOLE record suspect, not just that key: the
58804
58921
  * record was written by a pre-GHSA binary, so every value in it is unredacted.
58805
58922
  * Withholding is therefore record-level. The change is still REPORTED — only
58806
58923
  * the value is withheld — so `--fail` and the exports story are unaffected.
58807
- */
58808
- function computeOutputsDiff(current, desired, exportNames, secretSourceKeys = /* @__PURE__ */ new Set()) {
58924
+ *
58925
+ * ## The key today's template cannot account for (issue #1948)
58926
+ *
58927
+ * Neither signal above covers an output DELETED from the template, and an
58928
+ * earlier revision of this note claimed `secretSourceKeys` did — a false
58929
+ * coverage claim, which is exactly what lets a reader conclude the case is
58930
+ * handled. It structurally cannot: that set is built from DECLARATIONS, and a
58931
+ * deleted output declares nothing, so a record whose ONLY secret-bearing output
58932
+ * has since been removed is judged not-legacy and its stored pre-GHSA plaintext
58933
+ * renders as the `old:` side of a REMOVE row.
58934
+ *
58935
+ * The signal has to come from the STORED bag, and the honest statement about
58936
+ * the stored bag is that the case is UNDECIDABLE there: post-GHSA a secret
58937
+ * output stores its `{{resolve:...}}` EXPRESSION, but pre-GHSA it stores a
58938
+ * plaintext, and a plaintext is indistinguishable from an ordinary string. So
58939
+ * this is a REFUSAL rather than a detection — a stored key the template cannot
58940
+ * account for has its value withheld — bounded by two gates that keep it off
58941
+ * the common benign case (deleting an output is a normal refactor):
58942
+ *
58943
+ * - `templateHasSecretReference` — the template must still prove this stack
58944
+ * handles secrets AT ALL (anywhere, `Resources` included). An ordinary stack
58945
+ * with no secret reference keeps printing its REMOVE values.
58946
+ * - no stored value is itself a secret-bearing expression. One that is proves
58947
+ * the LAST write was post-GHSA, and the bag is rewritten wholesale by
58948
+ * `resolveOutputs` on every deploy, so every other value in it is redacted
58949
+ * too. This exonerates the record.
58950
+ *
58951
+ * That reasoning holds for a DEPLOY write and is weaker for a `cdkd scrub`
58952
+ * one, which rewrites `state.outputs` IN PLACE: scrub redacts what it has a
58953
+ * needle or a template position for, and its own docs admit it can leave a
58954
+ * plaintext it has neither for — so a scrubbed bag holding one redacted key
58955
+ * can exonerate a deleted key's surviving plaintext. Recorded rather than
58956
+ * closed: removing the exoneration would withhold values on every clean
58957
+ * post-GHSA record in a secret-handling stack, which is the larger harm.
58958
+ *
58959
+ * Withholding here is PER-KEY, unlike the record-level arms above, and the
58960
+ * asymmetry follows from what each concludes: those conclude the record was
58961
+ * written by a pre-GHSA binary (a claim about the whole bag), while this one
58962
+ * concludes only that THIS key is undecidable. So a secret-handling stack that
58963
+ * deletes an ordinary output withholds that one row's value and prints the
58964
+ * rest.
58965
+ *
58966
+ * Residual, stated because the gate is what buys the low false-positive rate:
58967
+ * a stack whose ONLY secret reference WAS the deleted output has no secret left
58968
+ * in its template, so nothing here fires. Closing it would mean withholding
58969
+ * every REMOVE value on every stack, which is a worse trade.
58970
+ */
58971
+ function computeOutputsDiff(current, desired, exportNames, secretSourceKeys = /* @__PURE__ */ new Set(), unaccountableScan = {}) {
58809
58972
  const changes = [];
58810
58973
  const currentBag = current ?? {};
58811
58974
  const legacyRecord = Object.entries(currentBag).some(([name, oldValue]) => {
58812
58975
  if (typeof oldValue === "string" && isSecretDynamicReference(oldValue)) return false;
58813
58976
  return containsSecretDynamicReference(desired[name]) || secretSourceKeys.has(name);
58814
58977
  });
58978
+ const recordProvesPostGhsa = Object.values(currentBag).some((v) => typeof v === "string" && isSecretDynamicReference(v));
58979
+ const declaredKeys = unaccountableScan.declaredKeys ?? /* @__PURE__ */ new Set();
58980
+ const unaccountable = (name) => unaccountableScan.templateHasSecretReference === true && !recordProvesPostGhsa && !declaredKeys.has(name) && !Object.prototype.hasOwnProperty.call(desired, name);
58815
58981
  const push = (change) => {
58816
- if (legacyRecord && change.changeType !== "ADD") {
58982
+ if (change.changeType !== "ADD" && (legacyRecord || unaccountable(change.name))) {
58817
58983
  const { oldValue: _dropped, ...rest } = change;
58818
58984
  changes.push({
58819
58985
  ...rest,
@@ -59824,7 +59990,8 @@ async function loadStateOrEmpty(stackName, region, stateBackend) {
59824
59990
  * Pure with respect to AWS state mutation — only reads state (the resolver
59825
59991
  * may read producer state for `Fn::ImportValue` / `Fn::GetStackOutput`).
59826
59992
  */
59827
- async function computeStackDiff(currentState, template, region, stackName, stateBackend, diffCalculator, parameters, canonicalizeProperties, cfnFallback) {
59993
+ async function computeStackDiff(currentState, template, region, stackName, stateBackend, diffCalculator, options = {}) {
59994
+ const { parameters, canonicalizeProperties, cfnFallback, inheritSecretBearingTemplate } = options;
59828
59995
  const intrinsicResolver = new IntrinsicFunctionResolver(region, { cfnFallback: cfnFallback ?? true });
59829
59996
  let mergedParameters = parameters;
59830
59997
  let parametersBound = false;
@@ -59866,11 +60033,17 @@ async function computeStackDiff(currentState, template, region, stackName, state
59866
60033
  skipDynamicReferences: true
59867
60034
  });
59868
60035
  const changes = await diffCalculator.calculateDiff(currentState, effectiveTemplate, resolveFn, canonicalizeProperties);
59869
- const resolved = await resolveTemplateOutputs(effectiveTemplate, resolveFn, conditions);
60036
+ const resolved = await resolveTemplateOutputs(effectiveTemplate, resolveFn, conditions, currentState.outputs);
59870
60037
  let outputChanges = [];
59871
60038
  if (resolved.resolutionFailed) {
59872
- if (computeOutputsDiff(currentState.outputs, resolved.outputs, resolved.exportNames, resolved.secretSourceKeys).filter((change) => !resolved.failedKeys.has(change.name)).length > 0) logger.warn(`Outputs of stack ${stackName} may have changed, but one or more could not be resolved against current state — omitting the Outputs section from this diff. It is usually an output referencing a resource this deploy has yet to create.`);
59873
- } else outputChanges = computeOutputsDiff(currentState.outputs, resolved.outputs, resolved.exportNames, resolved.secretSourceKeys);
60039
+ if (computeOutputsDiff(currentState.outputs, resolved.outputs, resolved.exportNames, resolved.secretSourceKeys, {
60040
+ declaredKeys: resolved.declaredKeys,
60041
+ templateHasSecretReference: resolved.templateHasSecretReference || inheritSecretBearingTemplate === true
60042
+ }).filter((change) => !resolved.failedKeys.has(change.name)).length > 0) logger.warn(`Outputs of stack ${stackName} may have changed, but one or more could not be resolved against current state — omitting the Outputs section from this diff. It is usually an output referencing a resource this deploy has yet to create.`);
60043
+ } else outputChanges = computeOutputsDiff(currentState.outputs, resolved.outputs, resolved.exportNames, resolved.secretSourceKeys, {
60044
+ declaredKeys: resolved.declaredKeys,
60045
+ templateHasSecretReference: resolved.templateHasSecretReference || inheritSecretBearingTemplate === true
60046
+ });
59874
60047
  return {
59875
60048
  changes,
59876
60049
  outputChanges
@@ -59935,9 +60108,15 @@ async function resolveChildStackParameters(parentStackRow, parentTemplate, paren
59935
60108
  * fail at deploy time.
59936
60109
  */
59937
60110
  async function buildDiffTree(args) {
59938
- const { stackName, displayName, region, template, nestedTemplates, recursive, stateBackend, diffCalculator, parameters, canonicalizeProperties, assetRedirect, cfnFallback } = args;
60111
+ const { stackName, displayName, region, template, nestedTemplates, recursive, stateBackend, diffCalculator, parentHasSecretReference, parameters, canonicalizeProperties, assetRedirect, cfnFallback } = args;
59939
60112
  const state = await loadStateOrEmpty(stackName, region, stateBackend);
59940
- const { changes, outputChanges } = await computeStackDiff(state, template, region, stackName, stateBackend, diffCalculator, parameters, canonicalizeProperties, cfnFallback);
60113
+ const secretBearingAbove = parentHasSecretReference === true || templateHasSecretDynamicReference(template);
60114
+ const { changes, outputChanges } = await computeStackDiff(state, template, region, stackName, stateBackend, diffCalculator, {
60115
+ ...parameters && { parameters },
60116
+ ...canonicalizeProperties && { canonicalizeProperties },
60117
+ ...cfnFallback !== void 0 && { cfnFallback },
60118
+ inheritSecretBearingTemplate: false
60119
+ });
59941
60120
  const node = {
59942
60121
  stackName,
59943
60122
  displayName,
@@ -59971,13 +60150,14 @@ async function buildDiffTree(args) {
59971
60150
  parameters: childParameters,
59972
60151
  ...canonicalizeProperties && { canonicalizeProperties },
59973
60152
  ...assetRedirect && { assetRedirect },
59974
- ...cfnFallback !== void 0 && { cfnFallback }
60153
+ ...cfnFallback !== void 0 && { cfnFallback },
60154
+ parentHasSecretReference: secretBearingAbove
59975
60155
  }));
59976
60156
  }
59977
60157
  for (const [logicalId, resource] of Object.entries(state.resources)) {
59978
60158
  if (resource.resourceType !== "AWS::CloudFormation::Stack") continue;
59979
60159
  if (templateChildIds.has(logicalId)) continue;
59980
- node.children.push(await buildDeletedSubtree(`${stackName}~${logicalId}`, region, stateBackend, diffCalculator));
60160
+ node.children.push(await buildDeletedSubtree(`${stackName}~${logicalId}`, region, stateBackend, diffCalculator, secretBearingAbove));
59981
60161
  }
59982
60162
  return node;
59983
60163
  }
@@ -59987,9 +60167,9 @@ async function buildDiffTree(args) {
59987
60167
  * (state vs empty template), recursing into state-listed grandchildren
59988
60168
  * (also all DELETE).
59989
60169
  */
59990
- async function buildDeletedSubtree(stackName, region, stateBackend, diffCalculator) {
60170
+ async function buildDeletedSubtree(stackName, region, stateBackend, diffCalculator, parentHasSecretReference) {
59991
60171
  const state = await loadStateOrEmpty(stackName, region, stateBackend);
59992
- const { changes, outputChanges } = await computeStackDiff(state, EMPTY_TEMPLATE, region, stackName, stateBackend, diffCalculator);
60172
+ const { changes, outputChanges } = await computeStackDiff(state, EMPTY_TEMPLATE, region, stackName, stateBackend, diffCalculator, { inheritSecretBearingTemplate: parentHasSecretReference });
59993
60173
  const node = {
59994
60174
  stackName,
59995
60175
  displayName: stackName,
@@ -60001,7 +60181,7 @@ async function buildDeletedSubtree(stackName, region, stateBackend, diffCalculat
60001
60181
  };
60002
60182
  for (const [logicalId, resource] of Object.entries(state.resources)) {
60003
60183
  if (resource.resourceType !== "AWS::CloudFormation::Stack") continue;
60004
- node.children.push(await buildDeletedSubtree(`${stackName}~${logicalId}`, region, stateBackend, diffCalculator));
60184
+ node.children.push(await buildDeletedSubtree(`${stackName}~${logicalId}`, region, stateBackend, diffCalculator, parentHasSecretReference));
60005
60185
  }
60006
60186
  return node;
60007
60187
  }
@@ -60231,25 +60411,19 @@ function renderChangeLines(changes, logFn, ccApiRoutes) {
60231
60411
  delete: deleteCount
60232
60412
  };
60233
60413
  }
60234
- /** Stand-in printed instead of a withheld legacy-plaintext output value. */
60235
- const REDACTED_LEGACY_PLAINTEXT = "<redacted: legacy plaintext in state — run `cdkd scrub`>";
60236
60414
  /**
60237
- * Strip characters that let a template-controlled string manipulate the
60238
- * TERMINAL rather than merely appear in it: C0 (incl. ESC, so no ANSI sequence
60239
- * survives), DEL, C1 (the 8-bit CSI forms some terminals still honor), and the
60240
- * bidi / format overrides that visually reorder a rendered line.
60241
- *
60242
- * Applied only on the human-render path. The `--json` payload is deliberately
60243
- * left byte-faithful: it is a machine interface, an export NAME is data a
60244
- * consumer may match on, and silently mutating it there would trade a real
60245
- * correctness regression for a display concern belonging to whatever renders
60246
- * the JSON. `JSON.stringify` already escapes everything below 0x20.
60415
+ * Stand-in printed instead of a withheld stored output value.
60247
60416
  *
60248
- * The helper itself now lives in `src/utils/regexp.ts` `outputs-export-alias`
60249
- * prints the same class of string and had grown a second copy.
60417
+ * Worded as a POSSIBILITY because both reasons the value is withheld are
60418
+ * suspicions rather than detections a record that LOOKS pre-GHSA, and a
60419
+ * stored key today's template cannot account for (issue #1948), which is
60420
+ * undecidable by construction. Stating it as a fact would be wrong on the
60421
+ * benign half of each, and `cdkd scrub` finding nothing is the expected
60422
+ * outcome there.
60250
60423
  */
60424
+ const REDACTED_LEGACY_PLAINTEXT = "<redacted: may be legacy plaintext in state — run `cdkd scrub`>";
60251
60425
  /**
60252
- * The same guard for an already-JSON-SERIALIZED value: C1 and the bidi marks
60426
+ * The guard for an already-JSON-SERIALIZED value: C1 and the bidi marks
60253
60427
  * only, deliberately NOT C0.
60254
60428
  *
60255
60429
  * `JSON.stringify` escapes every character below 0x20 that occurs INSIDE a
@@ -67801,13 +67975,13 @@ async function stateResourcesCommand(stackName, options) {
67801
67975
  for (const detail of details) {
67802
67976
  lines.push(detail.logicalId);
67803
67977
  lines.push(` Type: ${detail.resourceType}`);
67804
- lines.push(` PhysicalID: ${detail.physicalId}`);
67978
+ lines.push(` PhysicalID: ${stripControlChars(detail.physicalId)}`);
67805
67979
  lines.push(` Dependencies: ${detail.dependencies.length > 0 ? detail.dependencies.join(", ") : "(none)"}`);
67806
67980
  const attrEntries = Object.entries(detail.attributes);
67807
67981
  if (attrEntries.length === 0) lines.push(" Attributes: (none)");
67808
67982
  else {
67809
67983
  lines.push(" Attributes:");
67810
- for (const [k, v] of attrEntries) lines.push(` ${k}: ${formatAttributeValue(v)}`);
67984
+ for (const [k, v] of attrEntries) lines.push(` ${stripControlChars(k)}: ${formatAttributeValue(v)}`);
67811
67985
  }
67812
67986
  lines.push("");
67813
67987
  }
@@ -67827,11 +68001,31 @@ async function stateResourcesCommand(stackName, options) {
67827
68001
  *
67828
68002
  * Scalar values render as-is; objects/arrays are JSON-encoded inline so a
67829
68003
  * resource block stays compact even when an attribute is structured.
68004
+ *
68005
+ * Control characters are stripped HERE rather than at each call site (issue
68006
+ * #1948 review), because every caller has the same provenance problem and the
68007
+ * same answer: an Outputs value, a resource PROPERTY and a provider-returned
68008
+ * ATTRIBUTE are all values cdkd resolved or read back, none of them passed a
68009
+ * CloudFormation validator, and all three land in the same terminal. Doing it
68010
+ * once means a future caller cannot forget — the half-applied version of this
68011
+ * guard is what the review caught.
68012
+ *
68013
+ * The FULL class (C0 included), unlike `diff-recursive`'s
68014
+ * `stripDisplayOnlyChars`: that narrower guard exists to protect a
68015
+ * PRETTY-PRINTED payload's structural newlines, and this function never
68016
+ * pretty-prints — `JSON.stringify` is called without an indent, so a newline
68017
+ * here is content injected by the value, not layout.
68018
+ *
68019
+ * Only the human path routes through this; `--json` serializes the state
68020
+ * record directly and stays byte-faithful, which is the same split
68021
+ * `renderOutputChangeLines` makes.
67830
68022
  */
67831
68023
  function formatAttributeValue(value) {
67832
68024
  if (value === null) return "null";
67833
- if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return String(value);
67834
- return JSON.stringify(value);
68025
+ if (value === void 0) return "undefined";
68026
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return stripControlChars(String(value));
68027
+ const json = JSON.stringify(value);
68028
+ return json === void 0 ? "(unserializable)" : stripControlChars(json);
67835
68029
  }
67836
68030
  /**
67837
68031
  * Render a duration in milliseconds as `1m23s` / `45s`.
@@ -67933,7 +68127,7 @@ function renderStateBlock(state, lockInfo) {
67933
68127
  if (outputEntries.length > 0) {
67934
68128
  lines.push("");
67935
68129
  lines.push("Outputs:");
67936
- for (const [k, v] of outputEntries) lines.push(` ${k}: ${formatAttributeValue(v)}`);
68130
+ for (const [k, v] of outputEntries) lines.push(` ${stripControlChars(k)}: ${formatAttributeValue(v)}`);
67937
68131
  }
67938
68132
  const resourceEntries = Object.entries(state.resources ?? {}).sort(([a], [b]) => a.localeCompare(b));
67939
68133
  lines.push("");
@@ -67942,7 +68136,7 @@ function renderStateBlock(state, lockInfo) {
67942
68136
  lines.push("");
67943
68137
  lines.push(logicalId);
67944
68138
  lines.push(` Type: ${resource.resourceType}`);
67945
- lines.push(` PhysicalID: ${resource.physicalId}`);
68139
+ lines.push(` PhysicalID: ${stripControlChars(resource.physicalId)}`);
67946
68140
  const provisionedBy = resource.provisionedBy ?? "(sdk, legacy default)";
67947
68141
  lines.push(` ProvisionedBy: ${provisionedBy}`);
67948
68142
  const deps = resource.dependencies ?? [];
@@ -67951,13 +68145,13 @@ function renderStateBlock(state, lockInfo) {
67951
68145
  if (attrEntries.length === 0) lines.push(" Attributes: (none)");
67952
68146
  else {
67953
68147
  lines.push(" Attributes:");
67954
- for (const [k, v] of attrEntries) lines.push(` ${k}: ${formatAttributeValue(v)}`);
68148
+ for (const [k, v] of attrEntries) lines.push(` ${stripControlChars(k)}: ${formatAttributeValue(v)}`);
67955
68149
  }
67956
68150
  const propEntries = Object.entries(resource.properties ?? {});
67957
68151
  if (propEntries.length === 0) lines.push(" Properties: (none)");
67958
68152
  else {
67959
68153
  lines.push(" Properties:");
67960
- for (const [k, v] of propEntries) lines.push(` ${k}: ${formatAttributeValue(v)}`);
68154
+ for (const [k, v] of propEntries) lines.push(` ${stripControlChars(k)}: ${formatAttributeValue(v)}`);
67961
68155
  }
67962
68156
  }
67963
68157
  return lines;
@@ -68628,6 +68822,19 @@ async function stateRefreshObservedCommand(stackArgs, options) {
68628
68822
  }
68629
68823
  }
68630
68824
  /**
68825
+ * The secrets map every redaction on this command's paths gets, and it is
68826
+ * EMPTY by construction (issue #1926).
68827
+ *
68828
+ * `cdkd state refresh-observed` neither synthesizes a template nor resolves a
68829
+ * dynamic reference, so no plaintext is ever recorded here and a VALUE scan
68830
+ * would have no needles at all. That is the issue #1900 shape, and it is why
68831
+ * the PATH pass has to carry the redaction on its own — see the call site.
68832
+ *
68833
+ * Shared rather than constructed per resource because nothing writes to it:
68834
+ * `redactSecretsForState` only reads its argument.
68835
+ */
68836
+ const NO_RECORDED_SECRETS = /* @__PURE__ */ new Map();
68837
+ /**
68631
68838
  * Refresh the `observedProperties` of every resource in one stack
68632
68839
  * record. Returns counts so the caller can aggregate across `--all`.
68633
68840
  *
@@ -68706,7 +68913,7 @@ async function refreshObservedForStack(stackName, region, stateBackend, lockMana
68706
68913
  unsupported++;
68707
68914
  return;
68708
68915
  }
68709
- resource.observedProperties = observed;
68916
+ resource.observedProperties = redactSecretsForState(observed, NO_RECORDED_SECRETS, resource.properties ?? {}, STATE_SOURCED_READBACK_RULES);
68710
68917
  refreshed++;
68711
68918
  } catch (err) {
68712
68919
  failed++;
@@ -68747,7 +68954,7 @@ async function confirmRefresh(prompt) {
68747
68954
  * Create the `state refresh-observed` subcommand.
68748
68955
  */
68749
68956
  function createStateRefreshObservedCommand() {
68750
- const cmd = new Command("refresh-observed").description("Refresh observedProperties for every resource in a stack by calling provider.readCurrentState — populates the drift baseline for stacks deployed before state schema v3, without redeploying.").argument("[stacks...]", "Stack name(s) to refresh (physical CloudFormation names)").option("--all", "Refresh every stack in the state bucket", false).option("--dry-run", "Print the per-stack refresh count without writing state", false).addOption(stackRegionOption$1()).action(withErrorHandling(stateRefreshObservedCommand));
68957
+ const cmd = new Command("refresh-observed").description("Refresh observedProperties for every resource in a stack by calling provider.readCurrentState — populates the drift baseline for stacks deployed before state schema v3, without redeploying. Run cdkd scrub first on state written by a pre-GHSA binary: the readback is redacted by POSITION against the record, so a record whose own properties still hold plaintext has nothing to redact from.").argument("[stacks...]", "Stack name(s) to refresh (physical CloudFormation names)").option("--all", "Refresh every stack in the state bucket", false).option("--dry-run", "Print the per-stack refresh count without writing state", false).addOption(stackRegionOption$1()).action(withErrorHandling(stateRefreshObservedCommand));
68751
68958
  [...commonOptions, ...stateOptions].forEach((opt) => cmd.addOption(opt));
68752
68959
  cmd.addOption(deprecatedRegionOption);
68753
68960
  return cmd;
@@ -69864,6 +70071,107 @@ const KEY_TERMINATORS = "[^\\s\"'?]";
69864
70071
  */
69865
70072
  const GC_EXTRA_URL_SUFFIXES = [];
69866
70073
  /**
70074
+ * Every AWS partition's URL suffix. The asset-reference matchers match the
70075
+ * suffix against this CLOSED SET instead of hardcoding one literal
70076
+ * (issue #1781).
70077
+ *
70078
+ * Before this list, all three matchers spelled `amazonaws\.com` inline, so a
70079
+ * state file written in `aws-cn` / `us-iso*` / `us-isob*` (recording
70080
+ * `amazonaws.com.cn` / `c2s.ic.gov` / `sc2s.sgov.gov` hosts) matched none of
70081
+ * them and its assets read as UNREFERENCED.
70082
+ *
70083
+ * **What that actually deleted, measured rather than assumed.** The blast
70084
+ * radius is narrower than "every live asset" but still a silent, irreversible
70085
+ * delete:
70086
+ *
70087
+ * - **S3 was already rescued**, by accident. {@link CONTENT_HASH_KEY_RE}
70088
+ * collects `<sha256>.<ext>` tokens out of ANY string regardless of host, and
70089
+ * cdkd's file assets are content-addressed with an extension — so a
70090
+ * non-commercial `https://…amazonaws.com.cn/<sha256>.zip` still had its key
70091
+ * protected by the name-independent pass. Measured on a real `--dry-run`
70092
+ * against a seeded `cn-north-1` state file: 71 of 72 objects, including the
70093
+ * referenced one, were protected with the matchers still broken.
70094
+ * - **ECR was NOT rescued, and is where the bug actually deleted.** cdkd's
70095
+ * image tags are bare 64-hex with no `.<ext>` tail, so the content-hash pass
70096
+ * cannot see them, and digests are `sha256:<hex>` — also no tail. With the
70097
+ * ECR matcher missing the suffix, a referenced non-commercial image is
70098
+ * selected for deletion. That is the case the live test reproduced.
70099
+ *
70100
+ * Either way the failure direction is the opposite of the #1758 / #1745 sites,
70101
+ * which merely emit a host that does not resolve.
70102
+ *
70103
+ * Two properties of this list are load-bearing:
70104
+ *
70105
+ * - It is the union over ALL partitions, not
70106
+ * `derivePartitionAndUrlSuffix(region).urlSuffix` for the caller's region.
70107
+ * The scan reads EVERY state file in the state bucket, written by any cdkd
70108
+ * binary for any region, so it must match every partition at once; a single
70109
+ * derived literal would still delete a `cn-north-1` stack's images during a
70110
+ * `us-east-1` gc run.
70111
+ * - It is a SUPERSET of the arms {@link derivePartitionAndUrlSuffix} knows
70112
+ * (issue #1764). A suffix missing HERE deletes live assets, which is the
70113
+ * irreversible direction, so this list must lead that table rather than
70114
+ * follow it.
70115
+ *
70116
+ * The superset property is now MECHANICAL rather than hand-maintained (issue
70117
+ * #1785): the list is built FROM {@link PARTITION_TABLE} plus
70118
+ * {@link GC_EXTRA_URL_SUFFIXES}, so a partition arm added to that table
70119
+ * propagates here in the same commit instead of silently outrunning gc. It
70120
+ * used to be a hand-written literal, and the test meant to fence it iterated a
70121
+ * hand-written region list — so a new arm in the derive table redded nothing.
70122
+ *
70123
+ * It is a closed SET rather than a `[^/\s]+` wildcard so a look-alike host
70124
+ * (`https://<assetBucket>.s3.<region>.example.com/<key>`) is not treated as a
70125
+ * cdkd asset reference. That direction only ever over-PROTECTS, but it would
70126
+ * also let any string embedding the bucket name pin an object forever, which
70127
+ * quietly turns gc into a no-op.
70128
+ *
70129
+ * NOTE this is a weaker check than `src/utils/ecr-uri.ts`, which CAPTURES the
70130
+ * suffix and validates it against the region the host names. That is the right
70131
+ * shape there (one host, one caller-known region) and the wrong shape here:
70132
+ * the region in a scanned string is not necessarily the region whose partition
70133
+ * the suffix must belong to, and pairing them would re-inherit #1764's missing
70134
+ * arms on the irreversible side.
70135
+ */
70136
+ const AWS_URL_SUFFIXES = [.../* @__PURE__ */ new Set([...PARTITION_TABLE.map((row) => row.urlSuffix), ...GC_EXTRA_URL_SUFFIXES])];
70137
+ /**
70138
+ * Spell an ASCII literal so it matches case-INSENSITIVELY inside a
70139
+ * case-SENSITIVE `RegExp`: `s3` → `[sS]3`, `amazonaws.com` →
70140
+ * `[aA][mM][aA][zZ][oO][nN][aA][wW][sS]\.[cC][oO][mM]`. EVERY character goes
70141
+ * through {@link escapeRegExp} first and the ASCII letters are folded after,
70142
+ * so the result is exactly as strict as an escaped literal outside the letters
70143
+ * it folds.
70144
+ *
70145
+ * This exists because gc must fold only PART of a pattern (issue #1847). Host
70146
+ * names are case-insensitive, so
70147
+ * `https://<assetBucket>.s3.<region>.AMAZONAWS.COM/<key>` in a state file names
70148
+ * the same object as the lower-cased spelling — and a reference gc misses reads
70149
+ * as UNREFERENCED, so the live object is DELETED. The obvious fix, the `i` flag
70150
+ * the ECR matcher carries, is per-REGEX and therefore all-or-nothing: it cannot
70151
+ * fold the host while leaving a KEY or a path-segment bucket exact, and those
70152
+ * segments are genuinely case-sensitive at S3. Which segment gets folded is
70153
+ * decided per shape at the matchers themselves — see the block above
70154
+ * `s3UriRe` for the authority / path-segment / DNS-label split.
70155
+ *
70156
+ * Two further properties are load-bearing:
70157
+ *
70158
+ * - Folding by character class rather than by flag leaves
70159
+ * {@link URL_SUFFIX_ALTERNATION} — SHARED with the ECR matcher — spelled
70160
+ * exactly as that matcher's own review settled it, so the S3 side cannot
70161
+ * quietly redefine the constant the ECR side reads (issues #1792 / #1793).
70162
+ * - A two-character class is not `/k/i`. Under a `u` flag the `i` flag
70163
+ * canonicalizes U+212A KELVIN SIGN onto ASCII `k` — the hazard
70164
+ * `src/utils/ecr-uri.ts` guards its region / suffix checks against —
70165
+ * whereas `[kK]` matches those two code points and nothing else, with or
70166
+ * without `u`. (Measured, and pinned by `tests/unit/cli/gc.test.ts`:
70167
+ * `/k/iu` matches U+212A, `/[kK]/u` does not.) The matchers below carry no
70168
+ * `u` flag today, so this is about what adding one later could silently
70169
+ * widen: with classes, nothing.
70170
+ */
70171
+ function caseFoldLiteral(literal) {
70172
+ return escapeRegExp(literal).replace(/[A-Za-z]/g, (ch) => `[${ch.toLowerCase()}${ch.toUpperCase()}]`);
70173
+ }
70174
+ /**
69867
70175
  * `(?:amazonaws\.com\.cn|amazonaws\.com|...)` — {@link AWS_URL_SUFFIXES} as a
69868
70176
  * non-capturing alternation, LONGEST FIRST so `amazonaws.com.cn` is tried
69869
70177
  * before its own `amazonaws.com` prefix instead of relying on backtracking.
@@ -69873,8 +70181,48 @@ const GC_EXTRA_URL_SUFFIXES = [];
69873
70181
  * the ECR digest), and this alternation sits BEFORE those groups in all three
69874
70182
  * patterns. Making it capturing would shift every index by one and silently
69875
70183
  * collect the suffix as if it were a key.
70184
+ *
70185
+ * `spellSuffix` decides how each suffix is written out, so the SET, the
70186
+ * longest-first order and the non-capturing property keep exactly one
70187
+ * definition while the two consumers differ in case handling — see
70188
+ * {@link URL_SUFFIX_ALTERNATION_CASE_FOLDED}.
69876
70189
  */
69877
- const URL_SUFFIX_ALTERNATION = `(?:${[...[.../* @__PURE__ */ new Set([...PARTITION_TABLE.map((row) => row.urlSuffix), ...GC_EXTRA_URL_SUFFIXES])]].sort((a, b) => b.length - a.length).map(escapeRegExp).join("|")})`;
70190
+ function buildUrlSuffixAlternation(spellSuffix) {
70191
+ return `(?:${[...AWS_URL_SUFFIXES].sort((a, b) => b.length - a.length).map(spellSuffix).join("|")})`;
70192
+ }
70193
+ /**
70194
+ * The case-SENSITIVE spelling, consumed by {@link ECR_REGISTRY_HOST}.
70195
+ *
70196
+ * Folding THIS constant would be inert for its only consumer — the ECR matcher
70197
+ * carries the `i` flag, so it already matches an upper-cased suffix either way.
70198
+ * The constraint is the other direction: this is the spelling the ECR side's
70199
+ * own review settled on (issues #1792 / #1793), and the S3 matchers must widen
70200
+ * through their own {@link URL_SUFFIX_ALTERNATION_CASE_FOLDED} copy rather than
70201
+ * by editing the shared one, so an S3-side change can never reach the ECR
70202
+ * grammar.
70203
+ */
70204
+ const URL_SUFFIX_ALTERNATION = buildUrlSuffixAlternation(escapeRegExp);
70205
+ /**
70206
+ * The case-FOLDED spelling, consumed by the three S3 matchers (issue #1847).
70207
+ * Same suffix set, same order, letters folded per {@link caseFoldLiteral} — so
70208
+ * an upper-cased or mixed-case host suffix collects the key. Still a CLOSED
70209
+ * set: folding widens the SPELLING of each suffix, never which suffixes count.
70210
+ */
70211
+ const URL_SUFFIX_ALTERNATION_CASE_FOLDED = buildUrlSuffixAlternation(caseFoldLiteral);
70212
+ /**
70213
+ * `[sS]3`, case-folded (issue #1847). One constant for BOTH places the literal
70214
+ * `s3` appears: the `s3://` URI SCHEME and the `s3` HOST LABEL of the two HTTPS
70215
+ * shapes. The two roles differ but the spelling and the fold argument are the
70216
+ * same, and a single constant is what stops the three matchers drifting into
70217
+ * two different spellings of it.
70218
+ */
70219
+ const S3_SEGMENT_CASE_FOLDED = caseFoldLiteral("s3");
70220
+ /**
70221
+ * `[hH][tT][tT][pP][sS]` — the URI scheme, case-folded (issue #1847). A scheme
70222
+ * is case-insensitive by RFC 3986 §3.1, and folding a fixed literal that is
70223
+ * neither the bucket name nor the key can only ever over-PROTECT.
70224
+ */
70225
+ const HTTPS_SCHEME_CASE_FOLDED = caseFoldLiteral("https");
69878
70226
  /**
69879
70227
  * The HOST half of an ECR registry, built from the ONE authoritative FORM TABLE
69880
70228
  * in `src/utils/ecr-uri.ts` (issue #1793) instead of spelled a second time here.
@@ -69967,16 +70315,21 @@ function tryDecodeBase64Text(value) {
69967
70315
  * account's or region's URI can only over-protect (keep more), never
69968
70316
  * delete more.
69969
70317
  *
69970
- * `<urlSuffix>` in the two S3 shapes is {@link URL_SUFFIX_ALTERNATION}, i.e.
69971
- * EVERY partition's suffix at once — see that constant for why a per-region
69972
- * derived literal is the wrong shape here (issue #1781).
70318
+ * `<urlSuffix>` in the two S3 shapes is
70319
+ * {@link URL_SUFFIX_ALTERNATION_CASE_FOLDED}, i.e. EVERY partition's suffix at
70320
+ * once see {@link AWS_URL_SUFFIXES} for why a per-region derived literal is
70321
+ * the wrong shape here (issue #1781). Every S3 HOST segment is matched
70322
+ * case-insensitively, including the bucket label of the virtual-hosted shape,
70323
+ * while the KEY and the bucket in the other two shapes stay EXACT (issue
70324
+ * #1847); the per-shape reasoning is in the block above `s3UriRe`.
69973
70325
  */
69974
70326
  function buildReferenceExtractors(marker) {
69975
- const bucket = escapeRegExp(marker.assetBucket);
70327
+ const bucketExact = escapeRegExp(marker.assetBucket);
70328
+ const bucketFolded = caseFoldLiteral(marker.assetBucket);
69976
70329
  const repo = escapeRegExp(marker.containerRepo);
69977
- const s3UriRe = new RegExp(`s3://${bucket}/(${KEY_TERMINATORS}+)`, "g");
69978
- const virtualHostedRe = new RegExp(`https://${bucket}\\.s3[^/\\s]*\\.${URL_SUFFIX_ALTERNATION}/(${KEY_TERMINATORS}+)`, "g");
69979
- const pathStyleRe = new RegExp(`https://s3[^/\\s]*\\.${URL_SUFFIX_ALTERNATION}/${bucket}/(${KEY_TERMINATORS}+)`, "g");
70330
+ const s3UriRe = new RegExp(`${S3_SEGMENT_CASE_FOLDED}://${bucketExact}/(${KEY_TERMINATORS}+)`, "g");
70331
+ const virtualHostedRe = new RegExp(`${HTTPS_SCHEME_CASE_FOLDED}://${bucketFolded}\\.${S3_SEGMENT_CASE_FOLDED}[^/\\s]*\\.${URL_SUFFIX_ALTERNATION_CASE_FOLDED}/(${KEY_TERMINATORS}+)`, "g");
70332
+ const pathStyleRe = new RegExp(`${HTTPS_SCHEME_CASE_FOLDED}://${S3_SEGMENT_CASE_FOLDED}[^/\\s]*\\.${URL_SUFFIX_ALTERNATION_CASE_FOLDED}/${bucketExact}/(${KEY_TERMINATORS}+)`, "g");
69980
70333
  const ecrRe = new RegExp(`${ECR_REGISTRY_HOST}/${repo}(?::([A-Za-z0-9_][A-Za-z0-9._-]{0,127}))?(?:@(sha256:[0-9a-f]{64}))?`, "gi");
69981
70334
  const extractOnce = (value, refs) => {
69982
70335
  for (const re of [
@@ -70245,9 +70598,10 @@ async function gcCommand(options) {
70245
70598
  logger.debug("Options:", options);
70246
70599
  await applyRoleArnIfSet({
70247
70600
  roleArn: options.roleArn,
70248
- region: options.region
70601
+ region: canonicalizeRegion(options.region)
70249
70602
  });
70250
- const region = options.region || process.env["AWS_REGION"] || "us-east-1";
70603
+ const rawRegion = options.region || process.env["AWS_REGION"] || "us-east-1";
70604
+ const region = canonicalizeRegion(rawRegion);
70251
70605
  const awsClients = new AwsClients({
70252
70606
  region,
70253
70607
  ...options.profile && { profile: options.profile }
@@ -70272,9 +70626,18 @@ async function gcCommand(options) {
70272
70626
  });
70273
70627
  try {
70274
70628
  const markerKey = getBootstrapMarkerKey(region);
70629
+ const rawMarkerKey = getBootstrapMarkerKey(rawRegion);
70275
70630
  let markerBody;
70631
+ let resolvedMarkerKey = markerKey;
70276
70632
  try {
70277
70633
  markerBody = await stateBackend.getRawObject(markerKey);
70634
+ if (markerBody === null && rawMarkerKey !== markerKey) {
70635
+ markerBody = await stateBackend.getRawObject(rawMarkerKey);
70636
+ if (markerBody !== null) {
70637
+ resolvedMarkerKey = rawMarkerKey;
70638
+ logger.debug(`Bootstrap marker found at the un-folded key '${rawMarkerKey}' (none at '${markerKey}') — an upper-cased region was used at 'cdkd bootstrap' time.`);
70639
+ }
70640
+ }
70278
70641
  } catch (error) {
70279
70642
  if (error.name === "NoSuchBucket") {
70280
70643
  logger.info(`State bucket '${bucketName}' does not exist — this account was never bootstrapped; nothing to garbage-collect.`);
@@ -70283,10 +70646,11 @@ async function gcCommand(options) {
70283
70646
  throw error;
70284
70647
  }
70285
70648
  if (markerBody === null) {
70286
- 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.)`);
70649
+ const probed = rawMarkerKey === markerKey ? markerKey : `${markerKey}, ${rawMarkerKey}`;
70650
+ 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.)`);
70287
70651
  return;
70288
70652
  }
70289
- const marker = parseBootstrapMarker(markerBody, markerKey);
70653
+ const marker = parseBootstrapMarker(markerBody, resolvedMarkerKey);
70290
70654
  const lockKeys = await listAllLockKeys(stateBackend);
70291
70655
  if (lockKeys.length > 0) {
70292
70656
  const listing = lockKeys.map((k) => ` - ${describeStateKey(k, LOCK_FILE_SUFFIX)} [${k}]`).join("\n");
@@ -82117,7 +82481,7 @@ function createMigrateCommand() {
82117
82481
  */
82118
82482
  function buildProgram() {
82119
82483
  const program = new Command();
82120
- program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.284.6");
82484
+ program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.284.8");
82121
82485
  program.hook("preAction", (_thisCommand, actionCommand) => {
82122
82486
  const { profile } = actionCommand.optsWithGlobals();
82123
82487
  if (profile !== void 0) process.env["AWS_PROFILE"] = profile;