@go-to-k/cdkd 0.286.3 → 0.287.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,8 @@
1
- import { $ as beginCommandInterruptScope, $n as synthesisStatusMessage, $r as withErrorHandling, $t as maskSecretsInError, A as formatResourceLine, An as ensureAssetStorage, At as requireConfigString, B as isExportAliasCollision, Bn as describeDockerCapturedOutput, Bt as INTRINSIC_KEYS, C as unsupportedFinalSnapshotError, Cn as loadPublishableAssetManifest, Cr as processStackMessages, Ct as coerceCfnBoolean, D as isStatefulRecreateTargetForReplace, Dn as AssetModeResolver, Dr as getAwsClients, Dt as replayWarn, E as MULTI_REGION_RECREATE_BLOCKED_TYPES, En as stripControlChars, Er as AwsClients, Et as readConfigString, F as red, Fn as validateAssetBucketName, Fr as DeployCancelledError, Ft as s3BucketDualStackDomainName, G as collectInlinePolicyNamesManagedBySiblings, Gn as getDockerCmd, Gr as ResourceUpdateNotSupportedError, Gt as STATE_SOURCED_CROSS_GENERATION_RULES, H as stateKeySecretExposure, Hn as describeDockerFailure, Hr as PartialFailureError, Ht as withRetry, I as yellow, In as validateContainerRepoName, Ir as DynamicReferenceRegionAmbiguousError, It as s3BucketRegionalDomainName, J as findActionableSilentDrops, Jn as runDockerForeground, Jt as carriesSecretMask, K as clearOnUpdateRemoval, Kn as partitionSensitiveEnv, Kr as StackHasActiveImportsError, Kt as STATE_SOURCED_READBACK_RULES, L as collectDeclaredOutputNames, Ln as buildDenyExternalAccessPolicy, Lr as IntrinsicResolutionRefusalError, Lt as s3BucketWebsiteUrl, M as cyan, Mn as isCrossRegionRedirect, Mt as producerRegionsFromState, N as gray, Nn as parseBootstrapMarker, Nr as CrossAccountSecretRefusalError, Nt as s3BucketArn, O as isStatefulRecreateTargetSync, On as BOOTSTRAP_MARKER_PREFIX, Or as resetAwsClients, Ot as requireConfigArray, P as green, Pn as readBootstrapMarkerBody, Pt as s3BucketDomainName, Q as maskerOrIdentity, Qn as Synthesizer, Qr as normalizeAwsError, Qt as isSingleDynamicReferenceToken, R as collectPublishedOutputNames, Rn as describeAwsFailure, Rr as LocalInvokeBuildError$1, Rt as applyRoleArnIfSet, S as refusesFinalSnapshot, Sn as createAssetRedirectResolver, Sr as AssemblyReader, St as assertRegionMatch, T as extractDeploymentEventError, Tn as escapeRegExp, Tr as resolveBucketRegion, Tt as configStringRefusal, U as getCurrentResourceSecrets, Un as dockerSpawnEnvWithSensitive, Ur as ProvisioningError, Ut as DagBuilder, V as secretBearingStateKeyWarning, Vn as describeDockerExecFailure, Vr as NestedStackChildDirectDestroyError, Vt as describeTypeWithThrottleRetry, W as IAMRoleProvider, Wn as formatDockerLoginError, Wr as ResourceTimeoutError, Wt as TemplateParser, X as createMaskedRetryLogger, Xn as AssetManifestLoader, Xt as dynamicReferenceTokens, Y as findSilentDropProperties, Yn as runDockerStreaming, Yt as createSecretMasker, Z as maskDeep, Zn as getDockerImageBySourceHash, Zt as errorCauseChain, _ as PRE_DELETE_SNAPSHOT_TYPES, _n as shouldRetainResource, _r as displaySafe, _t as parameterTypeMayLoseSecretIdentity, a as DeploymentEventsStore, ai as markRedactedCause, an as LockManager, ar as resolveSkipPrefix, at as slowCcOperationTimeoutMs, b as createPreDeleteFinalSnapshot, bn as WorkGraph, br as canonicalizeRegion, bt as normalizeAwsTagsToCfn, c as replayFailedOperations, cn as UNRENDERABLE, cr as resolveUseCdkBootstrapAssets, ct as deleteSkipReason, d as updatePartialReason, dn as forceQuitRecoveryClause, dr as CFN_TEMPLATE_BODY_LIMIT, dt as IntrinsicFunctionResolver, ei as isMarkedNonRetryable, en as maskSecretsInText, er as getDefaultStateBucketName, et as endCommandInterruptScope, f as withResourceDeadline, fn as CUSTOM_RESOURCE_RESPONSE_PREFIX, fr as CFN_TEMPLATE_URL_LIMIT, ft as carriesDynamicReference, g as ATOMIC_FINAL_SNAPSHOT_TYPES, gn as importableOutputs, gr as CUSTOM_RESOURCE_RESPONSE_OBJECT_DESCRIPTION, gt as isUnboundTemplateParameter, h as computeImplicitDeleteEdges, hn as importableOutputKeys, hr as uploadCfnTemplate, ht as getAccountInfo, i as DeploymentEventsReader, ii as markNonRetryable, in as scrubResourceRecord, ir as resolveCaptureObservedState, it as CloudControlProvider, j as bold, jn as getBootstrapMarkerKey, jr as CdkdError, jt as classifyReplaySecretRegion, k as renderStatefulReason, kn as assertAssetBucketRegion, kr as setAwsClients, kt as requireConfigObject, l as replayRollback, ln as buildForceUnlockCommand, lr as stateBucketExistenceConfirmed, lt as disableInstanceApiTermination, m as IMPLICIT_DELETE_DEPENDENCIES, mn as exportNamesCarriedFrom, mr as findLargeInlineResources, mt as coerceParameterTypedValue, n as DEFAULT_RESOURCE_WARN_AFTER_MS, ni as isThrottlingError, nn as recoverMaskedOutput, nr as resolveApp, nt as isInterruptedWaitError, o as planFailedOps, oi as retryClassificationText, on as S3StateBackend, or as resolveStateBucketWithDefault, ot as UNSPECIFIED_SKIP_REASON, p as maskingRetryLogger, pn as DEFAULT_STATE_PREFIX, pr as MIGRATE_TMP_PREFIX, pt as cfnRefValueFromPhysicalId, q as ProviderRegistry, qn as redactDockerArgvValues, qr as StackTerminationProtectionError, qt as TEMPLATE_SOURCED_RULES, r as DeployEngine, ri as isTransientServerError, rn as redactSecretsForState, rr as resolveAutoAssetStorage, rt as startInterruptWatch, s as planRollback, sn as rebuildClientForBucketRegion, sr as resolveStateBucketWithDefaultAndSource, st as deleteIndeterminateGuards, t as DEFAULT_RESOURCE_TIMEOUT_MS, ti as isRetryableTransientError, tn as recordMaskOnlyValue, tr as getLegacyStateBucketName, tt as interruptWatchListenerCount, u as updatePartialMessage, un as buildLockContentionMessage, ur as warnDeprecatedNoPrefixCliFlag, ut as isTerminationProtectionPropagationError, v as buildFinalSnapshotIdentifier, vn as AssetPublisher, vr as expectedOwnerParam, vt as refStateLookupFromResource, w as makeCanonicalizePropertiesFn, wn as rewriteTemplateAssetReferences, wt as configBooleanRefusal, x as isFinalSnapshotError, xn as buildAssetRedirectMap, xr as derivePartitionAndUrlSuffix, xt as resolveExplicitPhysicalId, y as ccRoutedFinalSnapshotError, yn as stringifyValue, yr as PARTITION_TABLE, yt as WAFv2WebACLProvider, z as exportAliasCollisionScrubWarning, zn as buildDockerImage, zr as LocalStartServiceError, zt as DiffCalculator } from "./deploy-engine--rkIGhow.js";
1
+ import { $ as findActionableSilentDrops, $n as runDockerStreaming, $r as StackTerminationProtectionError, $t as carriesSecretMask, A as WARM_THROUGHPUT_MEMBERS, An as rewriteTemplateAssetReferences, At as configStringRefusal, B as yellow, Bn as validateContainerRepoName, Bt as s3BucketDualStackDomainName, C as unsupportedFinalSnapshotError, Cn as shouldRetainResource, Cr as displaySafe, Ct as refStateLookupFromResource, D as isStatefulRecreateTargetForReplace, Dn as buildAssetRedirectMap, Dr as derivePartitionAndUrlSuffix, Dt as assertRegionMatch, E as MULTI_REGION_RECREATE_BLOCKED_TYPES, En as WorkGraph, Er as canonicalizeRegion, Et as resolveExplicitPhysicalId, F as bold, Fn as getBootstrapMarkerKey, Fr as setAwsClients, Ft as requireConfigString, G as secretBearingStateKeyWarning, Gn as describeDockerExecFailure, Gr as LocalStartServiceError, Gt as INTRINSIC_KEYS, H as collectPublishedOutputNames, Hn as describeAwsFailure, Hr as DynamicReferenceRegionAmbiguousError, Ht as s3BucketWebsiteUrl, I as cyan, In as isCrossRegionRedirect, It as classifyReplaySecretRegion, J as IAMRoleProvider, Jn as formatDockerLoginError, Jr as PartialFailureError, Jt as DagBuilder, K as stateKeySecretExposure, Kn as describeDockerFailure, Kt as describeTypeWithThrottleRetry, L as gray, Ln as parseBootstrapMarker, Lr as CdkdError, Lt as producerRegionsFromState, M as isWarmThroughputDecrease, Mn as BOOTSTRAP_MARKER_PREFIX, Mr as AwsClients, Mt as replayWarn, N as toFiniteNumber, Nn as assertAssetBucketRegion, Nr as getAwsClients, Nt as requireConfigArray, O as isStatefulRecreateTargetSync, On as createAssetRedirectResolver, Or as AssemblyReader, Ot as coerceCfnBoolean, P as formatResourceLine, Pn as ensureAssetStorage, Pr as resetAwsClients, Pt as requireConfigObject, Q as wouldReturnToSdkProvider, Qn as runDockerForeground, Qr as StackHasActiveImportsError, Qt as TEMPLATE_SOURCED_RULES, R as green, Rn as readBootstrapMarkerBody, Rt as s3BucketArn, S as refusesFinalSnapshot, Sn as importableOutputs, Sr as CUSTOM_RESOURCE_RESPONSE_OBJECT_DESCRIPTION, St as parameterTypeMayLoseSecretIdentity, T as extractDeploymentEventError, Tn as stringifyValue, Tr as PARTITION_TABLE, Tt as normalizeAwsTagsToCfn, U as exportAliasCollisionScrubWarning, Un as buildDockerImage, Ur as IntrinsicResolutionRefusalError, Ut as applyRoleArnIfSet, V as collectDeclaredOutputNames, Vn as buildDenyExternalAccessPolicy, Vr as DeployCancelledError, Vt as s3BucketRegionalDomainName, W as isExportAliasCollision, Wn as describeDockerCapturedOutput, Wr as LocalInvokeBuildError$1, Wt as DiffCalculator, X as clearOnUpdateRemoval, Xn as partitionSensitiveEnv, Xr as ResourceTimeoutError, Xt as STATE_SOURCED_CROSS_GENERATION_RULES, Y as collectInlinePolicyNamesManagedBySiblings, Yn as getDockerCmd, Yr as ProvisioningError, Yt as TemplateParser, Z as ProviderRegistry, Zn as redactDockerArgvValues, Zr as ResourceUpdateNotSupportedError, Zt as STATE_SOURCED_READBACK_RULES, _ as PRE_DELETE_SNAPSHOT_TYPES, _n as shellQuote, _r as CFN_TEMPLATE_BODY_LIMIT, _t as carriesDynamicReference, a as DeploymentEventsStore, ai as withErrorHandling, an as maskSecretsInText, ar as synthesisStatusMessage, at as endCommandInterruptScope, b as createPreDeleteFinalSnapshot, bn as exportNamesCarriedFrom, br as findLargeInlineResources, bt as getAccountInfo, c as replayFailedOperations, ci as isThrottlingError, cn as redactSecretsForState, cr as resolveApp, ct as startInterruptWatch, d as updatePartialReason, di as markRedactedCause, dn as S3StateBackend, dr as resolveSkipPrefix, dt as UNSPECIFIED_SKIP_REASON, en as createSecretMasker, er as escapeRegExp, et as findSilentDropProperties, f as withResourceDeadline, fi as retryClassificationText, fn as rebuildClientForBucketRegion, fr as resolveStateBucketWithDefault, ft as deleteIndeterminateGuards, g as ATOMIC_FINAL_SNAPSHOT_TYPES, gn as forceQuitRecoveryClause, gr as warnDeprecatedNoPrefixCliFlag, gt as IntrinsicFunctionResolver, h as computeImplicitDeleteEdges, hn as buildLockContentionMessage, hr as stateBucketExistenceConfirmed, ht as isTerminationProtectionPropagationError, i as DeploymentEventsReader, ii as normalizeAwsError, in as maskSecretsInError, ir as Synthesizer, it as beginCommandInterruptScope, j as coerceWarmThroughput$1, jn as AssetModeResolver, jr as resolveBucketRegion, jt as readConfigString, k as renderStatefulReason, kn as loadPublishableAssetManifest, kr as processStackMessages, kt as configBooleanRefusal, l as replayRollback, li as isTransientServerError, ln as scrubResourceRecord, lr as resolveAutoAssetStorage, lt as CloudControlProvider, m as IMPLICIT_DELETE_DEPENDENCIES, mn as buildForceUnlockCommand, mr as resolveUseCdkBootstrapAssets, mt as disableInstanceApiTermination, n as DEFAULT_RESOURCE_WARN_AFTER_MS, nn as errorCauseChain, nr as AssetManifestLoader, nt as maskDeep, o as planFailedOps, oi as isMarkedNonRetryable, on as recordMaskOnlyValue, or as getDefaultStateBucketName, ot as interruptWatchListenerCount, p as maskingRetryLogger, pn as UNRENDERABLE, pr as resolveStateBucketWithDefaultAndSource, pt as deleteSkipReason, q as getCurrentResourceSecrets, qn as dockerSpawnEnvWithSensitive, qr as NestedStackChildDirectDestroyError, qt as withRetry, r as DeployEngine, rn as isSingleDynamicReferenceToken, rr as getDockerImageBySourceHash, rt as maskerOrIdentity, s as planRollback, si as isRetryableTransientError, sn as recoverMaskedOutput, sr as getLegacyStateBucketName, st as isInterruptedWaitError, t as DEFAULT_RESOURCE_TIMEOUT_MS, tn as dynamicReferenceTokens, tr as stripControlChars, tt as createMaskedRetryLogger, u as updatePartialMessage, ui as markNonRetryable, un as LockManager, ur as resolveCaptureObservedState, ut as slowCcOperationTimeoutMs, v as buildFinalSnapshotIdentifier, vn as CUSTOM_RESOURCE_RESPONSE_PREFIX, vr as CFN_TEMPLATE_URL_LIMIT, vt as cfnRefValueFromPhysicalId, w as makeCanonicalizePropertiesFn, wn as AssetPublisher, wr as expectedOwnerParam, wt as WAFv2WebACLProvider, x as isFinalSnapshotError, xn as importableOutputKeys, xr as uploadCfnTemplate, xt as isUnboundTemplateParameter, y as ccRoutedFinalSnapshotError, yn as DEFAULT_STATE_PREFIX, yr as MIGRATE_TMP_PREFIX, yt as coerceParameterTypedValue, z as red, zn as validateAssetBucketName, zr as CrossAccountSecretRefusalError, zt as s3BucketDomainName } from "./deploy-engine-B377YKBI.js";
2
2
  import { _ as withStackName, c as PATTERN_B_NAME_OPTIONS, f as generateResourceName, g as withSkipPrefix, i as reserveStdoutForPayload, l as PATTERN_B_NAME_PROPERTIES, m as getCurrentStackName, n as getLogger, o as runStackBuffered, p as generateResourceNameWithFallback, s as getLiveRenderer, u as PATTERN_B_RESOURCE_TYPES } from "./logger-Dw-3y48G.js";
3
3
  import { t as awsClientDefaults } from "./aws-client-defaults-D-iYiIJ6.js";
4
- import { t as getCdkdVersion } from "./version-D3md0Lde.js";
5
- import { t as ASGProvider } from "./asg-provider-uaOnRUMe.js";
4
+ import { t as getCdkdVersion } from "./version-Rs7TV7Gz.js";
5
+ import { r as protectedReplacementAdvice, t as ASGProvider } from "./asg-provider-D10GWdiA.js";
6
6
  import { AsyncLocalStorage } from "node:async_hooks";
7
7
  import { createHash, randomBytes, randomUUID } from "node:crypto";
8
8
  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, NotFound, 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";
@@ -745,6 +745,32 @@ function parseRecreateViaSdkProviderToken(value, previous) {
745
745
  if (!LOGICAL_ID_FORMAT.test(token)) throw new Error(`Invalid --recreate-via-sdk-provider value "${value}": expected a CloudFormation logical id (alphanumeric, starts with a letter, max 255 chars). One --recreate-via-sdk-provider flag per resource — repeat the flag for additional targets.`);
746
746
  return [...previous ?? [], token];
747
747
  }
748
+ /**
749
+ * Issue [#2719] — `--pin-cc-api <logicalId>` keeps a named resource on the
750
+ * Cloud Control route for this deploy, suppressing the `'sdk-coverage'`
751
+ * sticky-exemption that would otherwise return it to its SDK provider.
752
+ *
753
+ * The lightweight counterpart to `--recreate-via-cc-api`: that one forces the
754
+ * CC layer by DESTROYING and recreating the resource, which is not something a
755
+ * user can do to a stateful resource just to decline a routing change. This
756
+ * one changes only which provider runs the update.
757
+ *
758
+ * Deliberately per-deploy rather than a state field. A persisted pin would be
759
+ * a v10 schema bump plus its migration integ, and nothing has yet shown a user
760
+ * needing the pin to survive a deploy they did not pass it on -- the flip is
761
+ * conditioned on the resource's own coverage, so the case it guards against is
762
+ * "I disagree with this specific deploy's routing", not a standing preference.
763
+ *
764
+ * It does NOT override a `'cc-broken'` exemption: honoring it there would pin
765
+ * the resource to a handler that cannot manage it, which is the bug those
766
+ * entries exist to escape.
767
+ */
768
+ function parsePinCcApiToken(value, previous) {
769
+ const token = value.trim();
770
+ if (!LOGICAL_ID_FORMAT.test(token)) throw new Error(`Invalid --pin-cc-api value "${value}": expected a CloudFormation logical id (alphanumeric, starts with a letter, max 255 chars). One --pin-cc-api flag per resource — repeat the flag for additional targets.`);
771
+ return [...previous ?? [], token];
772
+ }
773
+ const pinCcApiOption = new Option("--pin-cc-api <logicalId>", "Keep the named resource (by CloudFormation logical id) on the Cloud Control route in this deploy, declining the automatic return to the SDK Provider that a type-level sdk-coverage exemption would otherwise apply to a resource recorded as provisionedBy: cc-api. Repeatable — pass the flag once per resource. Has no effect on a resource already on the SDK path, and does not override an exemption admitted because Cloud Control cannot manage the type.").argParser(parsePinCcApiToken);
748
774
  const recreateViaSdkProviderOption = new Option("--recreate-via-sdk-provider <logicalId>", "Destroy + recreate the named resource (by CloudFormation logical id) via cdkd's SDK Provider in this deploy, so a resource currently sticky on provisionedBy: cc-api flips back to provisionedBy: sdk. Used after a #609 backfill release adds SDK coverage for a type the user originally needed CC for (e.g. Lambda LoggingConfig). Repeatable — pass the flag once per resource. Per-resource opt-in. Stateful resource types refuse unless --force-stateful-recreation is ALSO passed.").argParser(parseRecreateViaSdkProviderToken);
749
775
  /**
750
776
  * Issue [#615] — `--force-stateful-recreation` (boolean) confirms a
@@ -874,6 +900,7 @@ const deployOptions = [
874
900
  allowUnsupportedPropertiesOption,
875
901
  recreateViaCcApiOption,
876
902
  recreateViaSdkProviderOption,
903
+ pinCcApiOption,
877
904
  forceStatefulRecreationOption,
878
905
  replaceOption,
879
906
  useCdkBootstrapAssetsOption,
@@ -2579,6 +2606,31 @@ function isPromptAbortError(error) {
2579
2606
  * intent** — does the user want SDK + silent drop, or CC migration?
2580
2607
  * Fail fast and let the user pick one strategy per resource.
2581
2608
  */
2609
+ const EMPTY_ALLOW_SET$1 = /* @__PURE__ */ new Set();
2610
+ /**
2611
+ * The CFn type of a nested stack's row in its PARENT's template. It decides
2612
+ * both the refusal (`blockedNestedStackTargets`) and the evidence the
2613
+ * unknown-id note renders (`nestedStackLogicalIds`) — so within this module it
2614
+ * is spelled once, or the note could name ids the validator does not refuse.
2615
+ *
2616
+ * The two are NOT the same set, and deliberately so. The refusal fires when
2617
+ * EITHER the state record or the template says nested, because the two types
2618
+ * select different halves of the destructive path: the TEMPLATE's type picks
2619
+ * the provider that deletes the old resource (a Type change emits an UPDATE
2620
+ * carrying `resourceType: desiredResource.Type`, which is what
2621
+ * `oldDeleteProvider` resolves from), while the STATE's type is what
2622
+ * `NestedStackProvider` owns and derives `<parent>~<logicalId>` from. The note
2623
+ * keys on the TEMPLATE alone, because it is telling the user what their current
2624
+ * template contains. Which type governs was argued from plausibility twice and
2625
+ * settled by reading `analyzer/diff-calculator.ts` and `deploy-engine.ts`'s
2626
+ * `oldDeleteProvider`; do not re-derive it from either name. Other modules spell it
2627
+ * for themselves rather than sharing one export: the only EXPORTED copy lives
2628
+ * in `src/cli/commands/retire-cfn-stack.ts`, and importing a CLI command module
2629
+ * from the deployment layer would invert the dependency direction. (No count of
2630
+ * the other spellings is given here on purpose — an unfenced number in a
2631
+ * comment is a number that goes stale.)
2632
+ */
2633
+ const NESTED_STACK_RESOURCE_TYPE$1 = "AWS::CloudFormation::Stack";
2582
2634
  /**
2583
2635
  * Plan-time validation of the user's recreate-via-cc-api list.
2584
2636
  *
@@ -2592,7 +2644,6 @@ function isPromptAbortError(error) {
2592
2644
  * Input order is preserved; duplicate logical ids in the user's input
2593
2645
  * are deduplicated.
2594
2646
  */
2595
- const EMPTY_ALLOW_SET$1 = /* @__PURE__ */ new Set();
2596
2647
  function validateRecreateTargets(input) {
2597
2648
  const seenCcApi = new Set(input.recreateViaCcApi);
2598
2649
  const seenSdk = new Set(input.recreateViaSdkProvider ?? []);
@@ -2605,6 +2656,7 @@ function validateRecreateTargets(input) {
2605
2656
  const ambiguousIntentSdk = [];
2606
2657
  const blockedStatefulTargets = [];
2607
2658
  const blockedMultiRegionTargets = [];
2659
+ const blockedNestedStackTargets = [];
2608
2660
  const blockedAlreadySdk = [];
2609
2661
  const blockedAlreadyCcApi = [];
2610
2662
  const blockedNoSdkProvider = [];
@@ -2635,11 +2687,12 @@ function validateRecreateTargets(input) {
2635
2687
  logicalId,
2636
2688
  resourceType,
2637
2689
  physicalId: recordedResource.physicalId,
2638
- statefulReason: isStatefulRecreateTargetSync(resourceType, recordedResource.properties),
2690
+ statefulReason: isStatefulRecreateTargetSync(resourceType, recordedResource.properties, recordedResource.observedProperties),
2639
2691
  direction
2640
2692
  };
2641
2693
  targets.push(target);
2642
2694
  if (MULTI_REGION_RECREATE_BLOCKED_TYPES.has(resourceType)) blockedMultiRegionTargets.push(target);
2695
+ if (resourceType === NESTED_STACK_RESOURCE_TYPE$1 || templateResource.Type === NESTED_STACK_RESOURCE_TYPE$1) blockedNestedStackTargets.push(target);
2643
2696
  if (direction === "to-cc-api") {
2644
2697
  const actionableDrops = findActionableSilentDrops(resourceType, templateResource.Properties, EMPTY_ALLOW_SET$1);
2645
2698
  for (const { property } of actionableDrops) {
@@ -2671,10 +2724,12 @@ function validateRecreateTargets(input) {
2671
2724
  ambiguousIntentSdk,
2672
2725
  blockedStatefulTargets,
2673
2726
  blockedMultiRegionTargets,
2727
+ blockedNestedStackTargets,
2674
2728
  blockedAlreadySdk,
2675
2729
  blockedAlreadyCcApi,
2676
2730
  blockedNoSdkProvider,
2677
- conflictingDirections
2731
+ conflictingDirections,
2732
+ nestedStackLogicalIds: Object.entries(input.template.Resources ?? {}).filter(([, resource]) => resource?.Type === NESTED_STACK_RESOURCE_TYPE$1).map(([id]) => id)
2678
2733
  };
2679
2734
  }
2680
2735
  /**
@@ -2691,6 +2746,8 @@ function renderRecreateTargetsErrors(validation) {
2691
2746
  lines.push(`${FLAG_UMBRELLA} named ${validation.unknownLogicalIds.length} logical id(s) not present in the synth template:`);
2692
2747
  for (const id of validation.unknownLogicalIds) lines.push(` - ${id}`);
2693
2748
  lines.push(" Fix: confirm each id exists in the template (CDK display path is the parent; the logical id is the CFn-emitted name, e.g. cdkd synth | jq '.Resources | keys'). Recreate operates on the synth template's logical ids, not CDK display paths.");
2749
+ lines.push(" Note: each stack of this deploy validates this WHOLE flag list against its OWN template, so an id declared by a different stack of the same run is reported here as unknown. Name it in a deploy of only that stack.");
2750
+ if (validation.nestedStackLogicalIds.length > 0) lines.push(` Note: resources inside a nested stack are NOT addressable — the flags name logical ids of the stack being deployed, and this template's nested stack(s) (${validation.nestedStackLogicalIds.join(", ")}) carry their own. A logical id that a nested child happens to share with a top-level resource recreates the TOP-LEVEL one only, and the nested stack row itself is refused as a target.`);
2694
2751
  }
2695
2752
  if (validation.missingFromState.length > 0) {
2696
2753
  if (lines.length > 0) lines.push("");
@@ -2709,6 +2766,16 @@ function renderRecreateTargetsErrors(validation) {
2709
2766
  lines.push(`${FLAG_UMBRELLA} would destroy + recreate ${validation.blockedStatefulTargets.length} stateful resource(s). Recreate loses ALL data — no automatic data migration. Re-run with --force-stateful-recreation to acknowledge the data-loss footgun.`);
2710
2767
  for (const blocked of validation.blockedStatefulTargets) lines.push(` - ${blocked.logicalId} (${blocked.resourceType}) — ${renderStatefulReason(blocked.statefulReason)}`);
2711
2768
  }
2769
+ if (validation.blockedNestedStackTargets.length > 0) {
2770
+ if (lines.length > 0) lines.push("");
2771
+ lines.push(`${FLAG_UMBRELLA} refuses to operate on ${validation.blockedNestedStackTargets.length} nested-stack resource(s):`);
2772
+ for (const blocked of validation.blockedNestedStackTargets) {
2773
+ const templateType = validation.nestedStackLogicalIds.includes(blocked.logicalId) ? NESTED_STACK_RESOURCE_TYPE$1 : void 0;
2774
+ const shown = templateType && templateType !== blocked.resourceType ? `${blocked.resourceType} in state, ${templateType} in the template` : blocked.resourceType;
2775
+ lines.push(` - ${blocked.logicalId} (${shown})`);
2776
+ }
2777
+ lines.push(" Recreating one would DELETE the whole child stack — every resource it owns, with no per-resource confirmation — and re-create it through a layer that does not implement cdkd's nested-stack handling. Where cdkd holds no state for that child, the delete is a no-op instead and the old resource is silently left behind. There is no --force-stateful-recreation bypass. A resource inside a child is not addressable by these flags at all.");
2778
+ }
2712
2779
  if (validation.blockedMultiRegionTargets.length > 0) {
2713
2780
  if (lines.length > 0) lines.push("");
2714
2781
  lines.push(`--recreate-via-cc-api / --recreate-via-sdk-provider refuses to operate on ${validation.blockedMultiRegionTargets.length} multi-region resource(s) — out of scope for v1 of these flags (the destroy + recreate cycle across replica regions is more involved than the single-region path):`);
@@ -3072,7 +3139,7 @@ async function promptRecreateConfirm(input) {
3072
3139
  else if (toCcCount > 0) logger.warn(`--recreate-via-cc-api will destroy + recreate ${toCcCount} resource(s) via Cloud Control API on stack ${input.stackName}:`);
3073
3140
  else logger.warn(`--recreate-via-sdk-provider will destroy + recreate ${toSdkCount} resource(s) via SDK Provider on stack ${input.stackName}:`);
3074
3141
  for (const t of input.targets) {
3075
- const rederivedReason = t.statefulReason === null && input.forceStatefulRecreation ? isStatefulRecreateTargetForReplace(t.resourceType, void 0) : null;
3142
+ const rederivedReason = t.statefulReason === null && input.forceStatefulRecreation ? isStatefulRecreateTargetForReplace(t.resourceType, void 0, void 0) : null;
3076
3143
  const rederived = rederivedReason !== null;
3077
3144
  const reason = t.statefulReason ?? rederivedReason;
3078
3145
  const stateful = reason !== null;
@@ -3105,6 +3172,32 @@ async function promptRecreateConfirm(input) {
3105
3172
  }
3106
3173
  }
3107
3174
 
3175
+ //#endregion
3176
+ //#region src/cli/commands/pin-cc-api-reachability.ts
3177
+ function analyzePinCcApiReachability(pinnedLogicalIds, stacks) {
3178
+ const idSets = stacks.map((s) => ({
3179
+ stackName: s.stackName,
3180
+ ids: new Set(s.logicalIds)
3181
+ }));
3182
+ const unmatched = [];
3183
+ const partial = [];
3184
+ for (const logicalId of [...new Set(pinnedLogicalIds)]) {
3185
+ const appliesTo = idSets.filter((t) => t.ids.has(logicalId)).map((t) => t.stackName);
3186
+ const absentFrom = idSets.filter((t) => !t.ids.has(logicalId)).map((t) => t.stackName);
3187
+ if (appliesTo.length === 0) unmatched.push(logicalId);
3188
+ else if (absentFrom.length > 0) partial.push({
3189
+ logicalId,
3190
+ appliesTo,
3191
+ absentFrom
3192
+ });
3193
+ }
3194
+ return {
3195
+ unmatched,
3196
+ partial,
3197
+ errorMessage: unmatched.length === 0 ? "" : `--pin-cc-api named ${unmatched.length} logical id(s) present in no stack of this run:\n` + unmatched.map((id) => ` - ${id}`).join("\n") + `\n Stacks in this run: ${idSets.map((t) => t.stackName).join(", ")}\n Fix: pass the CFn-emitted logical id, not the CDK display path (\`cdkd synth\`, then read \`.Resources | keys\`). A resource inside a NESTED child stack cannot be pinned from its parent's deploy — name it in a deploy of that child.`
3198
+ };
3199
+ }
3200
+
3108
3201
  //#endregion
3109
3202
  //#region src/cli/commands/confirm-prompt.ts
3110
3203
  /**
@@ -15996,209 +16089,6 @@ function isRecord$1(value) {
15996
16089
  return typeof value === "object" && value !== null && !Array.isArray(value);
15997
16090
  }
15998
16091
 
15999
- //#endregion
16000
- //#region src/provisioning/dynamodb-warm-throughput.ts
16001
- /**
16002
- * The two DynamoDB `WarmThroughput` rules that `AWS::DynamoDB::Table` and
16003
- * `AWS::DynamoDB::GlobalTable` both need, in ONE spelling.
16004
- *
16005
- * `WarmThroughput` is the same CloudFormation block on both types, and both
16006
- * providers have to answer the same two questions about it before an
16007
- * `UpdateTable` / `CreateTable` goes out:
16008
- *
16009
- * 1. **Is this block SENDABLE, and as what numbers?** CloudFormation is
16010
- * stringly typed, so `{ReadUnitsPerSecond: '12000'}` — or anything that
16011
- * came back from an `Fn::Sub` — arrives as a STRING and must not be
16012
- * forwarded verbatim into a numeric `Long` field.
16013
- * 2. **Would sending it LOWER what AWS already reports?** Warm throughput
16014
- * only ever rises with a table's traffic and AWS REJECTS a call that
16015
- * lowers it (`decreasing WarmThroughput is not supported`, measured live
16016
- * us-east-1 2026-08-13 for issue #1768).
16017
- *
16018
- * **Provenance, stated precisely because it is easy to over-claim.** Exactly
16019
- * ONE of these rules ever shipped: `AWS::DynamoDB::Table` got them in PR #1808
16020
- * (issues #1760 / #1768), and the `AWS::DynamoDB::GlobalTable` side never
16021
- * existed outside the change that created this file (issue #1857). So this is
16022
- * the Table rule LIFTED — not two shipped rules reconciled, and no deployed
16023
- * behaviour changed for either type when it moved here.
16024
- *
16025
- * What WAS compared is the lifted rule against the GlobalTable spelling
16026
- * drafted alongside it, over the shapes both had to answer (a quoted numeric
16027
- * string, a partially usable block, a mixed decrease/increase, an absent live
16028
- * value, a zero, a negative, a non-numeric string, a whitespace string, an
16029
- * explicit `undefined` / `null`, a boolean, an empty block, a scalar, an
16030
- * array). {@link isWarmThroughputDecrease} agreed on every one; the coercion
16031
- * agreed on all but a whitespace-only string, where a bare `Number(' ')` is
16032
- * `0` rather than `NaN`. This module keeps the REFUSING answer for that shape,
16033
- * since `' '` is not a capacity anyone declared and a warm throughput of 0
16034
- * is not a request AWS can honour. That is a DRAFT reconciled against a
16035
- * shipped rule, which is worth less than two shipped rules agreeing — the
16036
- * reason to read the probe list as a design note rather than as field
16037
- * evidence.
16038
- *
16039
- * Living here rather than in either provider is the point. A decrease guard is
16040
- * three clauses that all FAIL OPEN for different reasons, and two files
16041
- * spelling it independently is two chances for a later "fix" to change one of
16042
- * them — at which point the sibling type silently keeps the old answer, and
16043
- * nothing in the tree says which is intended. Same class as
16044
- * `emr-configuration.ts`, and the same reason.
16045
- *
16046
- * Deliberately NOT here: everything only ONE provider has. The `Table` side's
16047
- * `isSendableWarmThroughput` / `isRefusedWarmThroughput` /
16048
- * `declaresWarmThroughput` / `warmThroughputAlreadyMatches` are its drift-side
16049
- * and already-matches gates, which `GlobalTable` has no counterpart to (issue
16050
- * #1742 strips the per-index `WarmThroughput` from BOTH of its drift
16051
- * comparison sides unconditionally, so drift never asks the question there);
16052
- * the `GlobalTable` side's `warmThroughputDiagnostic` builds a
16053
- * `ThroughputDiagnostic` that only that provider's collector understands.
16054
- * Moving a helper with one caller here would buy nothing and cost a hop.
16055
- *
16056
- * Issues: #1760 / #1768 (Table, PR #1808), #1857 (GlobalTable).
16057
- */
16058
- /**
16059
- * The two `WarmThroughput` members, in the ONE order every message, every
16060
- * comparison and every emitted block uses. A shared order is what makes two
16061
- * blocks carrying the same numbers compare equal by `deepEqual` and serialize
16062
- * to the same wire bytes regardless of the order the template wrote them in.
16063
- */
16064
- const WARM_THROUGHPUT_MEMBERS = ["ReadUnitsPerSecond", "WriteUnitsPerSecond"];
16065
- /**
16066
- * A CloudFormation-borne numeric property, or `undefined` when the value is
16067
- * not a usable number.
16068
- *
16069
- * Plain `Number()` coercion is NOT good enough and the tree learned it three
16070
- * separate times: `Number(null)`, `Number('')`, `Number([])`, `Number(false)`
16071
- * and `Number(' ')` are all **0**, not `NaN` — so a live `0` would compare
16072
- * EQUAL to a desired `null` / `''` / `[]` / `false`, and a whitespace-only
16073
- * string would be forwarded as a request for zero units.
16074
- *
16075
- * A YAML-borne numeric STRING is still accepted, because that is a real
16076
- * template shape and `'12000'` genuinely means 12000.
16077
- *
16078
- * EXPORTED, and not because warm throughput needs it exported. This exact rule
16079
- * was hand-written three times — here, as `dynamodb-table-provider.ts`'s
16080
- * `capacityNumber` (byte-identical), and as `dynamodb-globaltable-provider.ts`'s
16081
- * `toFiniteNumber` (a different spelling of the same total function) — which is
16082
- * precisely the divergence this module exists to stop: the next "fix" to one of
16083
- * them would have left the other two answering differently, with nothing in the
16084
- * tree saying which was intended. It reads `ReadCapacityUnits` /
16085
- * `MaxReadRequestUnits` / `MinCapacity` as readily as it reads
16086
- * `ReadUnitsPerSecond`; there is only ever one right answer for "is this
16087
- * stringly-typed CFn value a number I can send?".
16088
- */
16089
- function toFiniteNumber(value) {
16090
- if (typeof value === "number") return Number.isFinite(value) ? value : void 0;
16091
- if (typeof value === "string" && value.trim() !== "") {
16092
- const n = Number(value);
16093
- return Number.isFinite(n) ? n : void 0;
16094
- }
16095
- }
16096
- /**
16097
- * Coerce a CFn `WarmThroughput` block to the numeric shape the SDK's `Long`
16098
- * fields accept, PER MEMBER.
16099
- *
16100
- * PER MEMBER, not whole-block, and that distinction is the point: a block
16101
- * whose write half is an unresolved intrinsic still has a perfectly good read
16102
- * half, and dropping both would silently discard a value the template really
16103
- * did ask for. The dropped member is NAMED (`droppedMembers`) so the caller's
16104
- * warning can say which half went missing instead of reporting the whole
16105
- * property.
16106
- *
16107
- * A block with NO usable member yields `spec: undefined` — refused rather than
16108
- * forwarded, because forwarding a malformed block surfaces as an opaque AWS
16109
- * validation error naming neither cdkd nor the property. `droppedMembers` is
16110
- * still populated in that case, so a REFUSAL message can name what it refused;
16111
- * a block that is not an object at all (an unresolved `Fn::If`, a scalar, an
16112
- * array) has no member to name and reports none, which is what lets a caller
16113
- * word "one half went missing" differently from "the block is unusable".
16114
- *
16115
- * Pure: takes the raw bag, returns numbers, logs nothing. Each caller owns its
16116
- * own message, so the wording stays consistent across that provider's send
16117
- * sites without forcing one wording across both providers.
16118
- */
16119
- function coerceWarmThroughput$1(raw) {
16120
- if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return { droppedMembers: [] };
16121
- const bag = raw;
16122
- const spec = {};
16123
- const droppedMembers = [];
16124
- for (const member of WARM_THROUGHPUT_MEMBERS) {
16125
- if (bag[member] === void 0) continue;
16126
- const coerced = toFiniteNumber(bag[member]);
16127
- if (coerced === void 0) {
16128
- droppedMembers.push(member);
16129
- continue;
16130
- }
16131
- spec[member] = coerced;
16132
- }
16133
- if (Object.keys(spec).length === 0) return { droppedMembers };
16134
- return {
16135
- spec,
16136
- droppedMembers
16137
- };
16138
- }
16139
- /**
16140
- * Whether the COERCED desired `WarmThroughput` would LOWER what AWS already
16141
- * reports.
16142
- *
16143
- * Measured live (us-east-1, 2026-08-13, issue #1768) against a table AWS
16144
- * reports `{ReadUnitsPerSecond: 12000, WriteUnitsPerSecond: 4000}` for:
16145
- *
16146
- * ```
16147
- * UpdateTable WarmThroughput={ReadUnitsPerSecond: 6000, WriteUnitsPerSecond: 2000}
16148
- * ValidationException: One or more parameter values were invalid: Requested
16149
- * ReadUnitsPerSecond for WarmThroughput for table is lower than current
16150
- * WarmThroughput, decreasing WarmThroughput is not supported
16151
- * UpdateTable WarmThroughput={ReadUnitsPerSecond: 6000} -> same rejection
16152
- * UpdateTable WarmThroughput={WriteUnitsPerSecond: 2000} -> same rejection, naming WriteUnitsPerSecond
16153
- * UpdateTable WarmThroughput={12000, 4000} (re-assert) -> ACCEPTED
16154
- * ```
16155
- *
16156
- * So the value is one AWS raises with the table's traffic and never lowers,
16157
- * and a decrease is REJECTED rather than accepted-and-ignored — which is what
16158
- * had to be measured before choosing, because the two produce different
16159
- * correct answers. The caller SKIPS the call on a true here.
16160
- *
16161
- * Evaluated on the COERCED spec, never on the raw bag: analysing the raw bag
16162
- * makes the verdict describe a request that is not the one being sent (a
16163
- * dropped member is not part of the call and must not be part of the
16164
- * comparison).
16165
- *
16166
- * Semantics, all three chosen to FAIL OPEN — i.e. to let the call through and
16167
- * leave AWS as the authority — because a false positive here silently drops a
16168
- * legitimate INCREASE, which is a real capacity change the user asked for,
16169
- * while a false negative merely reproduces the pre-fix behaviour of an
16170
- * AWS-side rejection that names the property:
16171
- * - DECLARED members only. An absent member is not a request to lower
16172
- * anything, so it takes no part in the verdict.
16173
- * - MIXED is not a decrease. One member below live and the other above means
16174
- * the call carries a genuine increase; AWS decides.
16175
- * - An absent or unusable LIVE counterpart is not a decrease. Without a
16176
- * number to compare against there is no evidence of one. Only the LIVE side
16177
- * can reach that arm: the desired side is a COERCED spec, so a malformed
16178
- * template value has already been dropped by {@link coerceWarmThroughput}.
16179
- *
16180
- * A decrease therefore requires every declared member to be at-or-below live
16181
- * AND at least one to be strictly below.
16182
- *
16183
- * The skip this drives is right for EVERY `update()` caller — the deploy
16184
- * engine, `cdkd drift --revert`, and the rollback executor's two revert arms —
16185
- * because none of them can make AWS lower the value, so none loses anything a
16186
- * doomed call would have achieved.
16187
- */
16188
- function isWarmThroughputDecrease(desired, live) {
16189
- if (desired === void 0 || live === void 0) return false;
16190
- let sawDecrease = false;
16191
- for (const member of WARM_THROUGHPUT_MEMBERS) {
16192
- if (desired[member] === void 0) continue;
16193
- const wanted = toFiniteNumber(desired[member]);
16194
- const current = toFiniteNumber(live[member]);
16195
- if (wanted === void 0 || current === void 0) return false;
16196
- if (wanted > current) return false;
16197
- if (wanted < current) sawDecrease = true;
16198
- }
16199
- return sawDecrease;
16200
- }
16201
-
16202
16092
  //#endregion
16203
16093
  //#region src/provisioning/dynamodb-index-busy-delete.ts
16204
16094
  /**
@@ -19969,9 +19859,27 @@ var DynamoDBGlobalTableProvider = class {
19969
19859
  const warn = (message) => this.logger.warn(maskSecrets(message));
19970
19860
  const debug = (message) => this.logger.debug(maskSecrets(message));
19971
19861
  debug(`Updating DynamoDB GlobalTable ${logicalId}: ${maskSecrets(physicalId)}`);
19972
- if (properties["TableName"] !== void 0 && previousProperties["TableName"] !== void 0 && properties["TableName"] !== previousProperties["TableName"]) throw new ProvisioningError(`TableName is immutable on AWS::DynamoDB::GlobalTable; replacement required (deploy with --replace, or destroy + redeploy)`, resourceType, logicalId, physicalId);
19973
- if (properties["KeySchema"] !== void 0 && previousProperties["KeySchema"] !== void 0 && !deepEqual$1(properties["KeySchema"], previousProperties["KeySchema"])) throw new ProvisioningError(`KeySchema is immutable on AWS::DynamoDB::GlobalTable; replacement required (deploy with --replace, or destroy + redeploy)`, resourceType, logicalId, physicalId);
19974
- if (properties["LocalSecondaryIndexes"] !== void 0 && previousProperties["LocalSecondaryIndexes"] !== void 0 && !deepEqual$1(properties["LocalSecondaryIndexes"], previousProperties["LocalSecondaryIndexes"])) throw new ProvisioningError(`LocalSecondaryIndexes is immutable on AWS::DynamoDB::GlobalTable; replacement required (deploy with --replace, or destroy + redeploy)`, resourceType, logicalId, physicalId);
19862
+ const replaceFlags = "cdkd deploy --replace --force-stateful-recreation";
19863
+ const replaceRemedy = async () => {
19864
+ let guardRegion = "";
19865
+ try {
19866
+ guardRegion = await this.dynamoDBClient.config.region() ?? "";
19867
+ } catch {
19868
+ guardRegion = "";
19869
+ }
19870
+ return extractLocalDeletionProtection(previousProperties, guardRegion) === true ? protectedReplacementAdvice({
19871
+ evidence: "cdkd's recorded properties for this table carry DeletionProtectionEnabled: true for the deploy region",
19872
+ replaceFlags,
19873
+ disable: {
19874
+ before: "aws dynamodb update-table --table-name",
19875
+ identifier: physicalId,
19876
+ after: "--no-deletion-protection-enabled"
19877
+ }
19878
+ }) : `replacement required (deploy with ${replaceFlags}, or destroy + redeploy)`;
19879
+ };
19880
+ if (properties["TableName"] !== void 0 && previousProperties["TableName"] !== void 0 && properties["TableName"] !== previousProperties["TableName"]) throw new ProvisioningError(`TableName is immutable on AWS::DynamoDB::GlobalTable; ${await replaceRemedy()}`, resourceType, logicalId, physicalId);
19881
+ if (properties["KeySchema"] !== void 0 && previousProperties["KeySchema"] !== void 0 && !deepEqual$1(properties["KeySchema"], previousProperties["KeySchema"])) throw new ProvisioningError(`KeySchema is immutable on AWS::DynamoDB::GlobalTable; ${await replaceRemedy()}`, resourceType, logicalId, physicalId);
19882
+ if (properties["LocalSecondaryIndexes"] !== void 0 && previousProperties["LocalSecondaryIndexes"] !== void 0 && !deepEqual$1(properties["LocalSecondaryIndexes"], previousProperties["LocalSecondaryIndexes"])) throw new ProvisioningError(`LocalSecondaryIndexes is immutable on AWS::DynamoDB::GlobalTable; ${await replaceRemedy()}`, resourceType, logicalId, physicalId);
19975
19883
  const oldAttrs = previousProperties["AttributeDefinitions"] ?? [];
19976
19884
  const newAttrs = properties["AttributeDefinitions"] ?? [];
19977
19885
  const desiredKeyAttrNames = collectDesiredKeyAttributeNames(properties);
@@ -19984,7 +19892,7 @@ var DynamoDBGlobalTableProvider = class {
19984
19892
  const describeResp = await this.dynamoDBClient.send(new DescribeTableCommand({ TableName: physicalId }));
19985
19893
  const tableArn = describeResp.Table?.TableArn;
19986
19894
  const extractLocalTags = (props) => {
19987
- return (props["Replicas"] ?? []).find((r) => r["Region"] === currentRegion)?.["Tags"];
19895
+ return localReplicaEntry(props, currentRegion)?.["Tags"];
19988
19896
  };
19989
19897
  if (tableArn) await this.applyTagDiff(tableArn, extractLocalTags(previousProperties), extractLocalTags(properties));
19990
19898
  const liveWarmByIndexName = /* @__PURE__ */ new Map();
@@ -21034,7 +20942,7 @@ var DynamoDBGlobalTableProvider = class {
21034
20942
  return entry;
21035
20943
  }));
21036
20944
  result["Replicas"] = replicas;
21037
- const localReplicaEntry = replicas.find((r) => r["Region"] === currentRegion);
20945
+ const localReplica = replicas.find((r) => r["Region"] === currentRegion);
21038
20946
  if (table.GlobalSecondaryIndexes && table.GlobalSecondaryIndexes.length > 0) {
21039
20947
  const cfnIndexes = [];
21040
20948
  const replicaIndexEntries = [];
@@ -21065,11 +20973,11 @@ var DynamoDBGlobalTableProvider = class {
21065
20973
  if (Object.keys(replicaEntry).length > 1) replicaIndexEntries.push(replicaEntry);
21066
20974
  }
21067
20975
  result["GlobalSecondaryIndexes"] = cfnIndexes;
21068
- if (localReplicaEntry && replicaIndexEntries.length > 0) localReplicaEntry["GlobalSecondaryIndexes"] = replicaIndexEntries;
20976
+ if (localReplica && replicaIndexEntries.length > 0) localReplica["GlobalSecondaryIndexes"] = replicaIndexEntries;
21069
20977
  }
21070
20978
  if (table.OnDemandThroughput?.MaxWriteRequestUnits !== void 0) result["WriteOnDemandThroughputSettings"] = { MaxWriteRequestUnits: table.OnDemandThroughput.MaxWriteRequestUnits };
21071
20979
  else result["WriteOnDemandThroughputSettings"] = {};
21072
- if (localReplicaEntry && table.OnDemandThroughput?.MaxReadRequestUnits !== void 0) localReplicaEntry["ReadOnDemandThroughputSettings"] = { MaxReadRequestUnits: table.OnDemandThroughput.MaxReadRequestUnits };
20980
+ if (localReplica && table.OnDemandThroughput?.MaxReadRequestUnits !== void 0) localReplica["ReadOnDemandThroughputSettings"] = { MaxReadRequestUnits: table.OnDemandThroughput.MaxReadRequestUnits };
21073
20981
  if (billingMode === "PROVISIONED") {
21074
20982
  const autoScaling = await this.readAutoScalingSettings(tableNameForSubs, "dynamodb:table:WriteCapacityUnits");
21075
20983
  if (autoScaling) result["WriteProvisionedThroughputSettings"] = { WriteCapacityAutoScalingSettings: autoScaling };
@@ -21638,10 +21546,29 @@ var DynamoDBGlobalTableProvider = class {
21638
21546
  }
21639
21547
  };
21640
21548
  /**
21641
- * Diff CFn `Replicas[]` arrays. Keyed by `Region`. Returns adds, removes,
21642
- * and modifies (entries whose other keys — KMSMasterKeyId,
21643
- * GlobalSecondaryIndexes, TableClassOverride differ from the old shape).
21549
+ * The `Replicas[]` entry for `region`, or `undefined`.
21550
+ *
21551
+ * ONE predicate, because two readers of the same field had drifted: issue
21552
+ * [#2610]'s review round guarded `extractLocalDeletionProtection` against a
21553
+ * non-array `Replicas` and left `update()`'s inline `extractLocalTags` on the
21554
+ * old `(props['Replicas'] ?? []) as Array<...>` -- which is the one that
21555
+ * actually throws FIRST on the apply path, ~400 lines earlier in the same
21556
+ * method, so guarding only the other produced no visible change and no test
21557
+ * went red. A shared predicate is what stops the next reader inheriting the
21558
+ * unguarded spelling; `derivePerCallProvisionedThroughput` documents the same
21559
+ * hazard for the same field and guards independently.
21560
+ *
21561
+ * `Array.isArray`, not `?? []`: a non-array `Replicas` -- an unresolved
21562
+ * intrinsic, or a hand-edited state record -- makes `.find` throw a bare
21563
+ * `TypeError`, and since [#2610] one caller sits on the REFUSAL path, where a
21564
+ * throw REPLACES three informative `ProvisioningError`s with a stack trace.
21565
+ * The `r?.` guards a null ENTRY inside an otherwise-valid array.
21644
21566
  */
21567
+ function localReplicaEntry(props, region) {
21568
+ const raw = props["Replicas"];
21569
+ if (!Array.isArray(raw)) return void 0;
21570
+ return raw.find((r) => r?.["Region"] === region);
21571
+ }
21645
21572
  /**
21646
21573
  * Extract the local replica's `DeletionProtectionEnabled` from a CFn
21647
21574
  * properties shape. The `AWS::DynamoDB::GlobalTable` CFn schema places
@@ -21657,7 +21584,7 @@ var DynamoDBGlobalTableProvider = class {
21657
21584
  * the SDK call.
21658
21585
  */
21659
21586
  function extractLocalDeletionProtection(props, region) {
21660
- const perReplica = (props["Replicas"] ?? []).find((r) => r["Region"] === region)?.["DeletionProtectionEnabled"];
21587
+ const perReplica = localReplicaEntry(props, region)?.["DeletionProtectionEnabled"];
21661
21588
  if (typeof perReplica === "boolean") return perReplica;
21662
21589
  const topLevel = props["DeletionProtectionEnabled"];
21663
21590
  return typeof topLevel === "boolean" ? topLevel : void 0;
@@ -22916,6 +22843,11 @@ function collectTableOnDemandCeilings(properties, region, diagnostics, maskSecre
22916
22843
  }
22917
22844
  };
22918
22845
  }
22846
+ /**
22847
+ * Diff CFn `Replicas[]` arrays. Keyed by `Region`. Returns adds, removes,
22848
+ * and modifies (entries whose other keys — KMSMasterKeyId,
22849
+ * GlobalSecondaryIndexes, TableClassOverride — differ from the old shape).
22850
+ */
22919
22851
  function diffReplicas(oldReplicas, newReplicas) {
22920
22852
  const oldByRegion = /* @__PURE__ */ new Map();
22921
22853
  const newByRegion = /* @__PURE__ */ new Map();
@@ -22984,6 +22916,75 @@ function deepEqual$1(a, b) {
22984
22916
  //#endregion
22985
22917
  //#region src/provisioning/providers/logs-loggroup-provider.ts
22986
22918
  /**
22919
+ * Refuse a `RetentionInDays` the send path cannot USE (issue [#2521]).
22920
+ *
22921
+ * **The scope of this refusal is a TEST, not a paragraph.** Every review round
22922
+ * on issue [#2521] found a hole in a PROSE statement of it — a coercible
22923
+ * negative that fell through to `DeleteRetentionPolicy`, an unusable value
22924
+ * silenced by the wrong change gate, wrong cells in the hand-written table
22925
+ * that replaced those sentences, and then the one non-finite number
22926
+ * JavaScript makes FALSY. No count is given here: the count itself drifted
22927
+ * between this comment and the changelog, which is the same failure one level
22928
+ * up. The table now lives in
22929
+ * `tests/unit/provisioning/logs-loggroup-provider-retention-coercion.test.ts`
22930
+ * as `CREATE_MATRIX` / `UPDATE_MATRIX`: one row per (value, previous) pair,
22931
+ * each asserting which AWS call goes out, each carrying what the pre-#2521
22932
+ * code did. Read a change against those rows and add one for any pair they do
22933
+ * not cover; do not restate the rule here, because a fourth restatement is
22934
+ * the thing that keeps being wrong.
22935
+ *
22936
+ * THREE refusal CASES across TWO `throw` statements — the non-finite guard
22937
+ * clause at the top of the body has its own, and the other two share the
22938
+ * second, differing only in the `detail` clause. Only the WHY of each:
22939
+ *
22940
+ * - **a non-finite NUMBER** (`NaN`, `Infinity`, `-Infinity`). It sits ahead
22941
+ * of the falsy return because `NaN` is FALSY: without it that value exits
22942
+ * early, fails the `> 0` send test and reaches `DeleteRetentionPolicy`,
22943
+ * while its two siblings — truthy — were already refused. Leaving one
22944
+ * member of a family out is the inconsistency the negative arm below
22945
+ * introduced, so the arm names the family.
22946
+ * - **not a number at all** (`' '`, `'abc'`, `[]`, `{}`, `true`). The
22947
+ * pre-coercion code tested `if (properties['RetentionInDays'])` and handed
22948
+ * the raw value to `PutRetentionPolicyCommand`, so CloudWatch Logs rejected
22949
+ * it. Refusing moves that rejection one layer earlier and names the
22950
+ * property.
22951
+ * - **a NEGATIVE number** (`-1`, `'-1'`). It coerces, so a refusal gated on
22952
+ * coercibility alone let it through; it then failed the `> 0` send test and
22953
+ * fell into the UPDATE path's `else`, which issues
22954
+ * `DeleteRetentionPolicy` — a template typo SILENTLY REMOVING a live
22955
+ * retention where the old code sent `-1` and failed loudly.
22956
+ *
22957
+ * The FALSY values pass through, as they did before — `0`, `''`, `false`,
22958
+ * `null` and absent; `NaN` is the one exception, refused above because it is a
22959
+ * malformed number rather than a spelling of "no retention".
22960
+ *
22961
+ * `'0'` is TRUTHY, so it reaches this function and passes too: zero is
22962
+ * what {@link LogsLogGroupProvider.readCurrentState} records for a group with
22963
+ * no retention policy, and CloudFormation coerces `'0'` to `0`, so routing the
22964
+ * string to the same arm as the number is the consistency the coercion is for.
22965
+ * On the UPDATE path against a live positive retention that is a real change —
22966
+ * it DELETES where the old code sent and was rejected — and `UPDATE_MATRIX`
22967
+ * pins it.
22968
+ *
22969
+ * The message names the value's TYPE and never the value. A provider's
22970
+ * `properties` bag arrives with dynamic references already RESOLVED, so a
22971
+ * `{{resolve:secretsmanager:...}}` scalar is PLAINTEXT by the time it reaches
22972
+ * here, and this function has no masker to thread — `create()` takes no
22973
+ * `CreateContext` and `applyUpdate` no `UpdateContext`
22974
+ * (`.claude/rules/provider-masking.md`). Interpolating it as
22975
+ * `${JSON.stringify(raw)}` was the first draft and
22976
+ * `vp run audit:provider-secret-mask:check` refused it — but read that as one
22977
+ * SPELLING being fenced, not the rule: the critic's population is
22978
+ * interpolated `JSON.stringify` calls, so a bare `${raw}` here would leave it
22979
+ * green. Do not put the value back without threading the capability first.
22980
+ */
22981
+ function assertUsableRetention(raw, coerced, logicalId, resourceType) {
22982
+ if (typeof raw === "number" && !Number.isFinite(raw)) throw new ProvisioningError(`${logicalId} (${resourceType}): RetentionInDays must be a non-negative number or a numeric string (CloudFormation coerces '30' to 30); the template declares a non-finite number`, resourceType, logicalId);
22983
+ if (!raw) return;
22984
+ if (coerced !== void 0 && coerced >= 0) return;
22985
+ throw new ProvisioningError(`${logicalId} (${resourceType}): RetentionInDays must be a non-negative number or a numeric string (CloudFormation coerces '30' to 30); ${coerced === void 0 ? `the template's value is of type ${Array.isArray(raw) ? "array" : typeof raw} and does not parse as a finite number` : "the template declares a negative number"}`, resourceType, logicalId);
22986
+ }
22987
+ /**
22987
22988
  * AWS CloudWatch Logs LogGroup Provider
22988
22989
  *
22989
22990
  * Implements resource provisioning for AWS::Logs::LogGroup using the CloudWatch Logs SDK.
@@ -23064,8 +23065,10 @@ var LogsLogGroupProvider = class {
23064
23065
  else throw createError;
23065
23066
  }
23066
23067
  try {
23067
- const retentionInDays = properties["RetentionInDays"];
23068
- if (retentionInDays) await this.logsClient.send(new PutRetentionPolicyCommand({
23068
+ const rawRetention = properties["RetentionInDays"];
23069
+ const retentionInDays = toFiniteNumber(rawRetention);
23070
+ assertUsableRetention(rawRetention, retentionInDays, logicalId, resourceType);
23071
+ if (retentionInDays !== void 0 && retentionInDays > 0) await this.logsClient.send(new PutRetentionPolicyCommand({
23069
23072
  logGroupName,
23070
23073
  retentionInDays
23071
23074
  }));
@@ -23135,14 +23138,14 @@ var LogsLogGroupProvider = class {
23135
23138
  */
23136
23139
  async update(logicalId, physicalId, resourceType, properties, previousProperties) {
23137
23140
  try {
23138
- return await this.applyUpdate(logicalId, physicalId, properties, previousProperties);
23141
+ return await this.applyUpdate(logicalId, physicalId, resourceType, properties, previousProperties);
23139
23142
  } catch (error) {
23140
23143
  if (error instanceof CdkdError) throw error;
23141
23144
  const cause = error instanceof Error ? error : void 0;
23142
23145
  throw new ProvisioningError(`Failed to update log group ${logicalId}: ${error instanceof Error ? error.message : String(error)}`, resourceType, logicalId, physicalId, cause);
23143
23146
  }
23144
23147
  }
23145
- async applyUpdate(logicalId, physicalId, properties, previousProperties) {
23148
+ async applyUpdate(logicalId, physicalId, resourceType, properties, previousProperties) {
23146
23149
  this.logger.debug(`Updating log group ${logicalId}: ${physicalId}`);
23147
23150
  const normalizeClass = (v) => typeof v === "string" && v.length > 0 ? v : "STANDARD";
23148
23151
  const prevClass = normalizeClass(previousProperties["LogGroupClass"]);
@@ -23151,6 +23154,13 @@ var LogsLogGroupProvider = class {
23151
23154
  const replaceFlags = "--replace --force-stateful-recreation";
23152
23155
  throw new ResourceUpdateNotSupportedError("AWS::Logs::LogGroup", logicalId, `the LogGroupClass ('${prevClass}' -> '${nextClass}') cannot be changed after creation. ${isTruthyCfnBoolean(previousProperties["DeletionProtectionEnabled"]) ? `cdkd's recorded properties for this log group carry DeletionProtectionEnabled, so ${replaceFlags} alone will NOT succeed while AWS still has it on: the replacement normally deletes the log group, AWS refuses that delete while protection is on, and cdkd deploy has no --remove-protection flag to clear it (only cdkd destroy and cdkd state destroy act on one). Read "Deletion protection blocks a replacement" in docs/cli-deploy-safety.md BEFORE you disable anything: whether disabling helps at all, and what the flag ends up as, depend on your UpdateReplacePolicy and on whether the deploy completes — neither of which this refusal can see. Then disable deletion protection — \`aws logs put-log-group-deletion-protection --log-group-identifier '${physicalId}' --no-deletion-protection-enabled\`, or via the console — and re-deploy with ${replaceFlags} to delete + recreate the log group under the new class (its stored log events are lost). Setting DeletionProtectionEnabled: false in the template does NOT clear it in the same deploy: this refusal fires before that property is applied, so that route needs its own deploy with the LogGroupClass change reverted. Or revert the LogGroupClass change and keep the current class.` : `Re-deploy with ${replaceFlags} to delete + recreate the log group under the new class (its stored log events are lost), or revert the LogGroupClass change.`} Note --force-stateful-recreation has NO per-resource granularity: it clears the data guard for every replacement target in the run`);
23153
23156
  }
23157
+ const rawRetention = properties["RetentionInDays"];
23158
+ const rawPreviousRetention = previousProperties["RetentionInDays"];
23159
+ const renderedRetention = JSON.stringify(rawRetention);
23160
+ const sameRawRetention = Object.is(rawRetention, rawPreviousRetention) || renderedRetention !== void 0 && renderedRetention !== "null" && renderedRetention === JSON.stringify(rawPreviousRetention);
23161
+ const retentionInDays = toFiniteNumber(rawRetention);
23162
+ const oldRetentionInDays = toFiniteNumber(rawPreviousRetention);
23163
+ if (!sameRawRetention) assertUsableRetention(rawRetention, retentionInDays, logicalId, resourceType);
23154
23164
  const newKmsKeyId = properties["KmsKeyId"];
23155
23165
  if (newKmsKeyId !== previousProperties["KmsKeyId"]) {
23156
23166
  if (newKmsKeyId) await this.logsClient.send(new AssociateKmsKeyCommand({
@@ -23160,8 +23170,7 @@ var LogsLogGroupProvider = class {
23160
23170
  else await this.logsClient.send(new DisassociateKmsKeyCommand({ logGroupName: physicalId }));
23161
23171
  this.logger.debug(`Updated KMS key association for log group ${physicalId}`);
23162
23172
  }
23163
- const retentionInDays = properties["RetentionInDays"];
23164
- if (retentionInDays !== previousProperties["RetentionInDays"]) if (retentionInDays) await this.logsClient.send(new PutRetentionPolicyCommand({
23173
+ if (retentionInDays !== oldRetentionInDays || !sameRawRetention && Boolean(rawPreviousRetention) && oldRetentionInDays === void 0) if (retentionInDays !== void 0 && retentionInDays > 0) await this.logsClient.send(new PutRetentionPolicyCommand({
23165
23174
  logGroupName: physicalId,
23166
23175
  retentionInDays
23167
23176
  }));
@@ -37258,7 +37267,15 @@ var ELBv2Provider = class {
37258
37267
  for (const [k, v] of Object.entries(p)) if (!handledKeys.has(k)) out[k] = v;
37259
37268
  return out;
37260
37269
  };
37261
- if (JSON.stringify(stripHandled(properties)) !== JSON.stringify(stripHandled(previousProperties))) throw new ResourceUpdateNotSupportedError("AWS::ElasticLoadBalancingV2::LoadBalancer", logicalId, "ELBv2 LoadBalancer Name / Type / Scheme are immutable on AWS — none of the ELBv2 Modify* / Set* APIs accept these fields; they are fixed at creation. cdkd handles LoadBalancerAttributes / Subnets / SubnetMappings / SecurityGroups / IpAddressType / Tags in-place; for Name / Type / Scheme re-deploy with cdkd deploy --replace, or destroy + redeploy the stack.");
37270
+ if (JSON.stringify(stripHandled(properties)) !== JSON.stringify(stripHandled(previousProperties))) throw new ResourceUpdateNotSupportedError("AWS::ElasticLoadBalancingV2::LoadBalancer", logicalId, "ELBv2 LoadBalancer Name / Type / Scheme are immutable on AWS — none of the ELBv2 Modify* / Set* APIs accept these fields; they are fixed at creation. cdkd handles LoadBalancerAttributes / Subnets / SubnetMappings / SecurityGroups / IpAddressType / Tags in-place. " + (this.normalizeAttributes(previousProperties["LoadBalancerAttributes"]).some((a) => a.Key === "deletion_protection.enabled" && isTruthyCfnBoolean(a.Value)) ? protectedReplacementAdvice({
37271
+ evidence: "cdkd's recorded properties for this load balancer carry LoadBalancerAttributes deletion_protection.enabled=true",
37272
+ replaceFlags: "cdkd deploy --replace",
37273
+ disable: {
37274
+ before: "aws elbv2 modify-load-balancer-attributes --load-balancer-arn",
37275
+ identifier: physicalId,
37276
+ after: "--attributes Key=deletion_protection.enabled,Value=false"
37277
+ }
37278
+ }) : "For Name / Type / Scheme re-deploy with cdkd deploy --replace, or destroy + redeploy the stack."));
37262
37279
  const submittedAttrs = this.diffAttributes(this.normalizeAttributes(properties["LoadBalancerAttributes"]), this.normalizeAttributes(previousProperties["LoadBalancerAttributes"]), this.attributeRemovalResolver(LOAD_BALANCER_ATTRIBUTE_DEFAULTS));
37263
37280
  if (submittedAttrs.length > 0) {
37264
37281
  await this.getClient().send(new ModifyLoadBalancerAttributesCommand({
@@ -43897,7 +43914,7 @@ var CognitoUserPoolProvider = class {
43897
43914
  if (properties["SmsVerificationMessage"] !== void 0) updateParams.SmsVerificationMessage = properties["SmsVerificationMessage"];
43898
43915
  if (properties["UserPoolTier"]) updateParams.UserPoolTier = properties["UserPoolTier"];
43899
43916
  await this.getClient().send(new UpdateUserPoolCommand(updateParams));
43900
- await this.reconcileSchemaCustomAttributes(logicalId, physicalId, resourceType, properties["Schema"], previousProperties["Schema"]);
43917
+ await this.reconcileSchemaCustomAttributes(logicalId, physicalId, resourceType, properties["Schema"], previousProperties["Schema"], (properties["DeletionProtection"] ? properties["DeletionProtection"] : previousProperties["DeletionProtection"]) === "ACTIVE");
43901
43918
  await this.applyMfaConfig(physicalId, properties, mfaConfiguration, {
43902
43919
  ...liveMfa?.value !== void 0 ? { liveMfaConfiguration: liveMfa.value } : {},
43903
43920
  ...liveMfa?.failed === true ? { liveReadFailed: true } : {},
@@ -43939,7 +43956,7 @@ var CognitoUserPoolProvider = class {
43939
43956
  * attribute, requires replacement -> ResourceUpdateNotSupportedError.
43940
43957
  * A byte-identical Schema is a no-op (no AddCustomAttributes call).
43941
43958
  */
43942
- async reconcileSchemaCustomAttributes(logicalId, physicalId, resourceType, newSchema, oldSchema) {
43959
+ async reconcileSchemaCustomAttributes(logicalId, physicalId, resourceType, newSchema, oldSchema, deletionProtected = false) {
43943
43960
  const newAttrs = newSchema ?? [];
43944
43961
  const oldAttrs = oldSchema ?? [];
43945
43962
  for (const attr of newAttrs) if (attr.Name === void 0) throw new ProvisioningError(`Cognito User Pool ${logicalId} has a Schema attribute with no Name — every Schema entry must have a Name`, resourceType, logicalId, physicalId);
@@ -43964,7 +43981,20 @@ var CognitoUserPoolProvider = class {
43964
43981
  ...modified.map((n) => `modified attribute '${n}'`),
43965
43982
  ...addedStandard.map((n) => `added standard attribute '${n}'`)
43966
43983
  ];
43967
- if (immutableChanges.length > 0) throw new ResourceUpdateNotSupportedError(resourceType, logicalId, `the Schema change (${immutableChanges.join("; ")}) is immutable on AWS — AWS can only ADD custom attributes in place, so removing or modifying an attribute requires recreating the pool. AWS::Cognito::UserPool is a stateful type, so re-run with cdkd deploy --replace --force-stateful-recreation to recreate it (this deletes all users in the pool)`);
43984
+ if (immutableChanges.length > 0) {
43985
+ const replaceFlags = "cdkd deploy --replace --force-stateful-recreation";
43986
+ const remedy = deletionProtected ? protectedReplacementAdvice({
43987
+ evidence: "cdkd's properties for this user pool carry DeletionProtection: ACTIVE",
43988
+ replaceFlags,
43989
+ disable: {
43990
+ before: "aws cognito-idp update-user-pool --user-pool-id",
43991
+ identifier: physicalId,
43992
+ after: "--deletion-protection INACTIVE",
43993
+ caveat: "Note UpdateUserPool's behaviour on an omitted member differs per field, and is unmeasured for DeletionProtection, so send your complete pool configuration alongside that flag rather than the flag alone."
43994
+ }
43995
+ }) : `AWS::Cognito::UserPool is a stateful type, so re-run with ${replaceFlags} to recreate it (this deletes all users in the pool).`;
43996
+ throw new ResourceUpdateNotSupportedError(resourceType, logicalId, `the Schema change (${immutableChanges.join("; ")}) is immutable on AWS — AWS can only ADD custom attributes in place, so removing or modifying an attribute requires recreating the pool. ${remedy}`);
43997
+ }
43968
43998
  const addedCustom = added.filter((a) => a.Name !== void 0 && !STANDARD_USER_POOL_ATTRIBUTES.has(a.Name));
43969
43999
  if (addedCustom.length === 0) return;
43970
44000
  this.logger.debug(`Adding ${addedCustom.length} custom attribute(s) to ${physicalId}: ` + addedCustom.map((a) => a.Name).join(", "));
@@ -52048,7 +52078,7 @@ var SchedulerScheduleProvider = class {
52048
52078
  async update(logicalId, physicalId, resourceType, properties, previousProperties) {
52049
52079
  const groupName = this.groupNameOf(properties);
52050
52080
  const previousGroupName = this.groupNameOf(previousProperties);
52051
- if (groupName !== previousGroupName) throw new ResourceUpdateNotSupportedError(resourceType, logicalId, `GroupName addresses the schedule (${previousGroupName ?? "default"} -> ${groupName ?? "default"}); re-run with \`cdkd deploy --replace ${logicalId}\` to recreate it in the new group`);
52081
+ if (groupName !== previousGroupName) throw new ResourceUpdateNotSupportedError(resourceType, logicalId, `GroupName addresses the schedule (${previousGroupName ?? "default"} -> ${groupName ?? "default"}); re-run with \`cdkd deploy --replace\` to recreate it in the new group (--replace is a boolean flag and takes no resource id; it applies to every resource in the run whose in-place update is refused)`);
52052
52082
  this.logger.debug(`Updating Schedule ${logicalId}: ${physicalId}${groupName ? ` (group: ${groupName})` : ""}`);
52053
52083
  try {
52054
52084
  const input = {
@@ -52070,7 +52100,11 @@ var SchedulerScheduleProvider = class {
52070
52100
  }
52071
52101
  async delete(logicalId, physicalId, resourceType, properties, context) {
52072
52102
  const groupName = this.groupNameOf(properties);
52073
- if (properties === void 0) this.logger.warn(`State record for Schedule ${logicalId} carries no properties — deleting '${physicalId}' from the default group. If the schedule lives in a custom group, delete it manually: aws scheduler delete-schedule --name ${physicalId} --group-name <group>`);
52103
+ if (properties === void 0) {
52104
+ const safeId = displaySafe(physicalId, { asciiOnly: true });
52105
+ const manualHint = safeId && safeId === physicalId ? `If the schedule lives in a custom group, delete it manually: aws scheduler delete-schedule --name ${shellQuote(safeId)} --group-name <group>` : "If the schedule lives in a custom group, delete it manually via the console: the name recorded for it cannot be reproduced safely on a command line.";
52106
+ this.logger.warn(`State record for Schedule ${logicalId} carries no properties — deleting '${safeId || "<unrenderable>"}' from the default group. ${manualHint}`);
52107
+ }
52074
52108
  this.logger.debug(`Deleting Schedule ${logicalId}: ${physicalId}${groupName ? ` (group: ${groupName})` : ""}`);
52075
52109
  try {
52076
52110
  await this.getClient().send(new DeleteScheduleCommand({
@@ -52254,7 +52288,7 @@ var EFSProvider = class {
52254
52288
  ]) {
52255
52289
  const next = properties[key];
52256
52290
  const prev = previousProperties[key];
52257
- if (next !== void 0 && prev !== void 0 && JSON.stringify(next) !== JSON.stringify(prev)) throw new ResourceUpdateNotSupportedError(resourceType, logicalId, `AWS EFS FileSystem ${key} is immutable on AWS — UpdateFileSystem does not accept ${key}; the property is fixed at creation. Re-deploy with cdkd deploy --replace, or destroy + redeploy the stack.`);
52291
+ if (next !== void 0 && prev !== void 0 && JSON.stringify(next) !== JSON.stringify(prev)) throw new ResourceUpdateNotSupportedError(resourceType, logicalId, `AWS EFS FileSystem ${key} is immutable on AWS — UpdateFileSystem does not accept ${key}; the property is fixed at creation. Re-deploy with cdkd deploy --replace --force-stateful-recreation (the type is in cdkd's stateful-recreate guard set, so a bare --replace is refused a second time with STATEFUL_REPLACE_BLOCKED), or destroy + redeploy the stack.`);
52258
52292
  }
52259
52293
  const newThroughputMode = properties["ThroughputMode"];
52260
52294
  const newProvisioned = properties["ProvisionedThroughputInMibps"];
@@ -53375,7 +53409,7 @@ var FSxFileSystemProvider = class {
53375
53409
  for (const key of TOP_LEVEL_IMMUTABLE_PROPS) {
53376
53410
  const next = properties[key];
53377
53411
  const prev = previousProperties[key];
53378
- if (JSON.stringify(next ?? null) !== JSON.stringify(prev ?? null)) throw new ResourceUpdateNotSupportedError(resourceType, logicalId, `AWS FSx FileSystem ${key} is immutable on AWS — UpdateFileSystem does not accept ${key}; the property is fixed at creation. Re-deploy with cdkd deploy --replace, or destroy + redeploy the stack.`);
53412
+ if (JSON.stringify(next ?? null) !== JSON.stringify(prev ?? null)) throw new ResourceUpdateNotSupportedError(resourceType, logicalId, `AWS FSx FileSystem ${key} is immutable on AWS — UpdateFileSystem does not accept ${key}; the property is fixed at creation. Re-deploy with cdkd deploy --replace --force-stateful-recreation (the type is in cdkd's stateful-recreate guard set, so a bare --replace is refused a second time with STATEFUL_REPLACE_BLOCKED), or destroy + redeploy the stack.`);
53379
53413
  }
53380
53414
  const variantConfigKey = this.detectVariantConfigKey(properties, previousProperties);
53381
53415
  const { diff: variantDiff, hasMutableDiff: variantHasMutableDiff } = variantConfigKey ? this.computeVariantConfigDiff(resourceType, logicalId, variantConfigKey, properties[variantConfigKey] ?? {}, previousProperties[variantConfigKey] ?? {}) : {
@@ -53456,7 +53490,7 @@ var FSxFileSystemProvider = class {
53456
53490
  const nextVal = next[key];
53457
53491
  const prevVal = prev[key];
53458
53492
  if (JSON.stringify(nextVal) === JSON.stringify(prevVal)) continue;
53459
- if (!mutable.has(key)) throw new ResourceUpdateNotSupportedError(resourceType, logicalId, `AWS FSx FileSystem ${configKey}.${key} is immutable on AWS — UpdateFileSystem cannot change it after creation. Re-deploy with cdkd deploy --replace, or destroy + redeploy the stack.`);
53493
+ if (!mutable.has(key)) throw new ResourceUpdateNotSupportedError(resourceType, logicalId, `AWS FSx FileSystem ${configKey}.${key} is immutable on AWS — UpdateFileSystem cannot change it after creation. Re-deploy with cdkd deploy --replace --force-stateful-recreation (the type is in cdkd's stateful-recreate guard set, so a bare --replace is refused a second time with STATEFUL_REPLACE_BLOCKED), or destroy + redeploy the stack.`);
53460
53494
  const ctx = {
53461
53495
  resourceType,
53462
53496
  logicalId,
@@ -54417,6 +54451,16 @@ var EMRClusterProvider = class {
54417
54451
  const changed = (key) => !jsonEqual$2(properties[key], previousProperties[key]);
54418
54452
  const nextInstances = properties["Instances"] ?? {};
54419
54453
  const prevInstances = previousProperties["Instances"] ?? {};
54454
+ const replaceFlags = "cdkd deploy --replace --force-stateful-recreation";
54455
+ const replaceRemedy = () => toBoolean(prevInstances["TerminationProtected"]) === true ? protectedReplacementAdvice({
54456
+ evidence: "cdkd's recorded properties for this cluster carry Instances.TerminationProtected: true",
54457
+ replaceFlags,
54458
+ disable: {
54459
+ before: "aws emr modify-cluster-attributes --cluster-id",
54460
+ identifier: physicalId,
54461
+ after: "--no-termination-protected"
54462
+ }
54463
+ }) : `Re-deploy with ${replaceFlags}, or destroy + redeploy the stack.`;
54420
54464
  let terminationProtectedChanged = false;
54421
54465
  if (changed("Instances")) {
54422
54466
  const instanceKeys = /* @__PURE__ */ new Set([...Object.keys(nextInstances), ...Object.keys(prevInstances)]);
@@ -54426,7 +54470,7 @@ var EMRClusterProvider = class {
54426
54470
  terminationProtectedChanged = true;
54427
54471
  continue;
54428
54472
  }
54429
- throw new ResourceUpdateNotSupportedError(resourceType, logicalId, `AWS EMR Cluster Instances.${key} is immutable on AWS — a running cluster's instance topology / networking cannot be changed in place. Re-deploy with cdkd deploy --replace, or destroy + redeploy the stack.`);
54473
+ throw new ResourceUpdateNotSupportedError(resourceType, logicalId, `AWS EMR Cluster Instances.${key} is immutable on AWS — a running cluster's instance topology / networking cannot be changed in place. ${replaceRemedy()}`);
54430
54474
  }
54431
54475
  }
54432
54476
  for (const key of Object.keys({
@@ -54435,7 +54479,7 @@ var EMRClusterProvider = class {
54435
54479
  })) {
54436
54480
  if (key === "Instances") continue;
54437
54481
  if (!changed(key)) continue;
54438
- if (!MUTABLE_TOP_LEVEL_PROPS.has(key)) throw new ResourceUpdateNotSupportedError(resourceType, logicalId, `AWS EMR Cluster ${key} is immutable on AWS — it is fixed at cluster creation. Re-deploy with cdkd deploy --replace, or destroy + redeploy the stack.`);
54482
+ if (!MUTABLE_TOP_LEVEL_PROPS.has(key)) throw new ResourceUpdateNotSupportedError(resourceType, logicalId, `AWS EMR Cluster ${key} is immutable on AWS — it is fixed at cluster creation. ${replaceRemedy()}`);
54439
54483
  }
54440
54484
  const visibleChanged = changed("VisibleToAllUsers");
54441
54485
  const stepConcurrencyChanged = changed("StepConcurrencyLevel");
@@ -58447,7 +58491,7 @@ var DLMLifecyclePolicyProvider = class {
58447
58491
  }
58448
58492
  }
58449
58493
  async update(logicalId, physicalId, resourceType, properties, previousProperties) {
58450
- if (JSON.stringify(properties["DefaultPolicy"]) !== JSON.stringify(previousProperties["DefaultPolicy"])) throw new ResourceUpdateNotSupportedError(resourceType, logicalId, `DefaultPolicy cannot be changed in place; re-run with \`cdkd deploy --replace ${logicalId}\` to recreate the policy`);
58494
+ if (JSON.stringify(properties["DefaultPolicy"]) !== JSON.stringify(previousProperties["DefaultPolicy"])) throw new ResourceUpdateNotSupportedError(resourceType, logicalId, "DefaultPolicy cannot be changed in place; re-run with `cdkd deploy --replace` to recreate the policy (--replace is a boolean flag and takes no resource id; it applies to every resource in the run whose in-place update is refused)");
58451
58495
  this.logger.debug(`Updating DLM Lifecycle Policy ${logicalId} (${physicalId})`);
58452
58496
  try {
58453
58497
  await this.getClient().send(new UpdateLifecyclePolicyCommand({
@@ -58647,7 +58691,7 @@ var S3VectorsProvider = class {
58647
58691
  * is surfaced as `ResourceUpdateNotSupportedError`.
58648
58692
  */
58649
58693
  async updateVectorBucket(logicalId, physicalId, resourceType, properties, previousProperties) {
58650
- for (const createOnly of ["VectorBucketName", "EncryptionConfiguration"]) if (JSON.stringify(properties[createOnly]) !== JSON.stringify(previousProperties[createOnly])) throw new ResourceUpdateNotSupportedError(resourceType, logicalId, `'${createOnly}' is immutable (create-only) on VectorBucket '${physicalId}'; a change requires replacement (cdkd deploy --replace)`);
58694
+ for (const createOnly of ["VectorBucketName", "EncryptionConfiguration"]) if (JSON.stringify(properties[createOnly]) !== JSON.stringify(previousProperties[createOnly])) throw new ResourceUpdateNotSupportedError(resourceType, logicalId, `'${createOnly}' is immutable (create-only) on VectorBucket '${physicalId}'; a change requires replacement (cdkd deploy --replace --force-stateful-recreation — the type is in cdkd's stateful-recreate guard set, so a bare --replace is refused a second time with STATEFUL_REPLACE_BLOCKED)`);
58651
58695
  const oldTags = this.cfnTagsToRecord(previousProperties["Tags"]);
58652
58696
  const newTags = this.cfnTagsToRecord(properties["Tags"]);
58653
58697
  const toSet = {};
@@ -62686,8 +62730,7 @@ async function deployCommand(stacks, options) {
62686
62730
  skipPrefix,
62687
62731
  yes: options.yes
62688
62732
  });
62689
- let recreateViaCcApiTargets;
62690
- let recreateViaSdkProviderTargets;
62733
+ let recreateTargets;
62691
62734
  if (options.recreateViaCcApi?.length || options.recreateViaSdkProvider?.length) {
62692
62735
  const stateForRecreateCheck = await stackStateBackend.getState(stackInfo.stackName, stackRegion);
62693
62736
  const validation = await probeAndRevalidateStateful({
@@ -62716,9 +62759,12 @@ async function deployCommand(stacks, options) {
62716
62759
  });
62717
62760
  const errorBlock = renderRecreateTargetsErrors(validation);
62718
62761
  if (errorBlock) throw new CdkdError(errorBlock, "RECREATE_TARGETS_INVALID");
62719
- recreateViaCcApiTargets = new Set(validation.targets.filter((t) => t.direction === "to-cc-api").map((t) => t.logicalId));
62720
- recreateViaSdkProviderTargets = new Set(validation.targets.filter((t) => t.direction === "to-sdk").map((t) => t.logicalId));
62721
- if (recreateViaCcApiTargets.size > 0 || recreateViaSdkProviderTargets.size > 0) {
62762
+ recreateTargets = {
62763
+ stackName: stackInfo.stackName,
62764
+ viaCcApi: new Set(validation.targets.filter((t) => t.direction === "to-cc-api").map((t) => t.logicalId)),
62765
+ viaSdkProvider: new Set(validation.targets.filter((t) => t.direction === "to-sdk").map((t) => t.logicalId))
62766
+ };
62767
+ if (recreateTargets.viaCcApi.size > 0 || recreateTargets.viaSdkProvider.size > 0) {
62722
62768
  const downstreamConsumers = await findDownstreamConsumers({
62723
62769
  producerStack: stackInfo.stackName,
62724
62770
  producerRegion: stackRegion,
@@ -62743,8 +62789,11 @@ async function deployCommand(stacks, options) {
62743
62789
  ...assetRedirect && { assetRedirect },
62744
62790
  ...eventRecorder && { eventRecorder },
62745
62791
  ...migrationGate && { onCurrentStateLoaded: migrationGate },
62746
- ...recreateViaCcApiTargets && recreateViaCcApiTargets.size > 0 && { recreateViaCcApiTargets },
62747
- ...recreateViaSdkProviderTargets && recreateViaSdkProviderTargets.size > 0 && { recreateViaSdkProviderTargets },
62792
+ ...options.pinCcApi?.length && { pinCcApi: {
62793
+ stackName: stackInfo.stackName,
62794
+ logicalIds: new Set(options.pinCcApi)
62795
+ } },
62796
+ ...recreateTargets && (recreateTargets.viaCcApi.size > 0 || recreateTargets.viaSdkProvider.size > 0) && { recreateTargets },
62748
62797
  ...options.replace && { replace: true },
62749
62798
  ...options.forceStatefulRecreation && { forceStatefulRecreation: true },
62750
62799
  ...options.skipFinalSnapshot && { skipFinalSnapshot: true },
@@ -62821,6 +62870,18 @@ async function deployCommand(stacks, options) {
62821
62870
  setAwsClients(awsClients);
62822
62871
  }
62823
62872
  };
62873
+ if (options.pinCcApi?.length && options.recreateViaSdkProvider?.length) {
62874
+ const both = options.pinCcApi.filter((id) => (options.recreateViaSdkProvider ?? []).includes(id));
62875
+ if (both.length > 0) throw new CdkdError(`--pin-cc-api and --recreate-via-sdk-provider both named: ${both.join(", ")}. They are opposite requests — the first keeps the resource on Cloud Control, the second destroys and recreates it via the SDK provider. Pass one.`, "PIN_CC_API_INVALID");
62876
+ }
62877
+ if (options.pinCcApi?.length) {
62878
+ const reach = analyzePinCcApiReachability(options.pinCcApi, targetStacks.map((st) => ({
62879
+ stackName: st.stackName,
62880
+ logicalIds: Object.keys(st.template.Resources ?? {})
62881
+ })));
62882
+ if (reach.unmatched.length > 0) throw new CdkdError(reach.errorMessage, "PIN_CC_API_INVALID");
62883
+ for (const partial of reach.partial) logger.info(`--pin-cc-api ${partial.logicalId}: applies to ${partial.appliesTo.join(", ")}; not declared by ${partial.absentFrom.join(", ")}`);
62884
+ }
62824
62885
  await workGraph.execute({
62825
62886
  "asset-build": options.imageBuildConcurrency,
62826
62887
  "asset-publish": options.assetPublishConcurrency,
@@ -64189,6 +64250,13 @@ function isStackNotFoundError(err) {
64189
64250
 
64190
64251
  //#endregion
64191
64252
  //#region src/cli/commands/diff-recursive.ts
64253
+ /**
64254
+ * The one spelling of the routing token for a resource leaving Cloud Control
64255
+ * (issue #2719). A literal, because `collectCcApiRoutes` writes it and
64256
+ * `annotateRouting` special-cases it, and the integ fixture greps for it --
64257
+ * three places that must agree on a string.
64258
+ */
64259
+ const SDK_MIGRATION_TOKEN = "returning to SDK provider";
64192
64260
  const logger = getLogger().child("DiffRecursive");
64193
64261
  /** Every `Ref` target reachable from a value, collected in one walk. */
64194
64262
  function collectRefTargets(value, refs) {
@@ -64645,7 +64713,15 @@ function collectCcApiRoutes(template, state) {
64645
64713
  hits.set(logicalId, drops.map((d) => d.property));
64646
64714
  continue;
64647
64715
  }
64648
- if (state.resources[logicalId]?.provisionedBy === "cc-api") hits.set(logicalId, ["sticky"]);
64716
+ const record = state.resources[logicalId];
64717
+ if (record?.provisionedBy === "cc-api") {
64718
+ const leaving = wouldReturnToSdkProvider({
64719
+ resourceType: resource.Type,
64720
+ desiredProperties: resource.Properties ?? {},
64721
+ previousProperties: record.properties
64722
+ });
64723
+ hits.set(logicalId, [leaving ? SDK_MIGRATION_TOKEN : "sticky"]);
64724
+ }
64649
64725
  }
64650
64726
  return hits;
64651
64727
  }
@@ -64791,6 +64867,7 @@ function renderChangeLines(changes, logFn, ccApiRoutes) {
64791
64867
  const annotateRouting = (logicalId) => {
64792
64868
  const props = ccApiRoutes?.get(logicalId);
64793
64869
  if (!props || props.length === 0) return "";
64870
+ if (props.length === 1 && props[0] === "returning to SDK provider") return ` [${SDK_MIGRATION_TOKEN}]`;
64794
64871
  return ` [via CC API: ${props.join(", ")}]`;
64795
64872
  };
64796
64873
  for (const [logicalId, change] of changes.entries()) switch (change.changeType) {
@@ -65301,7 +65378,7 @@ async function scrubCommand(stacks, options) {
65301
65378
  if (options.fail) throw new ScrubNeededError();
65302
65379
  return;
65303
65380
  }
65304
- if (totalStacksScrubbed > 0) logger.info(`\nDone: scrubbed ${totalStacksScrubbed} stack(s). The plaintext is no longer stored there, but a value that was ever persisted should be treated as compromised — ROTATE it in Secrets Manager (scrub matches the current value, so scrub BEFORE rotating).${keyNote}${unverifiableNote}${failureNote}`);
65381
+ if (totalStacksScrubbed > 0) logger.info(`\nDone: scrubbed ${totalStacksScrubbed} stack(s). The CURRENT state.json no longer holds the plaintext. Where the state bucket is VERSIONED — which cdkd bootstrap enables — the pre-scrub body survives as a noncurrent version, stays readable with GetObject and a VersionId, and scrub does not purge it. So a value that was ever persisted must be treated as compromised — ROTATE it in Secrets Manager (scrub matches the current value, so scrub BEFORE rotating); rotation is what makes any surviving version harmless.${keyNote}${unverifiableNote}${failureNote}`);
65305
65382
  else logger.info(`\nNothing could be rewritten.${keyNote}${unverifiableNote}${failureNote} ROTATE any exposed secret.`);
65306
65383
  if (failures.length > 0) throw scrubStacksFailedError(failures);
65307
65384
  if (options.fail && (totalStacksWithUnscrubbableKeys > 0 || totalStacksWithUnverifiableReads > 0)) throw new ScrubNeededError();
@@ -66501,6 +66578,108 @@ function isRegionAmbiguousRefusal(err) {
66501
66578
  return err instanceof Error && errorCauseChain(err).some((link) => link instanceof DynamicReferenceRegionAmbiguousError);
66502
66579
  }
66503
66580
  /**
66581
+ * A secrets map with its OWN identity whose entries ARE `target`'s (issue
66582
+ * #2531): every read and write goes to `target`; nothing is stored here.
66583
+ *
66584
+ * The outputs pass resolves each `Export.Name` through one of these rather
66585
+ * than through the pass map itself. The resolver records the uncollapsed
66586
+ * `expression -> plaintext` PAIR beside each entry, keyed by the map INSTANCE
66587
+ * (`recordResolvedPair`), and a second write of one expression with a
66588
+ * different plaintext marks that pair conflicting — so a name's resolution
66589
+ * recording straight into the pass map could conflict the pair a literal
66590
+ * Output embedding the same token needs. Pairs recorded through this view
66591
+ * hang off THIS instance and are discarded with it (a name never positions a
66592
+ * leaf); the entries are the pass map's, live, in both directions.
66593
+ *
66594
+ * Live sharing rather than a private map with a copy: the resolver records
66595
+ * as it goes and `Fn::Join` resolves its parts concurrently, so a part that
66596
+ * rejects while a secret part is still pending ends the name's block before
66597
+ * that part records — a copy at the end of the block misses the late entry,
66598
+ * and a private map SEEDED from the pass map is a snapshot that a LATER
66599
+ * name's resolution would not see the late entry through either. Every
66600
+ * reader in the block (the cross-region pin, the cross-stack pre-pass, the
66601
+ * resolver, the masking of their messages) needs exactly what the pass map
66602
+ * holds at the moment it reads, which is what a view gives. (The deploy
66603
+ * engine's sibling block keeps its end-of-block copy and so still has the
66604
+ * late-entry hole — issue #2563.)
66605
+ *
66606
+ * TWO RESIDUALS of discarding what the view recorded, both bounded to the
66607
+ * choice of EXPRESSION and neither reaching a plaintext:
66608
+ *
66609
+ * - The pairs. A value leaf re-using the token records its own pair at the
66610
+ * seam, which is what positions it — PROVIDED its own resolution succeeds.
66611
+ * A value whose read through the PRIMARY resolver fails after the name's
66612
+ * succeeded (that call sits in the value loop's best-effort catch) has no
66613
+ * pair of its own and no name-recorded one to fall back on, so its leaf
66614
+ * drops to the value scan (the #2485 shape, a sibling's spelling for a
66615
+ * same-plaintext pair); the entry the name recorded is still a needle, so
66616
+ * the plaintext is scrubbed either way — whole, at any length, when the
66617
+ * leaf IS the plaintext (`redactSecretsForState`'s whole-value arm is
66618
+ * unbounded and this pass hands it the raw pass map), and by the substring
66619
+ * arm at `MIN_NEEDLE_LENGTH` or longer when the plaintext is EMBEDDED in a
66620
+ * larger leaf; a shorter embedded one is the #2516 residual, which has
66621
+ * nothing to do with the name loop. A failed read through the cross-region PIN
66622
+ * is not a fallback at all: `unresolvableForeignScrubSecretError` refuses
66623
+ * the whole scrub (`SCRUB_CROSS_REGION_SECRET_UNRESOLVED`), name loop or
66624
+ * value loop.
66625
+ * - The cross-stack associations the pre-pass records
66626
+ * (`recordCrossStackExpression`), keyed by instance the same way. They are
66627
+ * recorded only for a plaintext ALREADY in the map (the resolver gates on
66628
+ * `recordedSecretValues.has(...)`), so losing one degrades the expression a
66629
+ * later leaf is positioned by, never whether the plaintext is found. No
66630
+ * case pins this arm on its own.
66631
+ *
66632
+ * Every member of `Map.prototype` that reads or writes entries is overridden,
66633
+ * and a unit fence (`scrub-shared-entries-secrets.test.ts`) asserts that set
66634
+ * against the runtime's `Map.prototype` so a future addition (the stage-3
66635
+ * `getOrInsert` / `getOrInsertComputed`) cannot land on the permanently empty
66636
+ * backing store unnoticed — a needle inserted there would never reach the
66637
+ * pass map. `structuredClone` reads the internal slots the same way and is
66638
+ * equally unreachable from this file; a clone of a view would be an empty
66639
+ * `Map`.
66640
+ */
66641
+ var SharedEntriesSecrets = class extends Map {
66642
+ target;
66643
+ constructor(target) {
66644
+ super();
66645
+ this.target = target;
66646
+ }
66647
+ get size() {
66648
+ return this.target.size;
66649
+ }
66650
+ get(plaintext) {
66651
+ return this.target.get(plaintext);
66652
+ }
66653
+ has(plaintext) {
66654
+ return this.target.has(plaintext);
66655
+ }
66656
+ set(plaintext, expression) {
66657
+ this.target.set(plaintext, expression);
66658
+ return this;
66659
+ }
66660
+ delete(plaintext) {
66661
+ return this.target.delete(plaintext);
66662
+ }
66663
+ clear() {
66664
+ this.target.clear();
66665
+ }
66666
+ keys() {
66667
+ return this.target.keys();
66668
+ }
66669
+ values() {
66670
+ return this.target.values();
66671
+ }
66672
+ entries() {
66673
+ return this.target.entries();
66674
+ }
66675
+ forEach(callback, thisArg) {
66676
+ this.target.forEach((value, key) => callback.call(thisArg, value, key, this));
66677
+ }
66678
+ [Symbol.iterator]() {
66679
+ return this.target[Symbol.iterator]();
66680
+ }
66681
+ };
66682
+ /**
66504
66683
  * Build the CROSS-STACK pre-pass for ONE stack's scrub (issue
66505
66684
  * [#2133](https://github.com/go-to-k/cdkd/issues/2133)).
66506
66685
  *
@@ -66898,21 +67077,28 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
66898
67077
  const declaredExportName = output.Export?.Name;
66899
67078
  let exportName = declaredExportName;
66900
67079
  if (declaredExportName !== void 0 && typeof declaredExportName !== "string") {
67080
+ const nameSecrets = new SharedEntriesSecrets(outputSecrets);
67081
+ let nameFailed = false;
67082
+ let nameError;
66901
67083
  const nameSource = await pinCrossRegionSecrets(declaredExportName, stack.stackName, {
66902
67084
  stackRegion: region,
66903
67085
  producerRegions,
66904
67086
  resolvers,
66905
- recordedSecretValues: outputSecrets,
67087
+ recordedSecretValues: nameSecrets,
66906
67088
  origin: `Export.Name of output '${name}'`
66907
67089
  });
66908
- const nameContext = resolverContext(outputSecrets);
67090
+ const nameContext = resolverContext(nameSecrets);
66909
67091
  await resolveCrossStackReads(nameSource, nameContext, `Export.Name of output '${name}'`, { canRefuse: !isOutputSuppressed(name, output, conditions, state.outputs ?? {}) });
66910
67092
  try {
66911
67093
  exportName = await resolver.resolve(nameSource, nameContext);
66912
67094
  } catch (err) {
66913
67095
  if (isRegionAmbiguousRefusal(err)) throw err;
67096
+ nameFailed = true;
67097
+ nameError = err;
67098
+ }
67099
+ if (nameFailed) {
66914
67100
  outputsSourceUntrusted = true;
66915
- logger.warn(`Export.Name of output ${name} could not be resolved during scrub (${maskSecretsInText(err instanceof Error ? err.message : String(err), outputSecrets)}) — redacting this stack's outputs by value match instead of by template position, since state may be keyed under a name this run cannot reproduce.`);
67101
+ logger.warn(`Export.Name of output ${name} could not be resolved during scrub (${maskSecretsInText(nameError instanceof Error ? nameError.message : String(nameError), outputSecrets)}) — redacting this stack's outputs by value match instead of by template position, since state may be keyed under a name this run cannot reproduce.`);
66916
67102
  }
66917
67103
  }
66918
67104
  if (declaredExportName !== void 0 && typeof exportName !== "string") outputsSourceUntrusted = true;
@@ -67962,9 +68148,10 @@ var DriftDetectedError = class DriftDetectedError extends CdkdError {
67962
68148
  * `notCompared`. The roll-up also contains resources whose only problem is a
67963
68149
  * surviving `{{resolve:...}}` token cdkd resolves for nobody — a look-alike
67964
68150
  * spelling, since issue #2482 moved `ssm-secure` onto the `ssm` path — which
67965
- * can never clear on a re-run, and which this change did not create. Exiting non-zero for them would break `cdkd drift` in CI forever over
67966
- * an unrelated defect, so the exit reads `refused` and `readFailed` while the
67967
- * report covers all three. See `NotComparedCause` and `outcomeExitSignal`.
68151
+ * can never clear on a re-run, and which this change did not create. Exiting
68152
+ * non-zero for them would break `cdkd drift` in CI forever over an unrelated
68153
+ * defect, so the exit reads `refused` and `readFailed` while the report covers
68154
+ * all three. See `NotComparedCause` and `outcomeExitSignal`.
67968
68155
  *
67969
68156
  * WHY A NON-ZERO EXIT, and why it is a PRESERVATION rather than a new signal.
67970
68157
  * Pre-#2108 that population resolved the reference in the WRONG region, the
@@ -68075,7 +68262,8 @@ function notComparedOutcomes(report) {
68075
68262
  * code covers only the CLEARABLE ones — `refused` (the population #2108 created)
68076
68263
  * and `readFailed` (issues #2151 / #1945). Exiting non-zero on
68077
68264
  * `unresolvedToken` would break `cdkd drift` in CI forever for every stack
68078
- * holding such a spelling — permanent, unclearable, and unrelated. See {@link NotComparedCause}.
68265
+ * holding such a spelling — permanent, unclearable, and unrelated. See
68266
+ * {@link NotComparedCause}.
68079
68267
  *
68080
68268
  * A `drifted` outcome reports `drifted` even when its own comparison was
68081
68269
  * refused: drift is the stronger, actionable signal and the caller ranks it
@@ -68211,10 +68399,10 @@ function uncomparedTally(reports) {
68211
68399
  *
68212
68400
  * - The TRIGGER is `anyIncomplete`, i.e. {@link outcomeExitSignal}'s
68213
68401
  * `incomplete`. It is narrow on purpose: a stack whose ONLY uncompared
68214
- * resource holds a `{{resolve:...}}` token cdkd resolves for nobody must not start
68215
- * shouting on every run about a comparison no action of the user's can ever
68216
- * complete -- the same CI-forever hazard that cause is kept out of the exit
68217
- * code on.
68402
+ * resource holds a `{{resolve:...}}` token cdkd resolves for nobody must
68403
+ * not start shouting on every run about a comparison no action of the
68404
+ * user's can ever complete -- the same CI-forever hazard that cause is
68405
+ * kept out of the exit code on.
68218
68406
  * - The COUNT and the LABELS, once the line is triggered, cover EVERY
68219
68407
  * uncompared resource, each named by its own reason (see
68220
68408
  * {@link uncomparedTally}). A resource that was not compared was not
@@ -70490,9 +70678,19 @@ function orderConsumersBeforeProducers(stackNames, consumerToProducers) {
70490
70678
  *
70491
70679
  * Purges the stack's deployment-event history (the post-mortem `deployments/`
70492
70680
  * store cdkd keeps by default) ONLY after a clean, non-interrupted destroy, so
70493
- * the state bucket returns fully empty. Deliberately skipped on a failed /
70494
- * interrupted destroy — those events ARE the post-mortem the user wants on the
70495
- * retry — and a no-op when `--purge-events` was not passed.
70681
+ * a plain object listing of the state bucket comes back empty. Deliberately
70682
+ * skipped on a failed / interrupted destroy — those events ARE the post-mortem
70683
+ * the user wants on the retry — and a no-op when `--purge-events` was not
70684
+ * passed.
70685
+ *
70686
+ * "Empty" is bounded, and this used to say "the state bucket returns fully
70687
+ * empty" without the bound (issue
70688
+ * [#2624](https://github.com/go-to-k/cdkd/issues/2624)). The purge routes
70689
+ * through `DeploymentEventsReader.pruneRuns` -> `deleteRawObjects`, which sends
70690
+ * `DeleteObjects` with no `VersionId`: on the versioned state bucket that
70691
+ * writes DELETE MARKERS and leaves every earlier version of those keys readable
70692
+ * with a `VersionId`. The log line below states that rather than claiming a
70693
+ * removal that did not happen.
70496
70694
  *
70497
70695
  * MUST be called AFTER the run's `eventRecorder.finalize()` (which writes this
70498
70696
  * run's own events + index): purging first would just be re-created by the
@@ -70509,7 +70707,7 @@ async function purgeEventsAfterDestroy(reader, stackName, region, opts, logger)
70509
70707
  if (opts.purgeEvents !== true || opts.runResult !== "SUCCEEDED" || opts.interrupted) return null;
70510
70708
  try {
70511
70709
  const purge = await reader.pruneRuns(stackName, region, { all: true });
70512
- if (purge.deletedRunIds.length > 0 || purge.indexDeleted) logger.info(` Purged deployment-event history for ${stackName} (${region}).`);
70710
+ if (purge.deletedRunIds.length > 0 || purge.indexDeleted) logger.info(` Purged deployment-event history for ${stackName} (${region}). Where the state bucket is versioned — which cdkd bootstrap enables — earlier versions of those keys survive and stay readable with GetObject and a VersionId.`);
70513
70711
  return purge;
70514
70712
  } catch (purgeError) {
70515
70713
  logger.warn(` Failed to purge deployment-event history for ${stackName}: ${purgeError instanceof Error ? purgeError.message : String(purgeError)}`);
@@ -70793,7 +70991,7 @@ async function destroyCommand(stackArgs, options) {
70793
70991
  * Create destroy command
70794
70992
  */
70795
70993
  function createDestroyCommand() {
70796
- const cmd = new Command("destroy").description("Destroy all resources in the stack").argument("[stacks...]", "Stack name(s) to destroy. Accepts physical CloudFormation names (e.g. 'MyStage-Api') or CDK display paths (e.g. 'MyStage/Api'). Supports wildcards (e.g. 'MyStage/*').").option("--all", "Destroy all stacks", false).option("--purge-events", "After a clean destroy, also delete the stack's deployment-event history (issue #808 store) so the state bucket returns fully empty. By default events survive destroy as post-mortem context. Skipped when the destroy fails or is interrupted (those events aid the retry). Equivalent for an already-destroyed stack: 'cdkd events prune <stack> --all'.", false).action(withErrorHandling(destroyCommand));
70994
+ const cmd = new Command("destroy").description("Destroy all resources in the stack").argument("[stacks...]", "Stack name(s) to destroy. Accepts physical CloudFormation names (e.g. 'MyStage-Api') or CDK display paths (e.g. 'MyStage/Api'). Supports wildcards (e.g. 'MyStage/*').").option("--all", "Destroy all stacks", false).option("--purge-events", "After a clean destroy, also delete the stack's deployment-event history (issue #808 store) so an object listing of the state bucket comes back empty. Where the state bucket is versioned — which cdkd bootstrap enables — earlier versions of those keys survive and stay readable with GetObject and a VersionId. By default events survive destroy as post-mortem context. Skipped when the destroy fails or is interrupted (those events aid the retry). Equivalent for an already-destroyed stack: 'cdkd events prune <stack> --all'.", false).action(withErrorHandling(destroyCommand));
70797
70995
  [
70798
70996
  ...commonOptions,
70799
70997
  ...appOptions,
@@ -76201,8 +76399,8 @@ function actionLabel(item, skipFinalSnapshot) {
76201
76399
  case "orphan-retain": return ` - orphan ${op.logicalId} (${op.resourceType}) [DeletionPolicy Retain — left in AWS]`;
76202
76400
  case "orphan-flag": return ` - orphan ${op.logicalId} (${op.resourceType}) [--orphan]`;
76203
76401
  case "revert": return ` - revert ${op.logicalId} (${op.resourceType})${rep}`;
76204
- case "reverse-replacement": return ` - reverse-replace ${op.logicalId} (${op.resourceType}) [re-create old resource, delete new]`;
76205
- case "reverse-replacement-readopt": return ` - reverse-replace ${op.logicalId} (${op.resourceType}) [delete new, re-adopt retained old resource]`;
76402
+ case "reverse-replacement": return item.retainsNewResource ? ` - reverse-replace ${op.logicalId} (${op.resourceType}) [re-create old resource; new one RETAINED (UpdateReplacePolicy: Retain) and left untracked — REFUSED instead if the re-create collides with the name the retained resource still holds]` : ` - reverse-replace ${op.logicalId} (${op.resourceType}) [re-create old resource, delete new]`;
76403
+ case "reverse-replacement-readopt": return item.retainsNewResource ? ` - reverse-replace ${op.logicalId} (${op.resourceType}) [re-adopt retained old resource; new one RETAINED (UpdateReplacePolicy: Retain) and left untracked]` : ` - reverse-replace ${op.logicalId} (${op.resourceType}) [delete new, re-adopt retained old resource]`;
76206
76404
  case "unrecoverable-delete": return ` - (cannot restore) ${op.logicalId} (${op.resourceType}) — was DELETED, unrecoverable`;
76207
76405
  case "skip-mismatch": return ` - skip ${op.logicalId} (${op.resourceType}) — physical id changed, needs manual attention`;
76208
76406
  case "skip-absent": return ` - skip ${op.logicalId} (${op.resourceType}) — no longer in state`;
@@ -76538,6 +76736,73 @@ function createRollbackCommand() {
76538
76736
  //#endregion
76539
76737
  //#region src/cli/commands/events.ts
76540
76738
  /**
76739
+ * Issue [#2438](https://github.com/go-to-k/cdkd/issues/2438): every string
76740
+ * `cdkd events` renders on its HUMAN path comes back out of a durable store
76741
+ * (`deployments/{runId}.jsonl` and `deployments/index.json`), and several of
76742
+ * those fields are provider- or template-authored — a logical id, a resource
76743
+ * type, a `reason` that interpolates a user-chosen physical name, an AWS error
76744
+ * message. `JSON.stringify` escapes control bytes on the way IN, so the store
76745
+ * is well-formed, but the reader is a plain `JSON.parse` that restores the
76746
+ * original bytes verbatim. Without a sanitising pass here an ESC-bracket
76747
+ * sequence stored in any of them re-forges the terminal of the one command
76748
+ * whose entire purpose is to be BELIEVED after a run.
76749
+ *
76750
+ * Three helpers rather than one, because the field classes have different
76751
+ * correct answers:
76752
+ *
76753
+ * - {@link safeId} is for values with a KNOWN ASCII charset (a run id, a
76754
+ * logical id, a CFn resource type, a region, an event type, a version
76755
+ * string). `asciiOnly` is a positive allowlist and therefore has no residual
76756
+ * at all — the right fence when nothing legitimate is lost.
76757
+ * - {@link safeText} is for free PROSE (`reason`, `error.name`,
76758
+ * `error.message`). A legitimate AWS error message may carry non-ASCII, and
76759
+ * `asciiOnly` would mangle it — so prose gets the control-character denylist,
76760
+ * accepting `display-safe.ts`'s documented residual.
76761
+ *
76762
+ * The wrap goes around the VALUE, never around the colorized token: sanitising
76763
+ * `red(x)` would strip cdkd's OWN ANSI and leave the row uncolored.
76764
+ */
76765
+ const safeId = (value) => displaySafe(value, { asciiOnly: true });
76766
+ /** Free-prose counterpart of {@link safeId}. See that helper's block comment. */
76767
+ const safeText = (value) => displaySafe(value);
76768
+ /**
76769
+ * Numeric counterpart of {@link safeId}, for the stored COUNTER fields.
76770
+ *
76771
+ * Their declared `number` type is the WRITER's guarantee; the value rendered
76772
+ * here came back through `JSON.parse` and can be any JSON scalar. Sanitising
76773
+ * would be the wrong tool — it would still render the forged text, merely
76774
+ * stripped — so a value that is not a number is not renderable as one and
76775
+ * becomes `?`. Mirrors the pre-existing `typeof e.durationMs === 'number'`
76776
+ * guard, which is why that one line needed no change.
76777
+ */
76778
+ const safeCount = (value) => typeof value === "number" ? String(value) : "?";
76779
+ /**
76780
+ * Appended to every `cdkd events prune` line that reports a DELETE, because
76781
+ * "pruned" and "removed" would otherwise read as removal and are not (issue
76782
+ * [#2624](https://github.com/go-to-k/cdkd/issues/2624)).
76783
+ *
76784
+ * The pruner reaches S3 through `S3StateBackend.deleteRawObjects`, which sends
76785
+ * `DeleteObjects` with no `VersionId`. On the versioned state bucket
76786
+ * `cdkd bootstrap` creates, that writes a DELETE MARKER and leaves every
76787
+ * earlier version of the key readable through `GetObject` with a `VersionId`.
76788
+ * And the versions exist in QUANTITY here rather than as a theoretical
76789
+ * remainder: the store re-PUTs a run's whole JSONL body on every flush, so a
76790
+ * single run accumulates one noncurrent version per flush before it is ever
76791
+ * pruned. `docs/deployment-events.md` classes `deployments/*.jsonl` as
76792
+ * sensitive, which is what makes the distinction worth a line of output.
76793
+ *
76794
+ * Deliberately NOT appended to the "no runs matched" arm: that arm returns
76795
+ * before any `deleteRawObjects` call, so there is no delete to qualify.
76796
+ *
76797
+ * The converse does NOT hold, and the gap is tracked on issue #2624 rather than
76798
+ * closed here: `pruneRuns({ all: true })` reports `indexDeleted: true`
76799
+ * unconditionally (its `DeleteObjects` is idempotent, so it "succeeds" on an
76800
+ * empty prefix), so the arms this note IS appended to can fire for a stack that
76801
+ * had no history at all. Making that gate truthful is a behaviour change, not a
76802
+ * wording one.
76803
+ */
76804
+ const NONCURRENT_VERSIONS_SURVIVE_NOTE = " Where the state bucket is versioned — which cdkd bootstrap enables — earlier versions of the deleted keys survive and stay readable with GetObject and a VersionId; prune does not purge them.";
76805
+ /**
76541
76806
  * `cdkd events <stack>` — read back the structured deployment events
76542
76807
  * (issue #808) cdkd persists per deploy / destroy run, the local
76543
76808
  * equivalent of CloudFormation's `DescribeStackEvents`.
@@ -76580,7 +76845,7 @@ async function eventsCommand(stackName, options) {
76580
76845
  const targetRegion = await resolveEventsRegion(reader, stackName, options.stackRegion);
76581
76846
  if (options.run) {
76582
76847
  const events = await reader.readRunEvents(stackName, targetRegion, options.run);
76583
- if (events === null) throw new CdkdError(`No deployment-event stream found for run '${options.run}' of stack '${stackName}' in region '${targetRegion}'.`, "EVENTS_RUN_NOT_FOUND");
76848
+ if (events === null) throw new CdkdError(`No deployment-event stream found for run '${safeId(options.run) || "<unrenderable>"}' of stack '${safeId(stackName) || "<unrenderable>"}' in region '${safeId(targetRegion) || "<unrenderable>"}'.`, "EVENTS_RUN_NOT_FOUND");
76584
76849
  if (asJson) {
76585
76850
  process.stdout.write(JSON.stringify(events, null, 2) + "\n");
76586
76851
  return;
@@ -76611,16 +76876,22 @@ async function eventsCommand(stackName, options) {
76611
76876
  async function resolveEventsRegion(reader, stackName, explicitRegion) {
76612
76877
  if (explicitRegion) return explicitRegion;
76613
76878
  const regions = await reader.listRegions(stackName);
76614
- if (regions.length === 0) throw new CdkdError(`No deployment-event history found for stack '${stackName}'. Events are recorded by 'cdkd deploy' / 'cdkd destroy' (issue #808); a stack deployed by an older cdkd version has none.`, "EVENTS_NOT_FOUND");
76615
- if (regions.length > 1) throw new CdkdError(`Stack '${stackName}' has deployment-event history in multiple regions: ${regions.join(", ")}. Re-run with '--stack-region <region>' to disambiguate.`, "EVENTS_REGION_AMBIGUOUS");
76879
+ if (regions.length === 0) throw new CdkdError(`No deployment-event history found for stack '${safeId(stackName) || "<unrenderable>"}'. Events are recorded by 'cdkd deploy' / 'cdkd destroy' (issue #808); a stack deployed by an older cdkd version has none.`, "EVENTS_NOT_FOUND");
76880
+ if (regions.length > 1) throw new CdkdError(`Stack '${safeId(stackName) || "<unrenderable>"}' has deployment-event history in multiple regions: ${regions.map((r) => safeId(r) || "<unrenderable>").join(", ")}. Re-run with '--stack-region <region>' to disambiguate.`, "EVENTS_REGION_AMBIGUOUS");
76616
76881
  return regions[0];
76617
76882
  }
76618
76883
  /**
76619
- * `cdkd events prune <stack>` — reclaim S3 space by deleting old per-run
76620
- * `{runId}.jsonl` event streams (issue #885). `cdkd destroy` deliberately
76621
- * keeps event history as post-mortem context, so this is the explicit way
76622
- * to purge it; the deploy/destroy writer also self-bounds to the last
76623
- * {@link DEPLOYMENT_EVENTS_MAX_INDEX_RUNS} runs automatically.
76884
+ * `cdkd events prune <stack>` — clear old per-run `{runId}.jsonl` event
76885
+ * streams out of the bucket's object LISTING (issue #885). `cdkd destroy`
76886
+ * deliberately keeps event history as post-mortem context, so this is the
76887
+ * explicit way to purge it; the deploy/destroy writer also self-bounds to the
76888
+ * last {@link DEPLOYMENT_EVENTS_MAX_INDEX_RUNS} runs automatically.
76889
+ *
76890
+ * It does NOT reclaim the storage, and the wording above said it did until
76891
+ * issue [#2624](https://github.com/go-to-k/cdkd/issues/2624): the delete
76892
+ * carries no `VersionId`, so on a versioned state bucket every earlier
76893
+ * version of a pruned key is still billed and still readable. See
76894
+ * {@link NONCURRENT_VERSIONS_SURVIVE_NOTE}.
76624
76895
  *
76625
76896
  * Retention selection:
76626
76897
  * - `--all` purge every run + the index.
@@ -76657,10 +76928,12 @@ async function eventsPruneCommand(stackName, options) {
76657
76928
  await stateBackend.verifyBucketExists();
76658
76929
  const reader = new DeploymentEventsReader(stateBackend);
76659
76930
  const targetRegion = await resolveEventsRegion(reader, stackName, options.stackRegion);
76931
+ const safeStack = safeId(stackName) || "<unrenderable>";
76932
+ const safeRegion = safeId(targetRegion) || "<unrenderable>";
76660
76933
  const totalRuns = (await reader.listRuns(stackName, targetRegion)).length;
76661
- const scope = options.all ? `ALL ${totalRuns} run(s)` : options.keep !== void 0 && olderThanMs !== void 0 ? `runs beyond the newest ${options.keep} AND older than ${options.olderThan}` : options.keep !== void 0 ? `runs beyond the newest ${options.keep}` : olderThanMs !== void 0 ? `runs older than ${options.olderThan}` : `runs beyond the newest ${20}`;
76934
+ const scope = options.all ? `ALL ${totalRuns} run(s)` : options.keep !== void 0 && olderThanMs !== void 0 ? `runs beyond the newest ${options.keep} AND older than ${safeId(options.olderThan)}` : options.keep !== void 0 ? `runs beyond the newest ${options.keep}` : olderThanMs !== void 0 ? `runs older than ${safeId(options.olderThan)}` : `runs beyond the newest ${20}`;
76662
76935
  if (options.yes !== true) {
76663
- if (!await confirmPrompt$2(`Prune deployment-event history for ${cyan(stackName)} ${gray(`(${targetRegion})`)}: ${scope}?`)) {
76936
+ if (!await confirmPrompt$2(`Prune deployment-event history for ${cyan(safeStack)} ${gray(`(${safeRegion})`)}: ${scope}?`)) {
76664
76937
  logger.info(gray("Aborted; nothing was deleted."));
76665
76938
  return;
76666
76939
  }
@@ -76671,10 +76944,10 @@ async function eventsPruneCommand(stackName, options) {
76671
76944
  ...olderThanMs !== void 0 && { olderThanMs }
76672
76945
  });
76673
76946
  if (result.deletedRunIds.length === 0) {
76674
- logger.info(gray(result.indexDeleted ? `Removed the empty deployment-event index for ${stackName} (${targetRegion}); no run streams to delete.` : `No runs matched the prune criteria for ${stackName} (${targetRegion}).`));
76947
+ logger.info(gray(result.indexDeleted ? `Removed the empty deployment-event index for ${safeStack} (${safeRegion}); no run streams to delete. Where the state bucket is versioned — which cdkd bootstrap enables — earlier versions of the deleted keys survive and stay readable with GetObject and a VersionId; prune does not purge them.` : `No runs matched the prune criteria for ${safeStack} (${safeRegion}).`));
76675
76948
  return;
76676
76949
  }
76677
- logger.info(`${green("Pruned")} ${result.deletedRunIds.length} deployment-event run(s) for ${cyan(stackName)} ${gray(`(${targetRegion})`)}; ${result.remainingRunIds.length} retained` + (result.indexDeleted ? gray(" (index removed)") : "") + ".");
76950
+ logger.info(`${green("Pruned")} ${result.deletedRunIds.length} deployment-event run(s) for ${cyan(safeStack)} ${gray(`(${safeRegion})`)}; ${result.remainingRunIds.length} retained` + (result.indexDeleted ? gray(" (index removed)") : "") + "." + gray(NONCURRENT_VERSIONS_SURVIVE_NOTE));
76678
76951
  } finally {
76679
76952
  awsClients.destroy();
76680
76953
  }
@@ -76690,19 +76963,29 @@ async function eventsPruneCommand(stackName, options) {
76690
76963
  async function confirmPrompt$2(prompt) {
76691
76964
  return confirmOrRefuse(prompt, { refusal: "The cdkd events prune confirmation prompt cannot run in a non-interactive environment. Pass -y / --yes to confirm the prune, or run the command from a real terminal. Nothing has been deleted." });
76692
76965
  }
76693
- /** Human-readable run listing (newest first). */
76966
+ /**
76967
+ * Human-readable run listing (newest first).
76968
+ *
76969
+ * Issue #2438: every `run.*` field here comes back out of
76970
+ * `deployments/index.json` via a plain `JSON.parse`, so its declared type is a
76971
+ * claim about the writer, not about the bytes on disk — each one goes through
76972
+ * {@link safeId}. `result` is sanitised BEFORE the colour decision so the
76973
+ * colour matches the text the user actually sees.
76974
+ */
76694
76975
  function printRunList(stackName, region, runs) {
76695
76976
  const logger = getLogger();
76696
- logger.info(`${bold("Deployment runs for")} ${cyan(stackName)} ${gray(`(${region})`)}`);
76977
+ const safeStack = safeId(stackName) || "<unrenderable>";
76978
+ logger.info(`${bold("Deployment runs for")} ${cyan(safeStack)} ${gray(`(${safeId(region) || "<unrenderable>"})`)}`);
76697
76979
  if (runs.length === 0) {
76698
76980
  logger.info(gray(" (no runs recorded)"));
76699
76981
  return;
76700
76982
  }
76701
76983
  for (const run of runs) {
76702
- const resultColored = run.result === "SUCCEEDED" ? green(run.result) : run.result === "UNKNOWN" ? gray(run.result) : red(run.result);
76703
- logger.info(` ${cyan(run.runId)} ${run.command} ${resultColored} ${gray(run.startedAt || "?")} -> ${gray(run.finishedAt || "?")} ${gray(`cdkd ${run.cdkdVersion}`)} ${gray(`${run.eventCount} events`)}`);
76984
+ const result = safeId(run.result) || "<unrenderable>";
76985
+ const resultColored = result === "SUCCEEDED" ? green(result) : result === "UNKNOWN" || result === "<unrenderable>" ? gray(result) : red(result);
76986
+ logger.info(` ${cyan(safeId(run.runId) || "<unrenderable>")} ${safeId(run.command) || "<unrenderable>"} ${resultColored} ${gray(safeId(run.startedAt) || "?")} -> ${gray(safeId(run.finishedAt) || "?")} ${gray(`cdkd ${safeId(run.cdkdVersion) || "<unrenderable>"}`)} ${gray(`${safeCount(run.eventCount)} events`)}`);
76704
76987
  }
76705
- logger.info(gray(`\nUse 'cdkd events ${stackName} --run <runId>' to read one run's events.`));
76988
+ logger.info(gray(`\nUse 'cdkd events ${safeStack} --run <runId>' to read one run's events.`));
76706
76989
  }
76707
76990
  /** Human-readable single-run event stream (in recorded order). */
76708
76991
  /**
@@ -76712,29 +76995,38 @@ function printRunList(stackName, region, runs) {
76712
76995
  */
76713
76996
  function printRunEvents(stackName, region, runId, events) {
76714
76997
  const logger = getLogger();
76715
- logger.info(`${bold("Events for run")} ${cyan(runId)} ${gray(`(${stackName}, ${region})`)}`);
76998
+ logger.info(`${bold("Events for run")} ${cyan(safeId(runId) || "<unrenderable>")} ${gray(`(${safeId(stackName) || "<unrenderable>"}, ${safeId(region) || "<unrenderable>"})`)}`);
76716
76999
  if (events.length === 0) {
76717
77000
  logger.info(gray(" (no events)"));
76718
77001
  return;
76719
77002
  }
76720
77003
  for (const e of events) {
76721
- const parts = [gray(e.timestamp), colorizeEventType(e.eventType)];
76722
- if (e.logicalId) parts.push(`${e.logicalId}${e.resourceType ? ` (${e.resourceType})` : ""}`);
76723
- if (e.operation) parts.push(gray(e.operation));
76724
- if (e.guard) parts.push(gray(`guard=${e.guard}`));
76725
- if (e.provisionedBy) parts.push(gray(`[${e.provisionedBy}]`));
76726
- if (e.command) parts.push(gray(e.command));
76727
- if (e.region) parts.push(gray(e.region));
76728
- if (e.cdkdVersion) parts.push(gray(`cdkd ${e.cdkdVersion}`));
76729
- if (e.result) parts.push(e.result === "SUCCEEDED" ? green(e.result) : red(e.result));
77004
+ const parts = [gray(safeId(e.timestamp) || "<unrenderable>"), colorizeEventType(e.eventType)];
77005
+ if (e.logicalId) {
77006
+ const logicalId = safeId(e.logicalId) || "<unrenderable>";
77007
+ const resourceType = e.resourceType ? ` (${safeId(e.resourceType) || "<unrenderable>"})` : "";
77008
+ parts.push(`${logicalId}${resourceType}`);
77009
+ }
77010
+ if (e.operation) parts.push(gray(safeId(e.operation) || "<unrenderable>"));
77011
+ if (e.guard) parts.push(gray(`guard=${safeId(e.guard) || "<unrenderable>"}`));
77012
+ if (e.provisionedBy) parts.push(gray(`[${safeId(e.provisionedBy) || "<unrenderable>"}]`));
77013
+ if (e.command) parts.push(gray(safeId(e.command) || "<unrenderable>"));
77014
+ if (e.region) parts.push(gray(safeId(e.region) || "<unrenderable>"));
77015
+ if (e.cdkdVersion) parts.push(gray(`cdkd ${safeId(e.cdkdVersion) || "<unrenderable>"}`));
77016
+ if (e.result) {
77017
+ const result = safeId(e.result) || "<unrenderable>";
77018
+ parts.push(result === "SUCCEEDED" ? green(result) : result === "<unrenderable>" ? gray(result) : red(result));
77019
+ }
76730
77020
  if (typeof e.durationMs === "number") parts.push(gray(`${e.durationMs}ms`));
76731
- if (e.counts) parts.push(gray(`+${e.counts.created}/~${e.counts.updated}/-${e.counts.deleted}` + (e.counts.failed ? ` !${e.counts.failed}` : "") + (e.counts.skipped ? ` ⚠${e.counts.skipped}` : "")));
77021
+ if (e.counts) parts.push(gray(`+${safeCount(e.counts.created)}/~${safeCount(e.counts.updated)}/-${safeCount(e.counts.deleted)}` + (e.counts.failed ? ` !${safeCount(e.counts.failed)}` : "") + (e.counts.skipped ? ` ⚠${safeCount(e.counts.skipped)}` : "")));
76732
77022
  logger.info(` ${parts.join(" ")}`);
76733
- if (e.reason) logger.info(` ${yellow(e.reason)}`);
77023
+ if (e.reason) logger.info(` ${yellow(safeText(e.reason) || "<unrenderable>")}`);
76734
77024
  if (e.error) {
76735
- const code = e.error.awsErrorCode ? ` (${e.error.awsErrorCode})` : "";
76736
- const reqId = e.error.requestId ? gray(` requestId=${e.error.requestId}`) : "";
76737
- logger.info(` ${red(`${e.error.name}${code}: ${e.error.message}`)}${reqId}`);
77025
+ const code = e.error.awsErrorCode ? ` (${safeId(e.error.awsErrorCode) || "<unrenderable>"})` : "";
77026
+ const reqId = e.error.requestId ? gray(` requestId=${safeId(e.error.requestId) || "<unrenderable>"}`) : "";
77027
+ const name = safeText(e.error.name) || "<unrenderable>";
77028
+ const message = e.error.message ? `: ${safeText(e.error.message) || "<unrenderable>"}` : "";
77029
+ logger.info(` ${red(`${name}${code}${message}`)}${reqId}`);
76738
77030
  }
76739
77031
  }
76740
77032
  }
@@ -76744,14 +77036,22 @@ function printRunEvents(stackName, region, runId, events) {
76744
77036
  * Exported for unit-test coverage (matching `destroy-runner.ts`'s
76745
77037
  * `PROTECTION_PROPERTY_BY_TYPE` convention) — it is pure, so testing it
76746
77038
  * directly beats driving it through the renderer's log output.
77039
+ *
77040
+ * Issue #2438: the declared union is what the WRITER emits; the value handed
77041
+ * here was restored by `JSON.parse` from the stored stream and is therefore
77042
+ * arbitrary text. It is sanitised once, up front, and every arm below both
77043
+ * classifies AND colorizes the sanitised token — classifying the raw value
77044
+ * while printing the sanitised one would let the two disagree. The token is
77045
+ * a known-ASCII discriminator, so `safeId`'s positive allowlist loses nothing.
76747
77046
  */
76748
77047
  function colorizeEventType(eventType) {
76749
- if (eventType.endsWith("FAILED")) return red(eventType);
76750
- if (eventType.endsWith("SUCCEEDED") || eventType === "RUN_FINISHED") return green(eventType);
76751
- if (eventType === "RESOURCE_SKIPPED") return yellow(eventType);
76752
- if (eventType === "RESOURCE_GUARD_INDETERMINATE") return yellow(eventType);
76753
- if (eventType.startsWith("ROLLBACK")) return yellow(eventType);
76754
- return cyan(eventType);
77048
+ const token = safeId(eventType) || "<unrenderable>";
77049
+ if (token.endsWith("FAILED")) return red(token);
77050
+ if (token.endsWith("SUCCEEDED") || token === "RUN_FINISHED") return green(token);
77051
+ if (token === "RESOURCE_SKIPPED") return yellow(token);
77052
+ if (token === "RESOURCE_GUARD_INDETERMINATE") return yellow(token);
77053
+ if (token.startsWith("ROLLBACK")) return yellow(token);
77054
+ return cyan(token);
76755
77055
  }
76756
77056
  /**
76757
77057
  * Create the `cdkd events` command.
@@ -76780,11 +77080,11 @@ function createEventsCommand() {
76780
77080
  * action therefore reads the merged view via `command.optsWithGlobals()`.
76781
77081
  */
76782
77082
  function createEventsPruneCommand() {
76783
- return new Command("prune").description("Delete old per-run deployment-event streams to reclaim S3 space").argument("<stack>", "Stack name (physical CloudFormation name)").addOption(new Option("--keep <N>", "Retain only the newest N runs").argParser((v) => {
77083
+ return new Command("prune").description("Delete old per-run deployment-event streams. On a versioned state bucket this clears the object listing; earlier versions of the deleted keys survive and are not purged.").argument("<stack>", "Stack name (physical CloudFormation name)").addOption(new Option("--keep <N>", "Retain only the newest N runs").argParser((v) => {
76784
77084
  const n = parseInt(v, 10);
76785
- if (!Number.isInteger(n) || n < 0) throw new Error(`Invalid --keep value "${v}": expected a non-negative integer.`);
77085
+ if (!Number.isInteger(n) || n < 0) throw new Error(`Invalid --keep value "${safeId(v)}": expected a non-negative integer.`);
76786
77086
  return n;
76787
- })).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())));
77087
+ })).option("--older-than <duration>", "Delete runs older than this duration (e.g. 24h, 90m)").option("--all", "Delete every recorded run and the index", false).action(withErrorHandling((stack, _options, command) => eventsPruneCommand(stack, command.optsWithGlobals())));
76788
77088
  }
76789
77089
 
76790
77090
  //#endregion
@@ -82548,10 +82848,11 @@ async function pullEcrImage(imageUri, options) {
82548
82848
  ecr.destroy();
82549
82849
  }
82550
82850
  logger.info(`Pulling ${canonicalUri}...`);
82851
+ const pullArgs = ["pull", canonicalUri];
82551
82852
  try {
82552
- await runDockerForeground(["pull", canonicalUri]);
82853
+ await runDockerForeground(pullArgs);
82553
82854
  } catch (err) {
82554
- throw new LocalInvokeBuildError$1(`docker pull ${canonicalUri} failed: ${err.message}`);
82855
+ throw new LocalInvokeBuildError$1(`docker pull ${canonicalUri} failed: ${describeDockerFailure(err, pullArgs)}`);
82555
82856
  }
82556
82857
  return canonicalUri;
82557
82858
  }
@@ -82600,17 +82901,17 @@ async function ecrLogin(client, accountId, region) {
82600
82901
  const [username, password] = Buffer.from(authData.authorizationToken, "base64").toString().split(":");
82601
82902
  if (!username || password === void 0) throw new LocalInvokeBuildError$1("ECR authorization token has unexpected shape (missing username/password)");
82602
82903
  const endpoint = authData.proxyEndpoint || `https://${accountId}.dkr.ecr.${region}.${ecrUrlSuffix(region)}`;
82904
+ const loginArgs = [
82905
+ "login",
82906
+ "--username",
82907
+ username,
82908
+ "--password-stdin",
82909
+ endpoint
82910
+ ];
82603
82911
  try {
82604
- await runDockerStreaming([
82605
- "login",
82606
- "--username",
82607
- username,
82608
- "--password-stdin",
82609
- endpoint
82610
- ], { input: password });
82912
+ await runDockerStreaming(loginArgs, { input: password });
82611
82913
  } catch (err) {
82612
- const e = err;
82613
- throw new LocalInvokeBuildError$1(`ECR login failed: ${formatDockerLoginError(e.stderr || e.message || String(err), endpoint)}`);
82914
+ throw new LocalInvokeBuildError$1(`ECR login failed: ${formatDockerLoginError(describeDockerFailure(err, loginArgs), endpoint)}`);
82614
82915
  }
82615
82916
  }
82616
82917
  /**
@@ -85982,7 +86283,7 @@ async function prepareOneImage(task, container, options) {
85982
86283
  const actualTag = await buildDockerImage(asset, cdkOutDir, {
85983
86284
  tag,
85984
86285
  ...options.platformOverride !== void 0 && { platform: options.platformOverride },
85985
- wrapError: (stderr) => new LocalInvokeBuildError$1(`docker build failed for ECS container '${container.name}' (${asset.source.directory ?? asset.source.executable?.join(" ")}): ${stderr}`)
86286
+ wrapError: (stderr) => new LocalInvokeBuildError$1(`docker build failed for ECS container '${container.name}' (${asset.source.directory ?? (asset.source.executable ? redactDockerArgvValues(asset.source.executable).join(" ") : void 0)}): ${stderr}`)
85986
86287
  });
85987
86288
  if (actualTag !== tag) {
85988
86289
  const tagArgs = [
@@ -88020,6 +88321,123 @@ function createLocalStartAgentCoreCommand$1() {
88020
88321
  //#endregion
88021
88322
  //#region src/cli/commands/local-start-alb.ts
88022
88323
  /**
88324
+ * Every distinct Lambda logical id a resolved front-door plan forwards to,
88325
+ * sorted so the warning text is deterministic.
88326
+ *
88327
+ * Walks BOTH action slots — a listener's `defaultAction` and each of its
88328
+ * rules' `action` — because either one can carry a `TargetType: lambda`
88329
+ * target group and an ALB that reaches a Lambda only through a
88330
+ * `path-pattern` rule is the common shape (`/api/*` to a Lambda, everything
88331
+ * else to the ECS service). Mirrors cdk-local's own `collectAlbLambdaTargets`
88332
+ * walk, one layer later: this reads the QUALIFIED plan the strategy returns,
88333
+ * where a Lambda target already carries its resolved `lambda.logicalId`.
88334
+ */
88335
+ function collectLambdaTargetLogicalIds(frontDoor) {
88336
+ if (!frontDoor) return [];
88337
+ const ids = /* @__PURE__ */ new Set();
88338
+ const fromAction = (action) => {
88339
+ if (action?.kind !== "forward") return;
88340
+ for (const target of action.targets) if (target.kind === "lambda") ids.add(target.lambda.logicalId);
88341
+ };
88342
+ for (const listener of frontDoor.listeners) {
88343
+ fromAction(listener.defaultAction);
88344
+ for (const rule of listener.rules) fromAction(rule.action);
88345
+ }
88346
+ return [...ids].sort();
88347
+ }
88348
+ /**
88349
+ * Wrap an ALB {@link EmulatorStrategy} so that, under `--from-state`, a plan
88350
+ * carrying a `TargetType: lambda` target group emits a WARNING naming the
88351
+ * affected Lambdas — because the flag does not reach their container
88352
+ * environment.
88353
+ *
88354
+ * ## Why a warning and not a refusal
88355
+ *
88356
+ * `cdkd local start-cloudfront` REFUSES cdkd's state flags outright
88357
+ * ([#2528](https://github.com/go-to-k/cdkd/issues/2528)) because NO consumer on
88358
+ * that command's path reads a host-registered state source. `start-alb` is
88359
+ * partial, not total, so the same remedy would delete a working capability:
88360
+ *
88361
+ * - **ECS service targets DO honor `--from-state`.** `bootOneTarget` /
88362
+ * `rollOneTarget` (`cdk-local@0.147.7`, `dist/local-studio-BBtUAVNy.js`
88363
+ * `:26474` / `:26422`) hand `createLocalStateProvider` the FULL options bag,
88364
+ * so cdkd's `fromState` factory is selected and the task containers'
88365
+ * images / env / secrets / volumes resolve against S3 state.
88366
+ * - **Lambda target groups do NOT.** `resolveAlbLambdaTargetEnv` (`:26614`)
88367
+ * rebuilds the bag it hands the shared `resolveLambdaContainerEnv` as a
88368
+ * SIX-KEY allow-list — `fromCfnStack` / `assumeRole` / `region` / `profile` /
88369
+ * `stackRegion` / `envVars` (`:26619-26626`) — dropping `fromState` /
88370
+ * `stateBucket` / `statePrefix`. It forwards `extraStateProviders` faithfully,
88371
+ * but the dispatcher activates an extra provider only when `options[key]` is
88372
+ * truthy (`:4894`), and the key it looks for is exactly the one the bag no
88373
+ * longer carries. So cdkd's factory is registered and never selected, and the
88374
+ * Lambda boots with its intrinsics dropped — one WARN per variable, none of
88375
+ * which says the flag was the problem.
88376
+ *
88377
+ * The fix is upstream (two arguments in `resolveAlbLambdaTargetEnv`); until it
88378
+ * lands, this makes the partiality LOUD at boot instead of leaving the user to
88379
+ * read "Environment variable X contains a CloudFormation intrinsic and was
88380
+ * dropped" as a state problem. `--from-cfn-stack` survives the allow-list and
88381
+ * therefore reaches both target kinds, which is what the message points at.
88382
+ *
88383
+ * ## Mechanics
88384
+ *
88385
+ * `runEcsServiceEmulator` logs every string in the `warnings` array
88386
+ * `strategy.resolveBoots` returns, so appending to it is the whole wiring — no
88387
+ * second synth, since the front-door plan is already resolved. Returns the
88388
+ * strategy UNCHANGED when `--from-state` is absent, so the non-state path
88389
+ * carries no wrapper at all.
88390
+ *
88391
+ * The warning REPEATS on every `--watch` reload, because `resolveBoots` is
88392
+ * re-run per reload (`:26264`) and its warnings re-logged. That matches how
88393
+ * cdk-local's own ALB resolution warnings behave on the same path, which is
88394
+ * the whole reason: repeating is what the surrounding output already does.
88395
+ * It is NOT because a reload could introduce a new Lambda target — the reload
88396
+ * discards `frontDoor` and `buildFrontDoor` runs only at boot (`:26110`), so
88397
+ * a Lambda added mid-`--watch` is never stood up at all. An earlier revision
88398
+ * of this comment argued the second, false thing.
88399
+ */
88400
+ function warnUnresolvedLambdaTargetEnv(strategy, options) {
88401
+ if (options.fromState !== true) return strategy;
88402
+ return {
88403
+ ...strategy,
88404
+ resolveBoots: (stacks, chosenTargets) => {
88405
+ const resolved = strategy.resolveBoots(stacks, chosenTargets);
88406
+ const lambdaIds = collectLambdaTargetLogicalIds(resolved.frontDoor);
88407
+ if (lambdaIds.length === 0) return resolved;
88408
+ const ecsNote = resolved.boots.length > 0 ? "The ECS service targets behind this ALB DO honor --from-state. " : "";
88409
+ return {
88410
+ ...resolved,
88411
+ warnings: [...resolved.warnings, `--from-state does not reach the container environment of this ALB's Lambda target group(s): ${lambdaIds.join(", ")}. Their Environment.Variables keep any Ref / Fn::GetAtt / Fn::Sub / Fn::ImportValue intrinsics unresolved, and each is then dropped with its own warning. ` + ecsNote + "The only state source the Lambda path reads is --from-cfn-stack <name>, which REPLACES --from-state (the two are mutually exclusive) and reaches both target kinds on a CloudFormation-deployed stack; otherwise override the affected variables with --env-vars. Tracked as go-to-k/cdkd#2602 (upstream go-to-k/cdk-local#707)."]
88412
+ };
88413
+ }
88414
+ };
88415
+ }
88416
+ /**
88417
+ * The `EmulatorStrategy` `cdkd local start-alb` runs with: cdk-local's ALB
88418
+ * strategy, decorated by {@link warnUnresolvedLambdaTargetEnv}.
88419
+ *
88420
+ * Extracted from the command's action for ONE reason, and it is the finding
88421
+ * that produced it: with the composition written inline in the `.action(...)`
88422
+ * closure, deleting the decorator at its only wiring point left the entire
88423
+ * unit suite green (measured in review — 29/29). The decorator's own cases
88424
+ * exercise it directly, so they cannot see a call site that stopped calling
88425
+ * it. A named export gives that seam a subject a test can hold.
88426
+ *
88427
+ * The seam is fenced BEHAVIOURALLY, in
88428
+ * `tests/unit/cli/local-start-alb-wiring.test.ts`, which runs the real command
88429
+ * and drives whatever object the action hands the engine. A source-shape
88430
+ * assertion was tried first and defeated in review round 3 by hoisting
88431
+ * `albStrategy(options)` into a local one statement up: its positive half was
88432
+ * satisfied by the mutation's own COMMENT and its negative half could not
88433
+ * cross the `)` the hoist introduced. That case is deleted rather than
88434
+ * tightened — the behavioural one catches strictly more, and a fence a comment
88435
+ * can satisfy reads as coverage while providing none.
88436
+ */
88437
+ function buildAlbEmulatorStrategy(options) {
88438
+ return warnUnresolvedLambdaTargetEnv(albStrategy(options), options);
88439
+ }
88440
+ /**
88023
88441
  * `cdkl start-alb <Stack/Alb>` — Issue #86 v1. Names an
88024
88442
  * `AWS::ElasticLoadBalancingV2::LoadBalancer`, discovers the ECS service(s)
88025
88443
  * behind its HTTP `forward` listeners, boots their replicas, and stands up a
@@ -88027,8 +88445,8 @@ function createLocalStartAgentCoreCommand$1() {
88027
88445
  * The symmetric ALB counterpart of `start-api`.
88028
88446
  */
88029
88447
  function createLocalStartAlbCommand() {
88030
- const cmd = new Command("start-alb").description("Run an Application Load Balancer locally: name the ALB, and cdk-local boots the ECS service(s) behind its listeners and stands up a local front-door on each listener port that round-robins across the running replicas and routes its listener rules across the backing services — a stable host endpoint, like behind a real load balancer. The symmetric ALB counterpart of `start-api`. Each <target> accepts a CDK display path (MyStack/MyAlb) or stack-qualified logical ID; single-stack apps may omit the stack prefix. Supports HTTP and HTTPS listeners — by default a cloud-HTTPS listener is served over plain HTTP locally (with X-Forwarded-Proto: https preserved). Pass --tls (or --tls-cert / --tls-key) to terminate TLS locally with a self-signed or user-supplied cert. All six ALB rule-condition fields are honored (path-pattern / host-header / http-header / http-request-method / query-string / source-ip); forward (single and weighted), redirect, and fixed-response actions; and ECS or Lambda targets (a Lambda target group is invoked locally via the Lambda RIE). authenticate-cognito / authenticate-oidc actions enforce a local Bearer-JWT check (or AWSELBAuthSessionCookie pass-through) against the same JWKS / OIDC discovery URL the deployed ALB would; use --bearer-token <jwt> to inject a default token or --no-verify-auth to disable the guard. Omit <targets> in an interactive terminal to multi-select the load balancers from a list.").argument("[targets...]", "One or more CDK display paths or stack-qualified logical IDs of the AWS::ElasticLoadBalancingV2::LoadBalancer resources to run (omit to multi-select interactively in a TTY)").addOption(new Option("--from-state", "Read cdkd's S3 state for the target stack and substitute Ref / Fn::GetAtt / Fn::Sub / Fn::ImportValue / Fn::GetStackOutput intrinsics in container images, environment variables, secrets, role ARNs, and volumes of the ECS services behind the ALB. Mutually exclusive with --from-cfn-stack.").default(false)).addOption(new Option("--state-bucket <bucket>", "S3 bucket for --from-state. Falls back to CDKD_STATE_BUCKET env or cdk.json context.cdkd.stateBucket.")).addOption(new Option("--state-prefix <prefix>", "S3 key prefix for --from-state state files.").default("cdkd")).action(withErrorHandling(async (targets, options) => {
88031
- await runEcsServiceEmulator(targets, options, albStrategy(options), cdkdExtraStateProviders);
88448
+ const cmd = new Command("start-alb").description("Run an Application Load Balancer locally: name the ALB, and cdk-local boots the ECS service(s) behind its listeners and stands up a local front-door on each listener port that round-robins across the running replicas and routes its listener rules across the backing services — a stable host endpoint, like behind a real load balancer. The symmetric ALB counterpart of `start-api`. Each <target> accepts a CDK display path (MyStack/MyAlb) or stack-qualified logical ID; single-stack apps may omit the stack prefix. Supports HTTP and HTTPS listeners — by default a cloud-HTTPS listener is served over plain HTTP locally (with X-Forwarded-Proto: https preserved). Pass --tls (or --tls-cert / --tls-key) to terminate TLS locally with a self-signed or user-supplied cert. All six ALB rule-condition fields are honored (path-pattern / host-header / http-header / http-request-method / query-string / source-ip); forward (single and weighted), redirect, and fixed-response actions; and ECS or Lambda targets (a Lambda target group is invoked locally via the Lambda RIE). authenticate-cognito / authenticate-oidc actions enforce a local Bearer-JWT check (or AWSELBAuthSessionCookie pass-through) against the same JWKS / OIDC discovery URL the deployed ALB would; use --bearer-token <jwt> to inject a default token or --no-verify-auth to disable the guard. Omit <targets> in an interactive terminal to multi-select the load balancers from a list.").argument("[targets...]", "One or more CDK display paths or stack-qualified logical IDs of the AWS::ElasticLoadBalancingV2::LoadBalancer resources to run (omit to multi-select interactively in a TTY)").addOption(new Option("--from-state", "Read cdkd's S3 state for the target stack and substitute Ref / Fn::GetAtt / Fn::Sub / Fn::ImportValue / Fn::GetStackOutput intrinsics in container images, environment variables, secrets, role ARNs, and volumes of the ECS services behind the ALB. Does NOT reach a Lambda target group's container environment (upstream go-to-k/cdk-local#707; a boot warning names the affected functions) — use --from-cfn-stack for those. Mutually exclusive with --from-cfn-stack.").default(false)).addOption(new Option("--state-bucket <bucket>", "S3 bucket for --from-state. Falls back to CDKD_STATE_BUCKET env or cdk.json context.cdkd.stateBucket.")).addOption(new Option("--state-prefix <prefix>", "S3 key prefix for --from-state state files.").default("cdkd")).action(withErrorHandling(async (targets, options) => {
88449
+ await runEcsServiceEmulator(targets, options, buildAlbEmulatorStrategy(options), cdkdExtraStateProviders);
88032
88450
  }));
88033
88451
  addAlbSpecificOptions(cmd);
88034
88452
  return adoptDeprecatedRegionFlag(addCommonEcsServiceOptions(cmd));
@@ -89099,4 +89517,4 @@ function buildProgram() {
89099
89517
 
89100
89518
  //#endregion
89101
89519
  export { buildProgram };
89102
- //# sourceMappingURL=program-CG34-sQd.js.map
89520
+ //# sourceMappingURL=program-D2Vykejc.js.map