@go-to-k/cdkd 0.24.0 → 0.26.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.
package/dist/cli.js CHANGED
@@ -508,6 +508,104 @@ var stateOptions = [
508
508
  new Option("--state-prefix <prefix>", "S3 key prefix for state files").default("cdkd")
509
509
  ];
510
510
  var stackOptions = [new Option("--stack <name>", "Stack name to operate on")];
511
+ function parseDuration(value) {
512
+ if (typeof value !== "string" || value.length === 0) {
513
+ throw new Error(
514
+ `Invalid duration "${value}": expected <number>s, <number>m, or <number>h (e.g. 30s, 5m, 1h)`
515
+ );
516
+ }
517
+ const match = /^(\d+(?:\.\d+)?)([smh])$/.exec(value.trim());
518
+ if (!match) {
519
+ throw new Error(
520
+ `Invalid duration "${value}": expected <number>s, <number>m, or <number>h (e.g. 30s, 5m, 1h)`
521
+ );
522
+ }
523
+ const num = Number(match[1]);
524
+ if (!Number.isFinite(num) || num <= 0) {
525
+ throw new Error(`Invalid duration "${value}": must be greater than zero`);
526
+ }
527
+ const unit = match[2];
528
+ const multiplier = unit === "s" ? 1e3 : unit === "m" ? 6e4 : 36e5;
529
+ return Math.round(num * multiplier);
530
+ }
531
+ var RESOURCE_TYPE_REGEX = /^[A-Z][A-Za-z0-9]+::[A-Z][A-Za-z0-9]+::[A-Z][A-Za-z0-9]+$/;
532
+ function parseResourceTimeoutToken(flagName) {
533
+ return (raw, previous) => {
534
+ const acc = previous ?? { perTypeMs: {} };
535
+ if (!acc.perTypeMs)
536
+ acc.perTypeMs = {};
537
+ const eqIndex = raw.indexOf("=");
538
+ if (eqIndex === -1) {
539
+ acc.globalMs = parseDuration(raw);
540
+ return acc;
541
+ }
542
+ const typePart = raw.substring(0, eqIndex).trim();
543
+ const durationPart = raw.substring(eqIndex + 1).trim();
544
+ if (!RESOURCE_TYPE_REGEX.test(typePart)) {
545
+ throw new Error(
546
+ `Invalid ${flagName} value "${raw}": left-hand side must be a CloudFormation resource type like AWS::Service::Resource (got "${typePart}")`
547
+ );
548
+ }
549
+ if (durationPart.length === 0) {
550
+ throw new Error(
551
+ `Invalid ${flagName} value "${raw}": missing duration after '=' (e.g. ${typePart}=1h)`
552
+ );
553
+ }
554
+ let ms;
555
+ try {
556
+ ms = parseDuration(durationPart);
557
+ } catch (err) {
558
+ const inner = err instanceof Error ? err.message : String(err);
559
+ throw new Error(`Invalid ${flagName} value "${raw}": ${inner}`);
560
+ }
561
+ acc.perTypeMs[typePart] = ms;
562
+ return acc;
563
+ };
564
+ }
565
+ var resourceTimeoutOptions = [
566
+ // Default is `undefined` (NOT a pre-seeded ResourceTimeoutOption) — the
567
+ // command handler resolves missing globalMs to DEFAULT_RESOURCE_*_MS
568
+ // at the call site. Pre-seeding here would force every accumulator
569
+ // call to carry a snapshot, and would also surprise unit tests that
570
+ // expect `opts.resourceTimeout` to be `undefined` when the flag is not
571
+ // passed.
572
+ new Option(
573
+ "--resource-warn-after <duration_or_type=duration>",
574
+ "Warn when a single resource operation exceeds this wall-clock duration. Repeatable: pass a bare duration (e.g. 5m) to set the global default, or TYPE=DURATION (e.g. AWS::CloudFront::Distribution=10m) for a per-type override."
575
+ ).default(void 0, "5m").argParser(parseResourceTimeoutToken("--resource-warn-after")),
576
+ new Option(
577
+ "--resource-timeout <duration_or_type=duration>",
578
+ "Abort a single resource operation that exceeds this wall-clock duration. Repeatable: pass a bare duration (e.g. 30m) to set the global default, or TYPE=DURATION (e.g. AWS::CloudFront::Distribution=1h) for a per-type override. Custom-Resource-heavy stacks may need to raise this above the default 30m (the Custom Resource provider's polling cap is 1h)."
579
+ ).default(void 0, "30m").argParser(parseResourceTimeoutToken("--resource-timeout"))
580
+ ];
581
+ function validateResourceTimeouts(opts) {
582
+ const warn = opts.resourceWarnAfter;
583
+ const timeout = opts.resourceTimeout;
584
+ const globalWarn = warn?.globalMs;
585
+ const globalTimeout = timeout?.globalMs;
586
+ if (typeof globalWarn === "number" && typeof globalTimeout === "number") {
587
+ if (globalWarn >= globalTimeout) {
588
+ throw new Error(
589
+ `--resource-warn-after (${globalWarn}ms) must be less than --resource-timeout (${globalTimeout}ms)`
590
+ );
591
+ }
592
+ }
593
+ const warnPerType = warn?.perTypeMs ?? {};
594
+ const timeoutPerType = timeout?.perTypeMs ?? {};
595
+ const types = /* @__PURE__ */ new Set([...Object.keys(warnPerType), ...Object.keys(timeoutPerType)]);
596
+ for (const t of types) {
597
+ const effectiveWarn = warnPerType[t] ?? globalWarn;
598
+ const effectiveTimeout = timeoutPerType[t] ?? globalTimeout;
599
+ if (typeof effectiveWarn !== "number" || typeof effectiveTimeout !== "number") {
600
+ continue;
601
+ }
602
+ if (effectiveWarn >= effectiveTimeout) {
603
+ throw new Error(
604
+ `--resource-warn-after for ${t} (${effectiveWarn}ms) must be less than --resource-timeout for ${t} (${effectiveTimeout}ms)`
605
+ );
606
+ }
607
+ }
608
+ }
511
609
  var deployOptions = [
512
610
  new Option("--concurrency <number>", "Maximum concurrent resource operations").default(10).argParser((value) => parseInt(value, 10)),
513
611
  new Option("--stack-concurrency <number>", "Maximum concurrent stack deployments").default(4).argParser((value) => parseInt(value, 10)),
@@ -523,7 +621,8 @@ var deployOptions = [
523
621
  new Option(
524
622
  "-e, --exclusively",
525
623
  "Only deploy requested stacks, do not include dependencies"
526
- ).default(false)
624
+ ).default(false),
625
+ ...resourceTimeoutOptions
527
626
  ];
528
627
  var contextOptions = [
529
628
  new Option(
@@ -718,6 +817,22 @@ var LiveRenderer = class {
718
817
  if (this.active)
719
818
  this.draw();
720
819
  }
820
+ /**
821
+ * Replace the label of a previously-added task in place. No-op if the
822
+ * task is not currently tracked (e.g. it already finished). Used by the
823
+ * per-resource deadline wrapper to surface a "[taking longer than
824
+ * expected, Nm+]" suffix without disturbing the elapsed-time counter
825
+ * the renderer tracks via `startedAt`.
826
+ */
827
+ updateTaskLabel(id, label) {
828
+ const stackName = getCurrentStackName();
829
+ const task = this.tasks.get(scopedKey(id, stackName));
830
+ if (!task)
831
+ return;
832
+ task.label = label;
833
+ if (this.active)
834
+ this.draw();
835
+ }
721
836
  /**
722
837
  * Print content above the live area. Clears the live area, runs the writer,
723
838
  * then redraws the live area. When the renderer is inactive, the writer
@@ -1011,6 +1126,38 @@ var ProvisioningError = class _ProvisioningError extends CdkdError {
1011
1126
  Object.setPrototypeOf(this, _ProvisioningError.prototype);
1012
1127
  }
1013
1128
  };
1129
+ var ResourceTimeoutError = class _ResourceTimeoutError extends CdkdError {
1130
+ constructor(logicalId, resourceType, region, elapsedMs, operation, timeoutMs) {
1131
+ const elapsedLabel = formatDuration(elapsedMs);
1132
+ const timeoutLabel = formatDuration(timeoutMs);
1133
+ super(
1134
+ `Resource ${logicalId} (${resourceType}) in ${region} timed out after ${timeoutLabel} during ${operation} (elapsed ${elapsedLabel}).
1135
+ This may indicate a stuck Cloud Control polling loop, hung Custom Resource, or
1136
+ slow ENI provisioning. Re-run with --resource-timeout 1h if the resource genuinely
1137
+ needs more time, or --verbose to see the underlying provider activity.`,
1138
+ "RESOURCE_TIMEOUT"
1139
+ );
1140
+ this.logicalId = logicalId;
1141
+ this.resourceType = resourceType;
1142
+ this.region = region;
1143
+ this.elapsedMs = elapsedMs;
1144
+ this.operation = operation;
1145
+ this.timeoutMs = timeoutMs;
1146
+ this.name = "ResourceTimeoutError";
1147
+ Object.setPrototypeOf(this, _ResourceTimeoutError.prototype);
1148
+ }
1149
+ };
1150
+ function formatDuration(ms) {
1151
+ if (ms < 6e4) {
1152
+ return `${Math.round(ms / 1e3)}s`;
1153
+ }
1154
+ const totalMinutes = Math.round(ms / 6e4);
1155
+ if (totalMinutes < 60)
1156
+ return `${totalMinutes}m`;
1157
+ const hours = Math.floor(totalMinutes / 60);
1158
+ const minutes = totalMinutes % 60;
1159
+ return minutes === 0 ? `${hours}h` : `${hours}h${minutes}m`;
1160
+ }
1014
1161
  var DependencyError = class _DependencyError extends CdkdError {
1015
1162
  constructor(message, cause) {
1016
1163
  super(message, "DEPENDENCY_ERROR", cause);
@@ -30781,7 +30928,85 @@ async function withRetry(operation, logicalId, opts = {}) {
30781
30928
  throw lastError;
30782
30929
  }
30783
30930
 
30931
+ // src/deployment/resource-deadline.ts
30932
+ var InvalidResourceDeadlineError = class extends Error {
30933
+ constructor(message) {
30934
+ super(message);
30935
+ this.name = "InvalidResourceDeadlineError";
30936
+ }
30937
+ };
30938
+ function validateOptions(opts) {
30939
+ const { warnAfterMs, timeoutMs } = opts;
30940
+ if (!Number.isFinite(warnAfterMs) || !Number.isFinite(timeoutMs) || warnAfterMs <= 0 || timeoutMs <= 0) {
30941
+ throw new InvalidResourceDeadlineError(
30942
+ `withResourceDeadline: warnAfterMs and timeoutMs must be positive finite numbers (got warnAfterMs=${warnAfterMs}, timeoutMs=${timeoutMs})`
30943
+ );
30944
+ }
30945
+ if (warnAfterMs >= timeoutMs) {
30946
+ throw new InvalidResourceDeadlineError(
30947
+ `withResourceDeadline: warnAfterMs (${warnAfterMs}ms) must be less than timeoutMs (${timeoutMs}ms)`
30948
+ );
30949
+ }
30950
+ }
30951
+ async function withResourceDeadline(operation, opts) {
30952
+ validateOptions(opts);
30953
+ const startedAt = Date.now();
30954
+ return new Promise((resolve4, reject) => {
30955
+ let settled = false;
30956
+ let warnTimer;
30957
+ let timeoutTimer;
30958
+ const cleanup = () => {
30959
+ if (warnTimer !== void 0)
30960
+ clearTimeout(warnTimer);
30961
+ if (timeoutTimer !== void 0)
30962
+ clearTimeout(timeoutTimer);
30963
+ warnTimer = void 0;
30964
+ timeoutTimer = void 0;
30965
+ };
30966
+ if (opts.onWarn) {
30967
+ warnTimer = setTimeout(() => {
30968
+ if (settled)
30969
+ return;
30970
+ try {
30971
+ opts.onWarn(Date.now() - startedAt);
30972
+ } catch {
30973
+ }
30974
+ }, opts.warnAfterMs);
30975
+ if (typeof warnTimer.unref === "function")
30976
+ warnTimer.unref();
30977
+ }
30978
+ timeoutTimer = setTimeout(() => {
30979
+ if (settled)
30980
+ return;
30981
+ settled = true;
30982
+ cleanup();
30983
+ const elapsed = Date.now() - startedAt;
30984
+ reject(opts.onTimeout(elapsed));
30985
+ }, opts.timeoutMs);
30986
+ if (typeof timeoutTimer.unref === "function")
30987
+ timeoutTimer.unref();
30988
+ Promise.resolve().then(() => operation()).then(
30989
+ (value) => {
30990
+ if (settled)
30991
+ return;
30992
+ settled = true;
30993
+ cleanup();
30994
+ resolve4(value);
30995
+ },
30996
+ (err) => {
30997
+ if (settled)
30998
+ return;
30999
+ settled = true;
31000
+ cleanup();
31001
+ reject(err);
31002
+ }
31003
+ );
31004
+ });
31005
+ }
31006
+
30784
31007
  // src/deployment/deploy-engine.ts
31008
+ var DEFAULT_RESOURCE_WARN_AFTER_MS = 5 * 60 * 1e3;
31009
+ var DEFAULT_RESOURCE_TIMEOUT_MS = 30 * 60 * 1e3;
30785
31010
  var InterruptedError = class extends Error {
30786
31011
  constructor() {
30787
31012
  super("Deployment interrupted by user (Ctrl+C)");
@@ -30802,6 +31027,8 @@ var DeployEngine = class {
30802
31027
  this.options.dryRun = options.dryRun ?? false;
30803
31028
  this.options.lockTimeout = options.lockTimeout ?? 5 * 60 * 1e3;
30804
31029
  this.options.noRollback = options.noRollback ?? false;
31030
+ this.options.resourceWarnAfterMs = options.resourceWarnAfterMs ?? DEFAULT_RESOURCE_WARN_AFTER_MS;
31031
+ this.options.resourceTimeoutMs = options.resourceTimeoutMs ?? DEFAULT_RESOURCE_TIMEOUT_MS;
30805
31032
  }
30806
31033
  logger = getLogger().child("DeployEngine");
30807
31034
  resolver;
@@ -31400,259 +31627,305 @@ var DeployEngine = class {
31400
31627
  */
31401
31628
  async provisionResource(logicalId, change, stateResources, stackName, template, parameterValues, conditions, counts, progress) {
31402
31629
  const resourceType = change.resourceType;
31403
- const provider = this.providerRegistry.getProvider(resourceType);
31404
31630
  const renderer = getLiveRenderer();
31405
31631
  const needsReplacement = change.changeType === "UPDATE" && (change.propertyChanges?.some((pc) => pc.requiresReplacement) ?? false);
31406
31632
  const verb = change.changeType === "CREATE" ? "Creating" : change.changeType === "DELETE" ? "Deleting" : needsReplacement ? "Replacing" : "Updating";
31407
- renderer.addTask(logicalId, `${verb} ${logicalId} (${resourceType})`);
31408
- try {
31409
- switch (change.changeType) {
31410
- case "CREATE": {
31411
- const desiredProps = change.desiredProperties || {};
31412
- const context = {
31633
+ const baseLabel = `${verb} ${logicalId} (${resourceType})`;
31634
+ renderer.addTask(logicalId, baseLabel);
31635
+ const operationKind = change.changeType === "CREATE" ? "CREATE" : change.changeType === "DELETE" ? "DELETE" : "UPDATE";
31636
+ const warnAfterMs = this.options.resourceWarnAfterByType?.[resourceType] ?? this.options.resourceWarnAfterMs ?? DEFAULT_RESOURCE_WARN_AFTER_MS;
31637
+ const timeoutMs = this.options.resourceTimeoutByType?.[resourceType] ?? this.options.resourceTimeoutMs ?? DEFAULT_RESOURCE_TIMEOUT_MS;
31638
+ try {
31639
+ await withResourceDeadline(
31640
+ async () => {
31641
+ await this.provisionResourceBody(
31642
+ logicalId,
31643
+ change,
31644
+ stateResources,
31645
+ stackName,
31413
31646
  template,
31414
- resources: stateResources,
31415
- ...parameterValues && Object.keys(parameterValues).length > 0 && { parameters: parameterValues },
31416
- ...conditions && Object.keys(conditions).length > 0 && { conditions },
31417
- stateBackend: this.stateBackend,
31418
- stackName
31419
- };
31420
- const resolvedProps = await this.resolver.resolve(desiredProps, context);
31421
- const { provider: createProvider, properties: createProps } = this.selectProviderWithSafetyNet(provider, resourceType, resolvedProps, logicalId);
31422
- const result = await this.withRetry(
31423
- () => createProvider.create(logicalId, resourceType, createProps),
31647
+ parameterValues,
31648
+ conditions,
31649
+ counts,
31650
+ progress
31651
+ );
31652
+ },
31653
+ {
31654
+ warnAfterMs,
31655
+ timeoutMs,
31656
+ onWarn: (elapsedMs) => {
31657
+ const minutes = Math.max(1, Math.round(elapsedMs / 6e4));
31658
+ const warnSuffix = ` [taking longer than expected, ${minutes}m+]`;
31659
+ renderer.updateTaskLabel(logicalId, `${baseLabel}${warnSuffix}`);
31660
+ renderer.printAbove(() => {
31661
+ this.logger.warn(
31662
+ `${logicalId} (${resourceType}) has been ${operationKind === "CREATE" ? "creating" : operationKind === "DELETE" ? "deleting" : "updating"} for ${minutes}m \u2014 still waiting`
31663
+ );
31664
+ });
31665
+ },
31666
+ onTimeout: (elapsedMs) => new ResourceTimeoutError(
31667
+ logicalId,
31668
+ resourceType,
31669
+ this.stackRegion,
31670
+ elapsedMs,
31671
+ operationKind,
31672
+ timeoutMs
31673
+ )
31674
+ }
31675
+ );
31676
+ } catch (error) {
31677
+ renderer.removeTask(logicalId);
31678
+ const message = error instanceof Error ? error.message : String(error);
31679
+ this.logger.error(`Failed to ${change.changeType.toLowerCase()} ${logicalId}: ${message}`);
31680
+ throw new ProvisioningError(
31681
+ `Failed to ${change.changeType.toLowerCase()} resource ${logicalId}`,
31682
+ resourceType,
31683
+ logicalId,
31684
+ stateResources[logicalId]?.physicalId,
31685
+ error instanceof Error ? error : void 0
31686
+ );
31687
+ } finally {
31688
+ renderer.removeTask(logicalId);
31689
+ }
31690
+ }
31691
+ /**
31692
+ * Inner body of provisionResource, extracted so the outer wrapper can
31693
+ * apply the per-resource deadline (`withResourceDeadline`) without
31694
+ * having the timeout / warn timer code dwarf the real provisioning
31695
+ * logic. Behaviour is unchanged from the pre-deadline implementation.
31696
+ */
31697
+ async provisionResourceBody(logicalId, change, stateResources, stackName, template, parameterValues, conditions, counts, progress) {
31698
+ const resourceType = change.resourceType;
31699
+ const provider = this.providerRegistry.getProvider(resourceType);
31700
+ const renderer = getLiveRenderer();
31701
+ switch (change.changeType) {
31702
+ case "CREATE": {
31703
+ const desiredProps = change.desiredProperties || {};
31704
+ const context = {
31705
+ template,
31706
+ resources: stateResources,
31707
+ ...parameterValues && Object.keys(parameterValues).length > 0 && { parameters: parameterValues },
31708
+ ...conditions && Object.keys(conditions).length > 0 && { conditions },
31709
+ stateBackend: this.stateBackend,
31710
+ stackName
31711
+ };
31712
+ const resolvedProps = await this.resolver.resolve(desiredProps, context);
31713
+ const { provider: createProvider, properties: createProps } = this.selectProviderWithSafetyNet(provider, resourceType, resolvedProps, logicalId);
31714
+ const result = await this.withRetry(
31715
+ () => createProvider.create(logicalId, resourceType, createProps),
31716
+ logicalId
31717
+ );
31718
+ const dependencies = this.extractAllDependencies(template, logicalId);
31719
+ stateResources[logicalId] = {
31720
+ physicalId: result.physicalId,
31721
+ resourceType,
31722
+ properties: resolvedProps,
31723
+ ...result.attributes && { attributes: result.attributes },
31724
+ ...dependencies && dependencies.length > 0 && { dependencies }
31725
+ };
31726
+ if (counts)
31727
+ counts.created++;
31728
+ if (progress)
31729
+ progress.current++;
31730
+ const createPrefix = progress ? `[${progress.current}/${progress.total}] ` : " ";
31731
+ renderer.removeTask(logicalId);
31732
+ this.logger.info(`${createPrefix}\u2705 ${logicalId} (${resourceType}) created`);
31733
+ break;
31734
+ }
31735
+ case "UPDATE": {
31736
+ const currentResource = stateResources[logicalId];
31737
+ if (!currentResource) {
31738
+ throw new Error(`Cannot update ${logicalId}: resource not found in state`);
31739
+ }
31740
+ const desiredProps = change.desiredProperties || {};
31741
+ const currentProps = change.currentProperties || {};
31742
+ const context = {
31743
+ template,
31744
+ resources: stateResources,
31745
+ ...parameterValues && Object.keys(parameterValues).length > 0 && { parameters: parameterValues },
31746
+ ...conditions && Object.keys(conditions).length > 0 && { conditions },
31747
+ stateBackend: this.stateBackend,
31748
+ stackName
31749
+ };
31750
+ const resolvedProps = await this.resolver.resolve(desiredProps, context);
31751
+ if (JSON.stringify(resolvedProps) === JSON.stringify(currentProps)) {
31752
+ this.logger.debug(
31753
+ `Skipping ${logicalId}: no actual changes after intrinsic function resolution`
31754
+ );
31755
+ if (counts)
31756
+ counts.skipped++;
31757
+ break;
31758
+ }
31759
+ const needsReplacement = change.propertyChanges?.some((pc) => pc.requiresReplacement);
31760
+ const dependencies = this.extractAllDependencies(template, logicalId);
31761
+ if (needsReplacement) {
31762
+ const replacedProps = change.propertyChanges?.filter((pc) => pc.requiresReplacement).map((pc) => pc.path).join(", ");
31763
+ this.logger.info(
31764
+ `Replacing ${logicalId} (${resourceType}) - immutable properties changed: ${replacedProps}`
31765
+ );
31766
+ this.logger.info(` Creating new ${logicalId}...`);
31767
+ const { provider: replaceProvider, properties: replaceProps } = this.selectProviderWithSafetyNet(provider, resourceType, resolvedProps, logicalId);
31768
+ const createResult = await this.withRetry(
31769
+ () => replaceProvider.create(logicalId, resourceType, replaceProps),
31424
31770
  logicalId
31425
31771
  );
31426
- const dependencies = this.extractAllDependencies(template, logicalId);
31772
+ const updateReplacePolicy = template?.Resources?.[logicalId]?.UpdateReplacePolicy;
31773
+ if (updateReplacePolicy === "Retain") {
31774
+ this.logger.info(
31775
+ ` Retaining old ${logicalId} (${currentResource.physicalId}) - UpdateReplacePolicy: Retain`
31776
+ );
31777
+ } else {
31778
+ this.logger.info(` Deleting old ${logicalId} (${currentResource.physicalId})...`);
31779
+ try {
31780
+ await provider.delete(
31781
+ logicalId,
31782
+ currentResource.physicalId,
31783
+ resourceType,
31784
+ currentResource.properties,
31785
+ { expectedRegion: this.stackRegion }
31786
+ );
31787
+ this.logger.info(` \u2713 Old resource deleted`);
31788
+ } catch (deleteError) {
31789
+ this.logger.warn(
31790
+ ` \u26A0 Failed to delete old resource ${logicalId} (${currentResource.physicalId}): ${deleteError instanceof Error ? deleteError.message : String(deleteError)}`
31791
+ );
31792
+ }
31793
+ }
31427
31794
  stateResources[logicalId] = {
31428
- physicalId: result.physicalId,
31795
+ physicalId: createResult.physicalId,
31429
31796
  resourceType,
31430
31797
  properties: resolvedProps,
31431
- ...result.attributes && { attributes: result.attributes },
31798
+ ...createResult.attributes && { attributes: createResult.attributes },
31432
31799
  ...dependencies && dependencies.length > 0 && { dependencies }
31433
31800
  };
31434
31801
  if (counts)
31435
- counts.created++;
31802
+ counts.updated++;
31436
31803
  if (progress)
31437
31804
  progress.current++;
31438
- const createPrefix = progress ? `[${progress.current}/${progress.total}] ` : " ";
31805
+ const replacePrefix = progress ? `[${progress.current}/${progress.total}] ` : " ";
31439
31806
  renderer.removeTask(logicalId);
31440
- this.logger.info(`${createPrefix}\u2705 ${logicalId} (${resourceType}) created`);
31441
- break;
31442
- }
31443
- case "UPDATE": {
31444
- const currentResource = stateResources[logicalId];
31445
- if (!currentResource) {
31446
- throw new Error(`Cannot update ${logicalId}: resource not found in state`);
31447
- }
31448
- const desiredProps = change.desiredProperties || {};
31449
- const currentProps = change.currentProperties || {};
31450
- const context = {
31451
- template,
31452
- resources: stateResources,
31453
- ...parameterValues && Object.keys(parameterValues).length > 0 && { parameters: parameterValues },
31454
- ...conditions && Object.keys(conditions).length > 0 && { conditions },
31455
- stateBackend: this.stateBackend,
31456
- stackName
31457
- };
31458
- const resolvedProps = await this.resolver.resolve(desiredProps, context);
31459
- if (JSON.stringify(resolvedProps) === JSON.stringify(currentProps)) {
31460
- this.logger.debug(
31461
- `Skipping ${logicalId}: no actual changes after intrinsic function resolution`
31462
- );
31463
- if (counts)
31464
- counts.skipped++;
31465
- break;
31466
- }
31467
- const needsReplacement2 = change.propertyChanges?.some((pc) => pc.requiresReplacement);
31468
- const dependencies = this.extractAllDependencies(template, logicalId);
31469
- if (needsReplacement2) {
31470
- const replacedProps = change.propertyChanges?.filter((pc) => pc.requiresReplacement).map((pc) => pc.path).join(", ");
31471
- this.logger.info(
31472
- `Replacing ${logicalId} (${resourceType}) - immutable properties changed: ${replacedProps}`
31473
- );
31474
- this.logger.info(` Creating new ${logicalId}...`);
31475
- const { provider: replaceProvider, properties: replaceProps } = this.selectProviderWithSafetyNet(provider, resourceType, resolvedProps, logicalId);
31476
- const createResult = await this.withRetry(
31477
- () => replaceProvider.create(logicalId, resourceType, replaceProps),
31807
+ this.logger.info(`${replacePrefix}\u2705 ${logicalId} (${resourceType}) replaced`);
31808
+ } else {
31809
+ this.logger.debug(`Updating ${logicalId} (${resourceType})`);
31810
+ const { provider: updateProvider, properties: updateProps } = this.selectProviderWithSafetyNet(provider, resourceType, resolvedProps, logicalId);
31811
+ let result;
31812
+ try {
31813
+ result = await this.withRetry(
31814
+ () => updateProvider.update(
31815
+ logicalId,
31816
+ currentResource.physicalId,
31817
+ resourceType,
31818
+ updateProps,
31819
+ currentProps
31820
+ ),
31478
31821
  logicalId
31479
31822
  );
31480
- const updateReplacePolicy = template?.Resources?.[logicalId]?.UpdateReplacePolicy;
31481
- if (updateReplacePolicy === "Retain") {
31823
+ } catch (updateError) {
31824
+ const msg = updateError instanceof Error ? updateError.message : String(updateError);
31825
+ if (msg.includes("UnsupportedActionException") || msg.includes("does not support UPDATE")) {
31482
31826
  this.logger.info(
31483
- ` Retaining old ${logicalId} (${currentResource.physicalId}) - UpdateReplacePolicy: Retain`
31827
+ `UPDATE not supported for ${logicalId} (${resourceType}), replacing (DELETE \u2192 CREATE)`
31484
31828
  );
31485
- } else {
31486
- this.logger.info(` Deleting old ${logicalId} (${currentResource.physicalId})...`);
31487
31829
  try {
31488
31830
  await provider.delete(
31489
31831
  logicalId,
31490
31832
  currentResource.physicalId,
31491
31833
  resourceType,
31492
- currentResource.properties,
31834
+ currentProps,
31493
31835
  { expectedRegion: this.stackRegion }
31494
31836
  );
31495
- this.logger.info(` \u2713 Old resource deleted`);
31496
31837
  } catch (deleteError) {
31497
- this.logger.warn(
31498
- ` \u26A0 Failed to delete old resource ${logicalId} (${currentResource.physicalId}): ${deleteError instanceof Error ? deleteError.message : String(deleteError)}`
31499
- );
31500
- }
31501
- }
31502
- stateResources[logicalId] = {
31503
- physicalId: createResult.physicalId,
31504
- resourceType,
31505
- properties: resolvedProps,
31506
- ...createResult.attributes && { attributes: createResult.attributes },
31507
- ...dependencies && dependencies.length > 0 && { dependencies }
31508
- };
31509
- if (counts)
31510
- counts.updated++;
31511
- if (progress)
31512
- progress.current++;
31513
- const replacePrefix = progress ? `[${progress.current}/${progress.total}] ` : " ";
31514
- renderer.removeTask(logicalId);
31515
- this.logger.info(`${replacePrefix}\u2705 ${logicalId} (${resourceType}) replaced`);
31516
- } else {
31517
- this.logger.debug(`Updating ${logicalId} (${resourceType})`);
31518
- const { provider: updateProvider, properties: updateProps } = this.selectProviderWithSafetyNet(provider, resourceType, resolvedProps, logicalId);
31519
- let result;
31520
- try {
31521
- result = await this.withRetry(
31522
- () => updateProvider.update(
31523
- logicalId,
31524
- currentResource.physicalId,
31525
- resourceType,
31526
- updateProps,
31527
- currentProps
31528
- ),
31529
- logicalId
31530
- );
31531
- } catch (updateError) {
31532
- const msg = updateError instanceof Error ? updateError.message : String(updateError);
31533
- if (msg.includes("UnsupportedActionException") || msg.includes("does not support UPDATE")) {
31534
- this.logger.info(
31535
- `UPDATE not supported for ${logicalId} (${resourceType}), replacing (DELETE \u2192 CREATE)`
31536
- );
31537
- try {
31538
- await provider.delete(
31539
- logicalId,
31540
- currentResource.physicalId,
31541
- resourceType,
31542
- currentProps,
31543
- { expectedRegion: this.stackRegion }
31838
+ const deleteMsg = deleteError instanceof Error ? deleteError.message : String(deleteError);
31839
+ if (deleteMsg.includes("does not exist") || deleteMsg.includes("not found") || deleteMsg.includes("NotFound")) {
31840
+ this.logger.debug(
31841
+ `Old resource ${logicalId} already gone, proceeding with CREATE`
31544
31842
  );
31545
- } catch (deleteError) {
31546
- const deleteMsg = deleteError instanceof Error ? deleteError.message : String(deleteError);
31547
- if (deleteMsg.includes("does not exist") || deleteMsg.includes("not found") || deleteMsg.includes("NotFound")) {
31548
- this.logger.debug(
31549
- `Old resource ${logicalId} already gone, proceeding with CREATE`
31550
- );
31551
- } else {
31552
- throw deleteError;
31553
- }
31843
+ } else {
31844
+ throw deleteError;
31554
31845
  }
31555
- const { provider: replProvider, properties: replProps } = this.selectProviderWithSafetyNet(
31556
- provider,
31557
- resourceType,
31558
- resolvedProps,
31559
- logicalId
31560
- );
31561
- const createResult = await this.withRetry(
31562
- () => replProvider.create(logicalId, resourceType, replProps),
31563
- logicalId
31564
- );
31565
- result = {
31566
- physicalId: createResult.physicalId,
31567
- attributes: createResult.attributes,
31568
- wasReplaced: true
31569
- };
31570
- } else {
31571
- throw updateError;
31572
31846
  }
31573
- }
31574
- if (result.wasReplaced) {
31575
- this.logger.info(
31576
- `Resource ${logicalId} was replaced: ${currentResource.physicalId} -> ${result.physicalId}`
31847
+ const { provider: replProvider, properties: replProps } = this.selectProviderWithSafetyNet(provider, resourceType, resolvedProps, logicalId);
31848
+ const createResult = await this.withRetry(
31849
+ () => replProvider.create(logicalId, resourceType, replProps),
31850
+ logicalId
31577
31851
  );
31852
+ result = {
31853
+ physicalId: createResult.physicalId,
31854
+ attributes: createResult.attributes,
31855
+ wasReplaced: true
31856
+ };
31857
+ } else {
31858
+ throw updateError;
31578
31859
  }
31579
- stateResources[logicalId] = {
31580
- physicalId: result.physicalId,
31581
- resourceType,
31582
- properties: resolvedProps,
31583
- ...result.attributes && { attributes: result.attributes },
31584
- ...dependencies && dependencies.length > 0 && { dependencies }
31585
- };
31586
- if (counts)
31587
- counts.updated++;
31588
- if (progress)
31589
- progress.current++;
31590
- const updatePrefix = progress ? `[${progress.current}/${progress.total}] ` : " ";
31591
- renderer.removeTask(logicalId);
31592
- this.logger.info(`${updatePrefix}\u2705 ${logicalId} (${resourceType}) updated`);
31593
- }
31594
- break;
31595
- }
31596
- case "DELETE": {
31597
- const currentResource = stateResources[logicalId];
31598
- if (!currentResource) {
31599
- throw new Error(`Cannot delete ${logicalId}: resource not found in state`);
31600
- }
31601
- const deletionPolicy = template?.Resources?.[logicalId]?.DeletionPolicy;
31602
- if (deletionPolicy === "Retain") {
31603
- this.logger.info(`Retaining ${logicalId} (${resourceType}) - DeletionPolicy: Retain`);
31604
- delete stateResources[logicalId];
31605
- break;
31606
31860
  }
31607
- this.logger.debug(`Deleting ${logicalId} (${resourceType})`);
31608
- try {
31609
- await this.withRetry(
31610
- () => provider.delete(
31611
- logicalId,
31612
- currentResource.physicalId,
31613
- resourceType,
31614
- currentResource.properties,
31615
- { expectedRegion: this.stackRegion }
31616
- ),
31617
- logicalId,
31618
- 3,
31619
- // fewer retries for DELETE
31620
- 5e3
31861
+ if (result.wasReplaced) {
31862
+ this.logger.info(
31863
+ `Resource ${logicalId} was replaced: ${currentResource.physicalId} -> ${result.physicalId}`
31621
31864
  );
31622
- } catch (deleteError) {
31623
- const msg = deleteError instanceof Error ? deleteError.message : String(deleteError);
31624
- if (msg.includes("does not exist") || msg.includes("was not found") || msg.includes("not found") || msg.includes("No policy found") || msg.includes("NoSuchEntity") || msg.includes("NotFoundException") || msg.includes("ResourceNotFoundException")) {
31625
- this.logger.debug(
31626
- `Resource ${logicalId} already deleted (${msg}), removing from state`
31627
- );
31628
- } else {
31629
- throw deleteError;
31630
- }
31631
31865
  }
31632
- delete stateResources[logicalId];
31866
+ stateResources[logicalId] = {
31867
+ physicalId: result.physicalId,
31868
+ resourceType,
31869
+ properties: resolvedProps,
31870
+ ...result.attributes && { attributes: result.attributes },
31871
+ ...dependencies && dependencies.length > 0 && { dependencies }
31872
+ };
31633
31873
  if (counts)
31634
- counts.deleted++;
31874
+ counts.updated++;
31635
31875
  if (progress)
31636
31876
  progress.current++;
31637
- const deletePrefix = progress ? `[${progress.current}/${progress.total}] ` : " ";
31877
+ const updatePrefix = progress ? `[${progress.current}/${progress.total}] ` : " ";
31638
31878
  renderer.removeTask(logicalId);
31639
- this.logger.info(`${deletePrefix}\u2705 ${logicalId} (${resourceType}) deleted`);
31879
+ this.logger.info(`${updatePrefix}\u2705 ${logicalId} (${resourceType}) updated`);
31880
+ }
31881
+ break;
31882
+ }
31883
+ case "DELETE": {
31884
+ const currentResource = stateResources[logicalId];
31885
+ if (!currentResource) {
31886
+ throw new Error(`Cannot delete ${logicalId}: resource not found in state`);
31887
+ }
31888
+ const deletionPolicy = template?.Resources?.[logicalId]?.DeletionPolicy;
31889
+ if (deletionPolicy === "Retain") {
31890
+ this.logger.info(`Retaining ${logicalId} (${resourceType}) - DeletionPolicy: Retain`);
31891
+ delete stateResources[logicalId];
31640
31892
  break;
31641
31893
  }
31894
+ this.logger.debug(`Deleting ${logicalId} (${resourceType})`);
31895
+ try {
31896
+ await this.withRetry(
31897
+ () => provider.delete(
31898
+ logicalId,
31899
+ currentResource.physicalId,
31900
+ resourceType,
31901
+ currentResource.properties,
31902
+ { expectedRegion: this.stackRegion }
31903
+ ),
31904
+ logicalId,
31905
+ 3,
31906
+ // fewer retries for DELETE
31907
+ 5e3
31908
+ );
31909
+ } catch (deleteError) {
31910
+ const msg = deleteError instanceof Error ? deleteError.message : String(deleteError);
31911
+ if (msg.includes("does not exist") || msg.includes("was not found") || msg.includes("not found") || msg.includes("No policy found") || msg.includes("NoSuchEntity") || msg.includes("NotFoundException") || msg.includes("ResourceNotFoundException")) {
31912
+ this.logger.debug(
31913
+ `Resource ${logicalId} already deleted (${msg}), removing from state`
31914
+ );
31915
+ } else {
31916
+ throw deleteError;
31917
+ }
31918
+ }
31919
+ delete stateResources[logicalId];
31920
+ if (counts)
31921
+ counts.deleted++;
31922
+ if (progress)
31923
+ progress.current++;
31924
+ const deletePrefix = progress ? `[${progress.current}/${progress.total}] ` : " ";
31925
+ renderer.removeTask(logicalId);
31926
+ this.logger.info(`${deletePrefix}\u2705 ${logicalId} (${resourceType}) deleted`);
31927
+ break;
31642
31928
  }
31643
- } catch (error) {
31644
- renderer.removeTask(logicalId);
31645
- const message = error instanceof Error ? error.message : String(error);
31646
- this.logger.error(`Failed to ${change.changeType.toLowerCase()} ${logicalId}: ${message}`);
31647
- throw new ProvisioningError(
31648
- `Failed to ${change.changeType.toLowerCase()} resource ${logicalId}`,
31649
- resourceType,
31650
- logicalId,
31651
- stateResources[logicalId]?.physicalId,
31652
- error instanceof Error ? error : void 0
31653
- );
31654
- } finally {
31655
- renderer.removeTask(logicalId);
31656
31929
  }
31657
31930
  }
31658
31931
  /**
@@ -31864,6 +32137,10 @@ async function deployCommand(stacks, options) {
31864
32137
  process.env["CDKD_NO_LIVE"] = "1";
31865
32138
  }
31866
32139
  warnIfDeprecatedRegion(options);
32140
+ validateResourceTimeouts({
32141
+ ...options.resourceWarnAfter && { resourceWarnAfter: options.resourceWarnAfter },
32142
+ ...options.resourceTimeout && { resourceTimeout: options.resourceTimeout }
32143
+ });
31867
32144
  if (!options.wait) {
31868
32145
  process.env["CDKD_NO_WAIT"] = "true";
31869
32146
  }
@@ -32056,7 +32333,19 @@ Deploying stack: ${stackInfo.stackName}${stackRegion !== baseRegion ? ` (region:
32056
32333
  {
32057
32334
  concurrency: options.concurrency,
32058
32335
  dryRun: options.dryRun,
32059
- noRollback: !options.rollback
32336
+ noRollback: !options.rollback,
32337
+ ...options.resourceWarnAfter?.globalMs !== void 0 && {
32338
+ resourceWarnAfterMs: options.resourceWarnAfter.globalMs
32339
+ },
32340
+ ...options.resourceTimeout?.globalMs !== void 0 && {
32341
+ resourceTimeoutMs: options.resourceTimeout.globalMs
32342
+ },
32343
+ ...options.resourceWarnAfter?.perTypeMs && {
32344
+ resourceWarnAfterByType: options.resourceWarnAfter.perTypeMs
32345
+ },
32346
+ ...options.resourceTimeout?.perTypeMs && {
32347
+ resourceTimeoutByType: options.resourceTimeout.perTypeMs
32348
+ }
32060
32349
  },
32061
32350
  stackRegion
32062
32351
  );
@@ -32466,41 +32755,73 @@ Acquiring lock for stack ${stackName}...`);
32466
32755
  logger.debug(
32467
32756
  `Deletion level ${executionLevels.length - levelIndex}/${executionLevels.length} (${level.length} resources)`
32468
32757
  );
32758
+ const stackRegion2 = state.region ?? ctx.baseRegion;
32469
32759
  const deletePromises = level.map(async (logicalId) => {
32470
32760
  const resource = state.resources[logicalId];
32471
32761
  if (!resource) {
32472
32762
  logger.warn(`Resource ${logicalId} not found in state, skipping`);
32473
32763
  return;
32474
32764
  }
32475
- renderer.addTask(logicalId, `Deleting ${logicalId} (${resource.resourceType})`);
32765
+ const warnAfterMs = ctx.resourceWarnAfterByType?.[resource.resourceType] ?? ctx.resourceWarnAfterMs ?? DEFAULT_RESOURCE_WARN_AFTER_MS;
32766
+ const timeoutMs = ctx.resourceTimeoutByType?.[resource.resourceType] ?? ctx.resourceTimeoutMs ?? DEFAULT_RESOURCE_TIMEOUT_MS;
32767
+ const baseLabel = `Deleting ${logicalId} (${resource.resourceType})`;
32768
+ renderer.addTask(logicalId, baseLabel);
32476
32769
  try {
32477
32770
  const provider = destroyProviderRegistry.getProvider(resource.resourceType);
32478
- let lastDeleteError;
32479
- for (let attempt = 0; attempt <= 3; attempt++) {
32480
- try {
32481
- await provider.delete(
32771
+ await withResourceDeadline(
32772
+ async () => {
32773
+ let lastDeleteError;
32774
+ for (let attempt = 0; attempt <= 3; attempt++) {
32775
+ try {
32776
+ await provider.delete(
32777
+ logicalId,
32778
+ resource.physicalId,
32779
+ resource.resourceType,
32780
+ resource.properties
32781
+ );
32782
+ lastDeleteError = null;
32783
+ break;
32784
+ } catch (retryError) {
32785
+ lastDeleteError = retryError;
32786
+ const msg = retryError instanceof Error ? retryError.message : String(retryError);
32787
+ const isRetryable = msg.includes("Too Many Requests") || msg.includes("has dependencies") || msg.includes("can't be deleted since") || msg.includes("DependencyViolation");
32788
+ if (!isRetryable || attempt >= 3)
32789
+ break;
32790
+ const delay = 5e3 * Math.pow(2, attempt);
32791
+ logger.debug(
32792
+ ` \u23F3 Retrying delete ${logicalId} in ${delay / 1e3}s (attempt ${attempt + 1}/3)`
32793
+ );
32794
+ await new Promise((resolve4) => setTimeout(resolve4, delay));
32795
+ }
32796
+ }
32797
+ if (lastDeleteError)
32798
+ throw lastDeleteError;
32799
+ },
32800
+ {
32801
+ warnAfterMs,
32802
+ timeoutMs,
32803
+ onWarn: (elapsedMs) => {
32804
+ const minutes = Math.max(1, Math.round(elapsedMs / 6e4));
32805
+ renderer.updateTaskLabel(
32806
+ logicalId,
32807
+ `${baseLabel} [taking longer than expected, ${minutes}m+]`
32808
+ );
32809
+ renderer.printAbove(() => {
32810
+ logger.warn(
32811
+ `${logicalId} (${resource.resourceType}) has been deleting for ${minutes}m \u2014 still waiting`
32812
+ );
32813
+ });
32814
+ },
32815
+ onTimeout: (elapsedMs) => new ResourceTimeoutError(
32482
32816
  logicalId,
32483
- resource.physicalId,
32484
32817
  resource.resourceType,
32485
- resource.properties
32486
- );
32487
- lastDeleteError = null;
32488
- break;
32489
- } catch (retryError) {
32490
- lastDeleteError = retryError;
32491
- const msg = retryError instanceof Error ? retryError.message : String(retryError);
32492
- const isRetryable = msg.includes("Too Many Requests") || msg.includes("has dependencies") || msg.includes("can't be deleted since") || msg.includes("DependencyViolation");
32493
- if (!isRetryable || attempt >= 3)
32494
- break;
32495
- const delay = 5e3 * Math.pow(2, attempt);
32496
- logger.debug(
32497
- ` \u23F3 Retrying delete ${logicalId} in ${delay / 1e3}s (attempt ${attempt + 1}/3)`
32498
- );
32499
- await new Promise((resolve4) => setTimeout(resolve4, delay));
32818
+ stackRegion2,
32819
+ elapsedMs,
32820
+ "DELETE",
32821
+ timeoutMs
32822
+ )
32500
32823
  }
32501
- }
32502
- if (lastDeleteError)
32503
- throw lastDeleteError;
32824
+ );
32504
32825
  renderer.removeTask(logicalId);
32505
32826
  logger.info(` \u2705 ${logicalId} (${resource.resourceType}) deleted`);
32506
32827
  result.deletedCount++;
@@ -32510,6 +32831,16 @@ Acquiring lock for stack ${stackName}...`);
32510
32831
  if (msg.includes("does not exist") || msg.includes("not found") || msg.includes("No policy found") || msg.includes("NoSuchEntity") || msg.includes("NotFoundException")) {
32511
32832
  logger.debug(` ${logicalId} already deleted, removing from state`);
32512
32833
  result.deletedCount++;
32834
+ } else if (error instanceof ResourceTimeoutError) {
32835
+ const wrapped = new ProvisioningError(
32836
+ error.message,
32837
+ resource.resourceType,
32838
+ logicalId,
32839
+ resource.physicalId,
32840
+ error
32841
+ );
32842
+ logger.error(` \u2717 Failed to delete ${logicalId}:`, wrapped.message);
32843
+ result.errorCount++;
32513
32844
  } else {
32514
32845
  logger.error(` \u2717 Failed to delete ${logicalId}:`, String(error));
32515
32846
  result.errorCount++;
@@ -32552,6 +32883,10 @@ async function destroyCommand(stackArgs, options) {
32552
32883
  process.env["CDKD_NO_LIVE"] = "1";
32553
32884
  }
32554
32885
  warnIfDeprecatedRegion(options);
32886
+ validateResourceTimeouts({
32887
+ ...options.resourceWarnAfter && { resourceWarnAfter: options.resourceWarnAfter },
32888
+ ...options.resourceTimeout && { resourceTimeout: options.resourceTimeout }
32889
+ });
32555
32890
  const region = options.region || process.env["AWS_REGION"] || "us-east-1";
32556
32891
  const stateBucket = await resolveStateBucketWithDefault(options.stateBucket, region);
32557
32892
  logger.info("Starting stack destruction...");
@@ -32683,7 +33018,19 @@ Preparing to destroy stack: ${stackName}`);
32683
33018
  baseRegion: region,
32684
33019
  ...options.profile && { profile: options.profile },
32685
33020
  stateBucket,
32686
- skipConfirmation: options.yes || options.force
33021
+ skipConfirmation: options.yes || options.force,
33022
+ ...options.resourceWarnAfter?.globalMs !== void 0 && {
33023
+ resourceWarnAfterMs: options.resourceWarnAfter.globalMs
33024
+ },
33025
+ ...options.resourceTimeout?.globalMs !== void 0 && {
33026
+ resourceTimeoutMs: options.resourceTimeout.globalMs
33027
+ },
33028
+ ...options.resourceWarnAfter?.perTypeMs && {
33029
+ resourceWarnAfterByType: options.resourceWarnAfter.perTypeMs
33030
+ },
33031
+ ...options.resourceTimeout?.perTypeMs && {
33032
+ resourceTimeoutByType: options.resourceTimeout.perTypeMs
33033
+ }
32687
33034
  });
32688
33035
  }
32689
33036
  } finally {
@@ -32701,6 +33048,7 @@ function createDestroyCommand() {
32701
33048
  ...stateOptions,
32702
33049
  ...stackOptions,
32703
33050
  ...destroyOptions,
33051
+ ...resourceTimeoutOptions,
32704
33052
  ...contextOptions
32705
33053
  ].forEach((opt) => cmd.addOption(opt));
32706
33054
  cmd.addOption(deprecatedRegionOption);
@@ -32711,15 +33059,432 @@ function createDestroyCommand() {
32711
33059
  import * as readline2 from "node:readline/promises";
32712
33060
  import { Command as Command7 } from "commander";
32713
33061
  init_aws_clients();
32714
- async function orphanCommand(stackArgs, options) {
33062
+
33063
+ // src/cli/cdk-path.ts
33064
+ function readCdkPath(resource) {
33065
+ const meta = resource.Metadata;
33066
+ if (!meta)
33067
+ return "";
33068
+ const v = meta["aws:cdk:path"];
33069
+ return typeof v === "string" ? v : "";
33070
+ }
33071
+ function buildCdkPathIndex(template) {
33072
+ const index = /* @__PURE__ */ new Map();
33073
+ for (const [logicalId, resource] of Object.entries(template.Resources)) {
33074
+ const path = readCdkPath(resource);
33075
+ if (path)
33076
+ index.set(path, logicalId);
33077
+ }
33078
+ return index;
33079
+ }
33080
+
33081
+ // src/analyzer/orphan-rewriter.ts
33082
+ var AttributeFetcher = class {
33083
+ constructor(orphans, providerRegistry, options) {
33084
+ this.orphans = orphans;
33085
+ this.providerRegistry = providerRegistry;
33086
+ this.options = options;
33087
+ }
33088
+ cache = /* @__PURE__ */ new Map();
33089
+ logger = getLogger().child("OrphanRewriter");
33090
+ /**
33091
+ * Return the orphan's resolved value for `Ref` (its physicalId) — never
33092
+ * needs an AWS call.
33093
+ */
33094
+ ref(orphanLogicalId) {
33095
+ const o = this.orphans[orphanLogicalId];
33096
+ if (!o) {
33097
+ throw new Error(
33098
+ `Internal: Ref to '${orphanLogicalId}' has no orphan entry \u2014 should have been filtered out`
33099
+ );
33100
+ }
33101
+ return o.physicalId;
33102
+ }
33103
+ /**
33104
+ * Return the orphan's resolved value for `Fn::GetAtt`. Hits the live
33105
+ * provider on first call; subsequent calls reuse the cached result.
33106
+ *
33107
+ * Returns `{ ok: true, value }` on success; `{ ok: false, reason }`
33108
+ * when the live fetch failed AND the `--force` cache fallback either
33109
+ * was disabled or also lacked the attribute. In the cache-fallback
33110
+ * success path returns `{ ok: true, value, fromCache: true }`.
33111
+ */
33112
+ async getAtt(orphanLogicalId, attribute) {
33113
+ const cacheKey = `${orphanLogicalId}\0${attribute}`;
33114
+ if (this.cache.has(cacheKey)) {
33115
+ return { ok: true, value: this.cache.get(cacheKey) };
33116
+ }
33117
+ const orphan = this.orphans[orphanLogicalId];
33118
+ if (!orphan) {
33119
+ return {
33120
+ ok: false,
33121
+ reason: `Internal: GetAtt to '${orphanLogicalId}' has no orphan entry`
33122
+ };
33123
+ }
33124
+ let provider;
33125
+ try {
33126
+ provider = this.providerRegistry.getProvider(orphan.resourceType);
33127
+ } catch (err) {
33128
+ return {
33129
+ ok: false,
33130
+ reason: `no provider available for ${orphan.resourceType}: ${err instanceof Error ? err.message : String(err)}`
33131
+ };
33132
+ }
33133
+ if (!provider.getAttribute) {
33134
+ return this.cacheFallback(
33135
+ orphanLogicalId,
33136
+ attribute,
33137
+ `provider for ${orphan.resourceType} does not implement getAttribute`
33138
+ );
33139
+ }
33140
+ try {
33141
+ const value = await provider.getAttribute(orphan.physicalId, orphan.resourceType, attribute);
33142
+ if (value === void 0) {
33143
+ return this.cacheFallback(
33144
+ orphanLogicalId,
33145
+ attribute,
33146
+ `provider returned undefined for ${orphan.resourceType}.${attribute}`
33147
+ );
33148
+ }
33149
+ this.cache.set(cacheKey, value);
33150
+ return { ok: true, value };
33151
+ } catch (err) {
33152
+ return this.cacheFallback(
33153
+ orphanLogicalId,
33154
+ attribute,
33155
+ err instanceof Error ? err.message : String(err)
33156
+ );
33157
+ }
33158
+ }
33159
+ /**
33160
+ * Try the orphan's `state.attributes[attribute]` as a last-resort value
33161
+ * source under `--force`. Without `--force`, returns the original
33162
+ * failure reason unchanged (caller pushes to `unresolvable`).
33163
+ */
33164
+ cacheFallback(orphanLogicalId, attribute, reason) {
33165
+ if (!this.options.force) {
33166
+ return { ok: false, reason };
33167
+ }
33168
+ const orphan = this.orphans[orphanLogicalId];
33169
+ const cached = orphan.attributes?.[attribute];
33170
+ if (cached === void 0) {
33171
+ this.logger.warn(
33172
+ `--force: state.attributes also lacks '${orphanLogicalId}.${attribute}'; leaving the original intrinsic in place.`
33173
+ );
33174
+ return {
33175
+ ok: false,
33176
+ reason: `${reason}; state.attributes cache also has no value for '${attribute}'`
33177
+ };
33178
+ }
33179
+ this.logger.warn(
33180
+ `--force: live fetch failed for '${orphanLogicalId}.${attribute}' (${reason}); falling back to cached value from state.attributes.`
33181
+ );
33182
+ const cacheKey = `${orphanLogicalId}\0${attribute}`;
33183
+ this.cache.set(cacheKey, cached);
33184
+ return { ok: true, value: cached, fromCache: true };
33185
+ }
33186
+ };
33187
+ async function rewriteResourceReferences(state, orphanLogicalIds, providerRegistry, options = {}) {
33188
+ const orphanSet = new Set(orphanLogicalIds);
33189
+ const orphans = {};
33190
+ for (const id of orphanLogicalIds) {
33191
+ const r = state.resources[id];
33192
+ if (!r) {
33193
+ throw new Error(`rewriteResourceReferences: orphan '${id}' not found in state.resources`);
33194
+ }
33195
+ orphans[id] = r;
33196
+ }
33197
+ const fetcher = new AttributeFetcher(orphans, providerRegistry, options);
33198
+ const rewrites = [];
33199
+ const unresolvable = [];
33200
+ const newResources = {};
33201
+ for (const [logicalId, resource] of Object.entries(state.resources)) {
33202
+ if (orphanSet.has(logicalId))
33203
+ continue;
33204
+ const rewrittenProperties = await rewriteValue(
33205
+ resource.properties,
33206
+ `properties`,
33207
+ logicalId,
33208
+ orphanSet,
33209
+ fetcher,
33210
+ rewrites,
33211
+ unresolvable
33212
+ );
33213
+ const rewrittenAttributes = resource.attributes ? await rewriteValue(
33214
+ resource.attributes,
33215
+ `attributes`,
33216
+ logicalId,
33217
+ orphanSet,
33218
+ fetcher,
33219
+ rewrites,
33220
+ unresolvable
33221
+ ) : void 0;
33222
+ const newDeps = (resource.dependencies ?? []).filter((dep) => {
33223
+ if (orphanSet.has(dep)) {
33224
+ rewrites.push({
33225
+ logicalId,
33226
+ path: "dependencies",
33227
+ kind: "dependency",
33228
+ before: dep,
33229
+ after: null,
33230
+ orphanLogicalId: dep
33231
+ });
33232
+ return false;
33233
+ }
33234
+ return true;
33235
+ });
33236
+ newResources[logicalId] = {
33237
+ ...resource,
33238
+ properties: rewrittenProperties,
33239
+ ...rewrittenAttributes !== void 0 && {
33240
+ attributes: rewrittenAttributes
33241
+ },
33242
+ dependencies: newDeps
33243
+ };
33244
+ }
33245
+ const newOutputs = {};
33246
+ for (const [name, value] of Object.entries(state.outputs ?? {})) {
33247
+ newOutputs[name] = await rewriteValue(
33248
+ value,
33249
+ `outputs.${name}`,
33250
+ `<output:${name}>`,
33251
+ orphanSet,
33252
+ fetcher,
33253
+ rewrites,
33254
+ unresolvable
33255
+ );
33256
+ }
33257
+ return {
33258
+ state: {
33259
+ ...state,
33260
+ resources: newResources,
33261
+ outputs: newOutputs,
33262
+ lastModified: Date.now()
33263
+ },
33264
+ rewrites,
33265
+ unresolvable
33266
+ };
33267
+ }
33268
+ async function rewriteValue(value, pathPrefix, ownerLogicalId, orphanSet, fetcher, rewrites, unresolvable) {
33269
+ if (typeof value !== "object" || value === null)
33270
+ return value;
33271
+ if (Array.isArray(value)) {
33272
+ const out2 = [];
33273
+ for (let i = 0; i < value.length; i++) {
33274
+ out2.push(
33275
+ await rewriteValue(
33276
+ value[i],
33277
+ `${pathPrefix}[${i}]`,
33278
+ ownerLogicalId,
33279
+ orphanSet,
33280
+ fetcher,
33281
+ rewrites,
33282
+ unresolvable
33283
+ )
33284
+ );
33285
+ }
33286
+ return out2;
33287
+ }
33288
+ const obj = value;
33289
+ if ("Ref" in obj && Object.keys(obj).length === 1 && typeof obj["Ref"] === "string") {
33290
+ const target = obj["Ref"];
33291
+ if (orphanSet.has(target)) {
33292
+ const replaced = fetcher.ref(target);
33293
+ rewrites.push({
33294
+ logicalId: ownerLogicalId,
33295
+ path: pathPrefix,
33296
+ kind: "ref",
33297
+ before: { Ref: target },
33298
+ after: replaced,
33299
+ orphanLogicalId: target
33300
+ });
33301
+ return replaced;
33302
+ }
33303
+ return value;
33304
+ }
33305
+ if ("Fn::GetAtt" in obj && Object.keys(obj).length === 1) {
33306
+ const arg = obj["Fn::GetAtt"];
33307
+ let target;
33308
+ let attribute;
33309
+ if (Array.isArray(arg) && arg.length === 2 && typeof arg[0] === "string" && typeof arg[1] === "string") {
33310
+ target = arg[0];
33311
+ attribute = arg[1];
33312
+ } else if (typeof arg === "string") {
33313
+ const dot = arg.indexOf(".");
33314
+ if (dot > 0) {
33315
+ target = arg.slice(0, dot);
33316
+ attribute = arg.slice(dot + 1);
33317
+ }
33318
+ }
33319
+ if (target && attribute && orphanSet.has(target)) {
33320
+ const result = await fetcher.getAtt(target, attribute);
33321
+ if (result.ok) {
33322
+ rewrites.push({
33323
+ logicalId: ownerLogicalId,
33324
+ path: pathPrefix,
33325
+ kind: "getAtt",
33326
+ before: { "Fn::GetAtt": [target, attribute] },
33327
+ after: result.value,
33328
+ orphanLogicalId: target
33329
+ });
33330
+ return result.value;
33331
+ }
33332
+ unresolvable.push({
33333
+ logicalId: ownerLogicalId,
33334
+ path: pathPrefix,
33335
+ orphanLogicalId: target,
33336
+ attribute,
33337
+ reason: result.reason
33338
+ });
33339
+ return value;
33340
+ }
33341
+ return value;
33342
+ }
33343
+ if ("Fn::Sub" in obj && Object.keys(obj).length === 1) {
33344
+ const arg = obj["Fn::Sub"];
33345
+ let template;
33346
+ let varMap;
33347
+ if (typeof arg === "string") {
33348
+ template = arg;
33349
+ } else if (Array.isArray(arg) && arg.length === 2 && typeof arg[0] === "string" && typeof arg[1] === "object" && arg[1] !== null) {
33350
+ template = arg[0];
33351
+ varMap = arg[1];
33352
+ }
33353
+ if (template !== void 0) {
33354
+ const { rewritten, didChange, hasUnresolvable } = await rewriteSubTemplate(
33355
+ template,
33356
+ ownerLogicalId,
33357
+ pathPrefix,
33358
+ orphanSet,
33359
+ fetcher,
33360
+ rewrites,
33361
+ unresolvable,
33362
+ varMap
33363
+ );
33364
+ if (didChange) {
33365
+ const stillHasIntrinsics = /\$\{[^}]+\}/.test(rewritten);
33366
+ if (varMap && stillHasIntrinsics) {
33367
+ return { "Fn::Sub": [rewritten, varMap] };
33368
+ }
33369
+ if (stillHasIntrinsics) {
33370
+ return { "Fn::Sub": rewritten };
33371
+ }
33372
+ return rewritten;
33373
+ }
33374
+ return value;
33375
+ }
33376
+ return value;
33377
+ }
33378
+ const out = {};
33379
+ for (const [k, v] of Object.entries(obj)) {
33380
+ out[k] = await rewriteValue(
33381
+ v,
33382
+ pathPrefix === "" ? k : `${pathPrefix}.${k}`,
33383
+ ownerLogicalId,
33384
+ orphanSet,
33385
+ fetcher,
33386
+ rewrites,
33387
+ unresolvable
33388
+ );
33389
+ }
33390
+ return out;
33391
+ }
33392
+ async function rewriteSubTemplate(template, ownerLogicalId, pathPrefix, orphanSet, fetcher, rewrites, unresolvable, varMap) {
33393
+ const placeholderRe = /\$\{([^}]+)\}/g;
33394
+ const matches = [...template.matchAll(placeholderRe)];
33395
+ if (matches.length === 0) {
33396
+ return { rewritten: template, didChange: false, hasUnresolvable: false };
33397
+ }
33398
+ let didChange = false;
33399
+ let hasUnresolvable = false;
33400
+ let cursor = 0;
33401
+ let out = "";
33402
+ for (const m of matches) {
33403
+ const inner = m[1] ?? "";
33404
+ const start = m.index ?? 0;
33405
+ out += template.slice(cursor, start);
33406
+ cursor = start + m[0].length;
33407
+ if (varMap && inner in varMap) {
33408
+ out += m[0];
33409
+ continue;
33410
+ }
33411
+ const dot = inner.indexOf(".");
33412
+ if (dot < 0) {
33413
+ if (orphanSet.has(inner)) {
33414
+ const replaced = fetcher.ref(inner);
33415
+ rewrites.push({
33416
+ logicalId: ownerLogicalId,
33417
+ path: pathPrefix,
33418
+ kind: "sub",
33419
+ before: m[0],
33420
+ after: replaced,
33421
+ orphanLogicalId: inner
33422
+ });
33423
+ out += replaced;
33424
+ didChange = true;
33425
+ } else {
33426
+ out += m[0];
33427
+ }
33428
+ } else {
33429
+ const target = inner.slice(0, dot);
33430
+ const attribute = inner.slice(dot + 1);
33431
+ if (orphanSet.has(target)) {
33432
+ const result = await fetcher.getAtt(target, attribute);
33433
+ if (result.ok) {
33434
+ const stringified = String(result.value);
33435
+ rewrites.push({
33436
+ logicalId: ownerLogicalId,
33437
+ path: pathPrefix,
33438
+ kind: "sub",
33439
+ before: m[0],
33440
+ after: stringified,
33441
+ orphanLogicalId: target
33442
+ });
33443
+ out += stringified;
33444
+ didChange = true;
33445
+ } else {
33446
+ unresolvable.push({
33447
+ logicalId: ownerLogicalId,
33448
+ path: pathPrefix,
33449
+ orphanLogicalId: target,
33450
+ attribute,
33451
+ reason: result.reason
33452
+ });
33453
+ out += m[0];
33454
+ hasUnresolvable = true;
33455
+ }
33456
+ } else {
33457
+ out += m[0];
33458
+ }
33459
+ }
33460
+ }
33461
+ out += template.slice(cursor);
33462
+ return { rewritten: out, didChange, hasUnresolvable };
33463
+ }
33464
+
33465
+ // src/cli/commands/orphan.ts
33466
+ async function orphanCommand(pathArgs, options) {
32715
33467
  const logger = getLogger();
32716
33468
  if (options.verbose)
32717
33469
  logger.setLevel("debug");
32718
33470
  warnIfDeprecatedRegion(options);
33471
+ if (pathArgs.length === 0) {
33472
+ throw new Error(
33473
+ "'cdkd orphan' requires at least one construct path, e.g. 'cdkd orphan MyStack/MyTable'.\n To remove a stack's state record (the previous behavior), use:\n cdkd state orphan MyStack"
33474
+ );
33475
+ }
33476
+ for (const p of pathArgs) {
33477
+ if (!p.includes("/")) {
33478
+ throw new Error(
33479
+ `'cdkd orphan' now expects a construct path like 'MyStack/MyTable'.
33480
+ Got: '${p}'
33481
+ To remove a stack's state record (the previous behavior), use:
33482
+ cdkd state orphan ${p}`
33483
+ );
33484
+ }
33485
+ }
32719
33486
  const region = options.region || process.env["AWS_REGION"] || "us-east-1";
32720
33487
  const stateBucket = await resolveStateBucketWithDefault(options.stateBucket, region);
32721
- logger.info("Starting stack orphan...");
32722
- logger.debug("Options:", options);
32723
33488
  if (options.region) {
32724
33489
  process.env["AWS_REGION"] = options.region;
32725
33490
  process.env["AWS_DEFAULT_REGION"] = options.region;
@@ -32730,10 +33495,7 @@ async function orphanCommand(stackArgs, options) {
32730
33495
  });
32731
33496
  setAwsClients(awsClients);
32732
33497
  try {
32733
- const stateConfig = {
32734
- bucket: stateBucket,
32735
- prefix: options.statePrefix
32736
- };
33498
+ const stateConfig = { bucket: stateBucket, prefix: options.statePrefix };
32737
33499
  const stateBackend = new S3StateBackend(awsClients.s3, stateConfig, {
32738
33500
  ...options.region && { region: options.region },
32739
33501
  ...options.profile && { profile: options.profile }
@@ -32741,150 +33503,245 @@ async function orphanCommand(stackArgs, options) {
32741
33503
  await stateBackend.verifyBucketExists();
32742
33504
  const lockManager = new LockManager(awsClients.s3, stateConfig);
32743
33505
  const appCmd = options.app || resolveApp();
32744
- let appStacks = [];
32745
- if (appCmd) {
32746
- try {
32747
- const synthesizer = new Synthesizer();
32748
- const context = parseContextOptions(options.context);
32749
- const result = await synthesizer.synthesize({
32750
- app: appCmd,
32751
- output: options.output || "cdk.out",
32752
- ...Object.keys(context).length > 0 && { context }
32753
- });
32754
- appStacks = result.stacks.map((s) => ({
32755
- stackName: s.stackName,
32756
- displayName: s.displayName,
32757
- ...s.region && { region: s.region }
32758
- }));
32759
- } catch {
32760
- logger.debug("Could not synthesize app, falling back to state-based stack list");
32761
- }
32762
- }
32763
- const allStateRefs = await stateBackend.listStacks();
32764
- let candidateStacks;
32765
- if (appStacks.length > 0) {
32766
- const stateNames = new Set(allStateRefs.map((r) => r.stackName));
32767
- candidateStacks = appStacks.filter((s) => stateNames.has(s.stackName));
32768
- } else if (stackArgs.length > 0 || options.stack || options.all) {
32769
- const seen = /* @__PURE__ */ new Set();
32770
- candidateStacks = [];
32771
- for (const ref of allStateRefs) {
32772
- if (seen.has(ref.stackName))
32773
- continue;
32774
- seen.add(ref.stackName);
32775
- candidateStacks.push({ stackName: ref.stackName });
32776
- }
32777
- } else {
32778
- throw new Error(
32779
- "Could not determine which stacks belong to this app. Specify stack names explicitly, use --all, or ensure --app / cdk.json is configured."
32780
- );
32781
- }
32782
- const stackPatterns = stackArgs.length > 0 ? stackArgs : options.stack ? [options.stack] : [];
32783
- let stackNames;
32784
- if (options.all) {
32785
- stackNames = candidateStacks.map((s) => s.stackName);
32786
- } else if (stackPatterns.length > 0) {
32787
- stackNames = matchStacks(candidateStacks, stackPatterns).map((s) => s.stackName);
32788
- } else if (candidateStacks.length === 1) {
32789
- stackNames = candidateStacks.map((s) => s.stackName);
32790
- } else if (candidateStacks.length === 0) {
32791
- logger.info("No stacks found in state");
32792
- return;
32793
- } else {
33506
+ if (!appCmd) {
32794
33507
  throw new Error(
32795
- `Multiple stacks found: ${candidateStacks.map(describeStack).join(", ")}. Specify stack name(s) or use --all`
33508
+ "'cdkd orphan' requires a CDK app: pass --app or set it in cdk.json. The template is read to resolve construct paths to logical IDs."
32796
33509
  );
32797
33510
  }
32798
- if (stackNames.length === 0) {
32799
- logger.info("No matching stacks found in state");
32800
- return;
32801
- }
32802
- logger.info(`Found ${stackNames.length} stack(s) to orphan: ${stackNames.join(", ")}`);
32803
- const stateRefsByName = /* @__PURE__ */ new Map();
32804
- for (const ref of allStateRefs) {
32805
- const arr = stateRefsByName.get(ref.stackName) ?? [];
32806
- arr.push(ref);
32807
- stateRefsByName.set(ref.stackName, arr);
33511
+ logger.info("Synthesizing CDK app to read template...");
33512
+ const synthesizer = new Synthesizer();
33513
+ const context = parseContextOptions(options.context);
33514
+ const result = await synthesizer.synthesize({
33515
+ app: appCmd,
33516
+ output: options.output || "cdk.out",
33517
+ ...Object.keys(context).length > 0 && { context }
33518
+ });
33519
+ const resolved = resolveConstructPaths(pathArgs, result.stacks);
33520
+ const stackInfo = resolved.stack;
33521
+ const orphanLogicalIds = resolved.logicalIds;
33522
+ const targetRegion = await pickStackRegion(
33523
+ stateBackend,
33524
+ stackInfo.stackName,
33525
+ stackInfo.region,
33526
+ options.stackRegion
33527
+ );
33528
+ logger.info(
33529
+ `Target: ${stackInfo.stackName} (${targetRegion}); orphaning ${orphanLogicalIds.length} resource(s): ${orphanLogicalIds.join(", ")}`
33530
+ );
33531
+ const owner = `${process.env["USER"] || "unknown"}@${process.env["HOSTNAME"] || "host"}:${process.pid}`;
33532
+ if (!options.dryRun) {
33533
+ await lockManager.acquireLock(stackInfo.stackName, targetRegion, owner, "orphan");
32808
33534
  }
32809
- const skipConfirmation = options.yes || options.force;
32810
- for (const stackName of stackNames) {
32811
- const refs = stateRefsByName.get(stackName) ?? [];
32812
- if (refs.length === 0) {
32813
- logger.info(`No state found for stack: ${stackName}, skipping`);
32814
- continue;
33535
+ try {
33536
+ const stateData = await stateBackend.getState(stackInfo.stackName, targetRegion);
33537
+ if (!stateData) {
33538
+ throw new Error(
33539
+ `No state found for stack '${stackInfo.stackName}' (${targetRegion}). Nothing to orphan. (Did the stack get deployed?)`
33540
+ );
32815
33541
  }
32816
- const targets = options.stackRegion ? refs.filter((r) => r.region === options.stackRegion) : refs;
32817
- if (targets.length === 0) {
32818
- const seen = refs.map((r) => r.region ?? "(legacy)").join(", ");
33542
+ const { state, etag, migrationPending } = stateData;
33543
+ const missing = orphanLogicalIds.filter((id) => !(id in state.resources));
33544
+ if (missing.length > 0) {
33545
+ const have = Object.keys(state.resources).join(", ");
32819
33546
  throw new Error(
32820
- `No state found for stack '${stackName}' in region '${options.stackRegion}'. Available regions: ${seen}.`
33547
+ `Resource(s) not in state for stack '${stackInfo.stackName}' (${targetRegion}): ${missing.join(", ")}.
33548
+ Available logical IDs: ${have}`
32821
33549
  );
32822
33550
  }
32823
- if (!options.force) {
32824
- for (const target of targets) {
32825
- const locked = await lockManager.isLocked(stackName, target.region);
32826
- if (locked) {
32827
- const where = target.region ?? "(legacy)";
32828
- throw new Error(
32829
- `Stack '${stackName}' (${where}) is locked. Run 'cdkd force-unlock ${stackName}${target.region ? ` --stack-region ${target.region}` : ""}' first, or pass --force to orphan anyway.`
32830
- );
32831
- }
32832
- }
33551
+ const providerRegistry = new ProviderRegistry();
33552
+ registerAllProviders(providerRegistry);
33553
+ const rewriteResult = await rewriteResourceReferences(
33554
+ state,
33555
+ orphanLogicalIds,
33556
+ providerRegistry,
33557
+ { force: options.force }
33558
+ );
33559
+ printRewriteSummary(rewriteResult.rewrites, orphanLogicalIds);
33560
+ if (rewriteResult.unresolvable.length > 0 && !options.force) {
33561
+ printUnresolvable(rewriteResult.unresolvable);
33562
+ throw new Error(
33563
+ `Orphan aborted: ${rewriteResult.unresolvable.length} reference(s) could not be resolved.
33564
+ Re-run with --force to fall back to cached attribute values from state, or fix the underlying provider/AWS issue and retry.`
33565
+ );
32833
33566
  }
32834
- if (!skipConfirmation) {
32835
- const targetList = targets.map((t) => t.region ? `${stackName} (${t.region})` : stackName).join(", ");
32836
- process.stdout.write(
32837
- `
32838
- WARNING: This removes cdkd's state record for [${targetList}] only. AWS resources will NOT be deleted.
32839
- Use 'cdkd destroy ${stackName}' if you want to delete the actual resources.
32840
-
32841
- `
33567
+ if (rewriteResult.unresolvable.length > 0) {
33568
+ printUnresolvable(rewriteResult.unresolvable);
33569
+ logger.warn(
33570
+ `--force: continuing despite ${rewriteResult.unresolvable.length} unresolved reference(s); the original intrinsic was left in place where the cache also lacked the value.`
32842
33571
  );
32843
- const rl = readline2.createInterface({
32844
- input: process.stdin,
32845
- output: process.stdout
32846
- });
32847
- const answer = await rl.question(
32848
- `Orphan state for ${targetList} from s3://${stateBucket}/${options.statePrefix}/? (y/N): `
33572
+ }
33573
+ if (options.dryRun) {
33574
+ logger.info("--dry-run: state will NOT be written. Re-run without --dry-run to apply.");
33575
+ return;
33576
+ }
33577
+ if (!options.yes && !options.force) {
33578
+ const ok = await confirmPrompt(
33579
+ `Orphan ${orphanLogicalIds.length} resource(s) from cdkd state for ${stackInfo.stackName} (${targetRegion})? AWS resources will NOT be deleted.`
32849
33580
  );
32850
- rl.close();
32851
- const trimmed = answer.trim().toLowerCase();
32852
- if (trimmed !== "y" && trimmed !== "yes") {
32853
- logger.info(`Cancelled orphan of stack: ${stackName}`);
32854
- continue;
33581
+ if (!ok) {
33582
+ logger.info("Orphan cancelled.");
33583
+ return;
32855
33584
  }
32856
33585
  }
32857
- for (const target of targets) {
32858
- if (target.region) {
32859
- await stateBackend.deleteState(stackName, target.region);
32860
- await lockManager.forceReleaseLock(stackName, target.region);
32861
- } else {
32862
- await lockManager.forceReleaseLock(stackName, void 0);
32863
- }
32864
- const label = target.region ? `${stackName} (${target.region})` : stackName;
32865
- logger.info(`\u2713 Orphaned state for stack: ${label}`);
33586
+ await stateBackend.saveState(stackInfo.stackName, targetRegion, rewriteResult.state, {
33587
+ expectedEtag: etag,
33588
+ ...migrationPending && { migrateLegacy: true }
33589
+ });
33590
+ logger.info(
33591
+ `Orphaned ${orphanLogicalIds.length} resource(s) from state: ${stackInfo.stackName} (${targetRegion}). AWS resources are still in AWS; cdkd will no longer manage them.`
33592
+ );
33593
+ } finally {
33594
+ if (!options.dryRun) {
33595
+ await lockManager.releaseLock(stackInfo.stackName, targetRegion).catch((err) => {
33596
+ logger.warn(
33597
+ `Failed to release lock: ${err instanceof Error ? err.message : String(err)}`
33598
+ );
33599
+ });
32866
33600
  }
32867
33601
  }
32868
33602
  } finally {
32869
33603
  awsClients.destroy();
32870
33604
  }
32871
33605
  }
33606
+ function resolveConstructPaths(paths, stacks) {
33607
+ const byStackName = /* @__PURE__ */ new Map();
33608
+ const byDisplayName = /* @__PURE__ */ new Map();
33609
+ for (const s of stacks) {
33610
+ byStackName.set(s.stackName, s);
33611
+ byDisplayName.set(s.displayName, s);
33612
+ }
33613
+ let stack;
33614
+ const logicalIds = [];
33615
+ for (const p of paths) {
33616
+ const slash = p.indexOf("/");
33617
+ if (slash <= 0 || slash === p.length - 1) {
33618
+ throw new Error(`Invalid construct path '${p}'. Expected '<StackName>/<Path/To/Resource>'.`);
33619
+ }
33620
+ const head = p.slice(0, slash);
33621
+ const candidate = byDisplayName.get(head) ?? byStackName.get(head);
33622
+ if (!candidate) {
33623
+ const available = stacks.map((s) => s.displayName ?? s.stackName).join(", ");
33624
+ throw new Error(
33625
+ `Construct path '${p}': stack '${head}' not found in synthesized app. Available: ${available}`
33626
+ );
33627
+ }
33628
+ if (stack === void 0) {
33629
+ stack = candidate;
33630
+ } else if (stack.stackName !== candidate.stackName) {
33631
+ throw new Error(
33632
+ `All construct paths must reference the same stack. Got '${stack.stackName}' and '${candidate.stackName}'. Run 'cdkd orphan' once per stack.`
33633
+ );
33634
+ }
33635
+ const cdkPath = p;
33636
+ const index = buildCdkPathIndex(candidate.template);
33637
+ const logicalId = index.get(cdkPath);
33638
+ if (!logicalId) {
33639
+ const available = [...index.keys()].sort().join("\n ");
33640
+ throw new Error(
33641
+ `Construct path '${cdkPath}' not found in template for stack '${candidate.stackName}'.
33642
+ Available paths:
33643
+ ${available}`
33644
+ );
33645
+ }
33646
+ if (!logicalIds.includes(logicalId)) {
33647
+ logicalIds.push(logicalId);
33648
+ }
33649
+ }
33650
+ if (!stack) {
33651
+ throw new Error("No construct paths supplied.");
33652
+ }
33653
+ return { stack, logicalIds };
33654
+ }
33655
+ async function pickStackRegion(stateBackend, stackName, synthRegion, flag) {
33656
+ const refs = (await stateBackend.listStacks()).filter((r) => r.stackName === stackName);
33657
+ if (refs.length === 0) {
33658
+ if (flag)
33659
+ return flag;
33660
+ if (synthRegion)
33661
+ return synthRegion;
33662
+ throw new Error(
33663
+ `No state found for stack '${stackName}'. Run 'cdkd state list' to see available stacks.`
33664
+ );
33665
+ }
33666
+ if (flag) {
33667
+ const found = refs.find((r) => r.region === flag);
33668
+ if (!found) {
33669
+ const seen = refs.map((r) => r.region ?? "(legacy)").join(", ");
33670
+ throw new Error(
33671
+ `No state found for stack '${stackName}' in region '${flag}'. Available regions: ${seen}.`
33672
+ );
33673
+ }
33674
+ return flag;
33675
+ }
33676
+ if (synthRegion) {
33677
+ const found = refs.find((r) => r.region === synthRegion);
33678
+ if (found)
33679
+ return synthRegion;
33680
+ }
33681
+ if (refs.length === 1) {
33682
+ return refs[0].region ?? synthRegion ?? "";
33683
+ }
33684
+ const regions = refs.map((r) => r.region ?? "(legacy)").join(", ");
33685
+ throw new Error(
33686
+ `Stack '${stackName}' has state in multiple regions: ${regions}. Re-run with --stack-region <region> to disambiguate.`
33687
+ );
33688
+ }
33689
+ function printRewriteSummary(rewrites, orphanLogicalIds) {
33690
+ const logger = getLogger();
33691
+ logger.info("");
33692
+ logger.info(`Orphaning ${orphanLogicalIds.length} resource(s): ${orphanLogicalIds.join(", ")}`);
33693
+ if (rewrites.length === 0) {
33694
+ logger.info(" No sibling references \u2014 every reference was already to a non-orphan resource.");
33695
+ return;
33696
+ }
33697
+ logger.info(`Applied ${rewrites.length} rewrite(s):`);
33698
+ for (const r of rewrites) {
33699
+ const before = stringifyForAudit(r.before);
33700
+ const after = r.kind === "dependency" ? "(dropped)" : stringifyForAudit(r.after);
33701
+ logger.info(` [${r.kind}] ${r.logicalId}.${r.path}: ${before} \u2192 ${after}`);
33702
+ }
33703
+ }
33704
+ function printUnresolvable(unresolvable) {
33705
+ const logger = getLogger();
33706
+ logger.error(`${unresolvable.length} reference(s) could not be resolved:`);
33707
+ for (const u of unresolvable) {
33708
+ logger.error(` ${u.logicalId}.${u.path}: ${u.orphanLogicalId}.${u.attribute} \u2014 ${u.reason}`);
33709
+ }
33710
+ }
33711
+ function stringifyForAudit(value) {
33712
+ if (typeof value === "string")
33713
+ return JSON.stringify(value);
33714
+ return JSON.stringify(value);
33715
+ }
33716
+ async function confirmPrompt(prompt) {
33717
+ const rl = readline2.createInterface({ input: process.stdin, output: process.stdout });
33718
+ try {
33719
+ const ans = await rl.question(`${prompt} [y/N] `);
33720
+ return /^y(es)?$/i.test(ans.trim());
33721
+ } finally {
33722
+ rl.close();
33723
+ }
33724
+ }
32872
33725
  function createOrphanCommand() {
32873
33726
  const cmd = new Command7("orphan").description(
32874
- "Remove cdkd's state record for one or more stacks (does NOT delete AWS resources). Synth-driven; for the CDK-app-free version use 'cdkd state orphan'."
33727
+ "Remove one or more resources from cdkd state by construct path (does NOT delete AWS resources). Mirrors aws-cdk-cli's 'cdk orphan --unstable=orphan'. Synth-driven; for the previous whole-stack-orphan behavior, use 'cdkd state orphan <stack>'."
32875
33728
  ).argument(
32876
- "[stacks...]",
32877
- "Stack name(s) to orphan. Accepts physical CloudFormation names (e.g. 'MyStage-Api') or CDK display paths (e.g. 'MyStage/Api'). Supports wildcards (e.g. 'MyStage/*')."
32878
- ).option("--all", "Orphan all stacks in the current app", false).option(
33729
+ "<paths...>",
33730
+ "Construct paths to orphan, e.g. 'MyStack/MyTable'. Multiple paths must reference the same stack."
33731
+ ).option(
32879
33732
  "--stack-region <region>",
32880
33733
  "Region of the stack record to operate on. Required when the same stack name has state in multiple regions."
33734
+ ).option(
33735
+ "--dry-run",
33736
+ "Compute and print the rewrite audit table without acquiring a lock or saving state.",
33737
+ false
32881
33738
  ).action(withErrorHandling(orphanCommand));
32882
33739
  [
32883
33740
  ...commonOptions,
32884
33741
  ...appOptions,
32885
33742
  ...stateOptions,
32886
- ...stackOptions,
32887
33743
  ...destroyOptions,
33744
+ // adds -f / --force (escape hatch for unresolvable references + skip confirm)
32888
33745
  ...contextOptions
32889
33746
  ].forEach((opt) => cmd.addOption(opt));
32890
33747
  cmd.addOption(deprecatedRegionOption);
@@ -33076,7 +33933,7 @@ async function stateMigrateCommand(options) {
33076
33933
  }
33077
33934
  if (!options.yes) {
33078
33935
  const action = options.removeLegacy ? "and DELETE the source bucket" : "(source bucket will be kept)";
33079
- const ok = await confirmPrompt(
33936
+ const ok = await confirmPrompt2(
33080
33937
  `Copy ${sourceObjects.length} object(s) from ${legacyBucket} -> ${newBucket} ${action}?`
33081
33938
  );
33082
33939
  if (!ok) {
@@ -33281,7 +34138,7 @@ async function emptyBucketAllVersions(s3, bucket) {
33281
34138
  versionIdMarker = resp.NextVersionIdMarker;
33282
34139
  } while (keyMarker || versionIdMarker);
33283
34140
  }
33284
- async function confirmPrompt(prompt) {
34141
+ async function confirmPrompt2(prompt) {
33285
34142
  const rl = readline3.createInterface({ input: process.stdin, output: process.stdout });
33286
34143
  try {
33287
34144
  const ans = await rl.question(`${prompt} [y/N] `);
@@ -33536,7 +34393,7 @@ function formatAttributeValue(value) {
33536
34393
  }
33537
34394
  return JSON.stringify(value);
33538
34395
  }
33539
- function formatDuration(ms) {
34396
+ function formatDuration2(ms) {
33540
34397
  const seconds = Math.floor(ms / 1e3);
33541
34398
  if (seconds < 60)
33542
34399
  return `${seconds}s`;
@@ -33549,7 +34406,7 @@ function formatLockSummary(lockInfo) {
33549
34406
  return "unlocked";
33550
34407
  const opStr = lockInfo.operation ? ` (operation: ${lockInfo.operation})` : "";
33551
34408
  const expiresInMs = lockInfo.expiresAt - Date.now();
33552
- const expiresStr = expiresInMs > 0 ? `expires in ${formatDuration(expiresInMs)}` : `expired ${formatDuration(-expiresInMs)} ago`;
34409
+ const expiresStr = expiresInMs > 0 ? `expires in ${formatDuration2(expiresInMs)}` : `expired ${formatDuration2(-expiresInMs)} ago`;
33553
34410
  return `locked by ${lockInfo.owner}${opStr}, ${expiresStr}`;
33554
34411
  }
33555
34412
  function createStateResourcesCommand() {
@@ -33737,6 +34594,10 @@ async function stateDestroyCommand(stackArgs, options) {
33737
34594
  logger.setLevel("debug");
33738
34595
  process.env["CDKD_NO_LIVE"] = "1";
33739
34596
  }
34597
+ validateResourceTimeouts({
34598
+ ...options.resourceWarnAfter && { resourceWarnAfter: options.resourceWarnAfter },
34599
+ ...options.resourceTimeout && { resourceTimeout: options.resourceTimeout }
34600
+ });
33740
34601
  if (!options.all && stackArgs.length === 0) {
33741
34602
  throw new Error(
33742
34603
  "Stack name is required. Usage: cdkd state destroy <stack> [<stack>...] | --all"
@@ -33836,7 +34697,19 @@ Preparing to destroy stack: ${stackName}${ref.region ? ` (${ref.region})` : ""}`
33836
34697
  // and the per-stack prompt inside the runner. Per-stack prompts are
33837
34698
  // skipped when `options.yes` is set OR `--all` was set (the user
33838
34699
  // already accepted the batch prompt).
33839
- skipConfirmation: options.yes || options.all === true
34700
+ skipConfirmation: options.yes || options.all === true,
34701
+ ...options.resourceWarnAfter?.globalMs !== void 0 && {
34702
+ resourceWarnAfterMs: options.resourceWarnAfter.globalMs
34703
+ },
34704
+ ...options.resourceTimeout?.globalMs !== void 0 && {
34705
+ resourceTimeoutMs: options.resourceTimeout.globalMs
34706
+ },
34707
+ ...options.resourceWarnAfter?.perTypeMs && {
34708
+ resourceWarnAfterByType: options.resourceWarnAfter.perTypeMs
34709
+ },
34710
+ ...options.resourceTimeout?.perTypeMs && {
34711
+ resourceTimeoutByType: options.resourceTimeout.perTypeMs
34712
+ }
33840
34713
  });
33841
34714
  totalErrors += result.errorCount;
33842
34715
  }
@@ -33867,7 +34740,9 @@ function createStateDestroyCommand() {
33867
34740
  "For removing only the state record (keeping AWS resources intact), use 'cdkd state orphan'."
33868
34741
  ].join("\n")
33869
34742
  ).action(withErrorHandling(stateDestroyCommand));
33870
- [...commonOptions, ...stateOptions].forEach((opt) => cmd.addOption(opt));
34743
+ [...commonOptions, ...stateOptions, ...resourceTimeoutOptions].forEach(
34744
+ (opt) => cmd.addOption(opt)
34745
+ );
33871
34746
  cmd.addOption(deprecatedRegionOption);
33872
34747
  return cmd;
33873
34748
  }
@@ -34002,7 +34877,7 @@ function createStateCommand() {
34002
34877
  }
34003
34878
 
34004
34879
  // src/cli/commands/import.ts
34005
- import { readFileSync as readFileSync5 } from "node:fs";
34880
+ import { readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "node:fs";
34006
34881
  import * as readline5 from "node:readline/promises";
34007
34882
  import { Command as Command12 } from "commander";
34008
34883
  init_aws_clients();
@@ -34121,6 +34996,9 @@ async function importCommand(stackArg, options) {
34121
34996
  rows.push(outcome);
34122
34997
  }
34123
34998
  printSummary(rows);
34999
+ if (options.recordResourceMapping) {
35000
+ writeRecordedMapping(options.recordResourceMapping, rows);
35001
+ }
34124
35002
  if (options.dryRun) {
34125
35003
  logger.info("--dry-run: state will NOT be written. Re-run without --dry-run to apply.");
34126
35004
  return;
@@ -34131,7 +35009,7 @@ async function importCommand(stackArg, options) {
34131
35009
  return;
34132
35010
  }
34133
35011
  if (!options.yes) {
34134
- const ok = await confirmPrompt2(
35012
+ const ok = await confirmPrompt3(
34135
35013
  `Write state for ${stackInfo.stackName} (${targetRegion}) with ${importedRows.length} resource(s)?`
34136
35014
  );
34137
35015
  if (!ok) {
@@ -34274,12 +35152,24 @@ function parseMappingJson(raw, source) {
34274
35152
  }
34275
35153
  return out;
34276
35154
  }
34277
- function readCdkPath(resource) {
34278
- const meta = resource.Metadata;
34279
- if (!meta)
34280
- return "";
34281
- const v = meta["aws:cdk:path"];
34282
- return typeof v === "string" ? v : "";
35155
+ function writeRecordedMapping(filePath, rows) {
35156
+ const logger = getLogger();
35157
+ const map = {};
35158
+ for (const row of rows) {
35159
+ if (row.outcome === "imported" && row.physicalId) {
35160
+ map[row.logicalId] = row.physicalId;
35161
+ }
35162
+ }
35163
+ const body = JSON.stringify(map, null, 2) + "\n";
35164
+ try {
35165
+ writeFileSync4(filePath, body, "utf-8");
35166
+ logger.info(`Wrote resolved mapping to ${filePath} (${Object.keys(map).length} entry(ies))`);
35167
+ } catch (err) {
35168
+ const msg = err instanceof Error ? err.message : String(err);
35169
+ logger.error(
35170
+ `Failed to write --record-resource-mapping file '${filePath}': ${msg}. Continuing \u2014 the import already resolved every physical id in memory.`
35171
+ );
35172
+ }
34283
35173
  }
34284
35174
  function collectImportableResources(template) {
34285
35175
  const out = [];
@@ -34352,7 +35242,7 @@ function formatOutcome(outcome) {
34352
35242
  return "\u2717";
34353
35243
  }
34354
35244
  }
34355
- async function confirmPrompt2(prompt) {
35245
+ async function confirmPrompt3(prompt) {
34356
35246
  const rl = readline5.createInterface({ input: process.stdin, output: process.stdout });
34357
35247
  try {
34358
35248
  const ans = await rl.question(`${prompt} [y/N] `);
@@ -34378,6 +35268,9 @@ function createImportCommand() {
34378
35268
  ).option(
34379
35269
  "--resource-mapping-inline <json>",
34380
35270
  "Inline JSON object of {logicalId: physicalId} overrides (CDK CLI `cdk import --resource-mapping-inline` compatible). Same shape as --resource-mapping but supplied as a string \u2014 useful for non-TTY CI scripts that do not want a separate file. Implies selective mode unless --auto is set. Mutually exclusive with --resource-mapping."
35271
+ ).option(
35272
+ "--record-resource-mapping <file>",
35273
+ 'After cdkd resolves every logical ID (via --resource / --resource-mapping / tag-based auto-lookup), write the resulting {logicalId: physicalId} map to <file> as JSON. Useful in auto / hybrid mode for capturing the tag-resolved mapping and feeding it back as --resource-mapping in non-interactive CI re-runs. Written before the confirmation prompt (so the user can review the file before saying "yes") and even when the user says "no". Mirrors `cdk import --record-resource-mapping`.'
34381
35274
  ).option(
34382
35275
  "--auto",
34383
35276
  "Hybrid mode: when explicit overrides are supplied, ALSO tag-import every other resource in the template. Without this flag, --resource / --resource-mapping behave as a whitelist (CDK CLI parity).",
@@ -34423,7 +35316,7 @@ function reorderArgs(argv) {
34423
35316
  }
34424
35317
  async function main() {
34425
35318
  const program = new Command13();
34426
- program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.24.0");
35319
+ program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.26.0");
34427
35320
  program.addCommand(createBootstrapCommand());
34428
35321
  program.addCommand(createSynthCommand());
34429
35322
  program.addCommand(createListCommand());