@go-to-k/cdkd 0.149.0 → 0.150.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
@@ -36057,6 +36057,7 @@ async function exportCommand(stackArg, options) {
36057
36057
  let synthedRegion;
36058
36058
  let templateFormat = "json";
36059
36059
  let allSynthStacks = [];
36060
+ let rootNestedTemplatePaths = {};
36060
36061
  if (options.template) {
36061
36062
  if (!stackArg) throw new Error("--template requires a stack name as a positional argument to identify the cdkd state record.");
36062
36063
  const parsed = parseTemplateFile(options.template);
@@ -36085,6 +36086,7 @@ async function exportCommand(stackArg, options) {
36085
36086
  template = stackInfo.template;
36086
36087
  resolvedStackName = stackInfo.stackName;
36087
36088
  synthedRegion = stackInfo.region;
36089
+ rootNestedTemplatePaths = stackInfo.nestedTemplates ?? {};
36088
36090
  allSynthStacks = result.stacks.map((s) => ({
36089
36091
  stackName: s.stackName,
36090
36092
  template: s.template
@@ -36109,16 +36111,55 @@ async function exportCommand(stackArg, options) {
36109
36111
  throw new Error(`${blocked.length} resource(s) block migration. Either destroy them first (cdkd destroy / cdkd state destroy cherry-picked), or remove them from the CDK app and re-synthesize.`);
36110
36112
  }
36111
36113
  if (nestedStackRows.length > 0) {
36112
- const leafFirst = flattenCdkdStateTreeLeafFirst(await buildCdkdStateStackTree(resolvedStackName, targetRegion, stateBackend, state));
36113
- logger.info(`Stack '${resolvedStackName}' contains ${nestedStackRows.length} top-level nested stack row(s); cdkd state tree spans ${leafFirst.length} stack(s) total (leaf-first migration order):`);
36114
- for (const node of leafFirst) logger.info(` - cdkd/${node.stackName}/${node.region}/state.json`);
36115
- const deferralMessage = `cdkd export does not yet submit nested-stack trees to CloudFormation (${nestedStackRows.length} top-level nested-stack row(s) detected; ${leafFirst.length} cdkd state record(s) in the tree). Recursive --include-nested-stacks IMPORT changeset submission is tracked under issue #464 PR B2. Workarounds until PR B2 ships:\n - Keep the stack on cdkd (recommended — nested-stack deploy / destroy / drift already work via the SDK provider path).\n - OR destroy the nested children first via 'cdkd state destroy <child>' (leaf-first), then re-run 'cdkd export <parent>' against the flattened parent.`;
36116
- if (options.dryRun) {
36117
- logger.warn(deferralMessage);
36118
- logger.info("--dry-run: no CloudFormation changeset will be created.");
36119
- return;
36114
+ const nestedStackTree = await buildCdkdStateStackTree(resolvedStackName, targetRegion, stateBackend, state);
36115
+ reportDriftBaselineGaps(state, logger);
36116
+ if (allSynthStacks.length > 0) {
36117
+ const crossRefs = scanCrossStackReferences(allSynthStacks, resolvedStackName);
36118
+ if (crossRefs.length > 0) {
36119
+ const lines = crossRefs.map((r) => ` ${r.consumerStackName} → ${resolvedStackName}.${r.outputName} at ${r.location}`);
36120
+ if (options.strictCrossStack) throw new Error(`Refusing to export: ${crossRefs.length} cross-stack reference(s) to ${resolvedStackName} found in sibling stacks. After migration, those references will break (cdkd's Fn::GetStackOutput reads cdkd state; the migrated stack's outputs live in CFn). Migrate consumers first, or remove the references, or drop --strict-cross-stack to proceed with a warning:\n` + lines.join("\n"));
36121
+ logger.warn(`${crossRefs.length} cross-stack reference(s) to '${resolvedStackName}' from sibling stacks. These will break the next time those stacks deploy via cdkd (cdkd's Fn::GetStackOutput resolver reads cdkd state; the migrated stack's outputs are now in CFn). Plan multi-stack migrations from the leaves up.`);
36122
+ for (const line of lines) logger.warn(line);
36123
+ }
36120
36124
  }
36121
- throw new Error(deferralMessage);
36125
+ const userParametersNested = parseParameterOverrides(options.parameter);
36126
+ const { parameters: rootParametersForNested, missing: missingNested } = resolveTemplateParameters(template, userParametersNested);
36127
+ if (missingNested.length > 0) throw new Error(`Template requires parameter(s) without defaults: ${missingNested.join(", ")}. Pass each one as --parameter Key=Value (or set a Default in the CDK code).`);
36128
+ const childOverrides = parseCfnChildStackNameOverrides(options.cfnChildStackName);
36129
+ if ((await runPerStackImportLoop({
36130
+ rootStackName: resolvedStackName,
36131
+ rootRegion: targetRegion,
36132
+ rootStackInfoNestedTemplates: rootNestedTemplatePaths,
36133
+ rootTemplateFormat: templateFormat,
36134
+ tree: nestedStackTree,
36135
+ rootTemplate: template,
36136
+ cfnStackNameOverrides: {
36137
+ root: options.cfnStackName,
36138
+ childMap: childOverrides
36139
+ },
36140
+ rootParameters: rootParametersForNested,
36141
+ deps: {
36142
+ cfnClient: awsClients.cloudFormation,
36143
+ stateBackend,
36144
+ lockManager,
36145
+ uploadOpts: {
36146
+ stateBucket,
36147
+ ...options.profile && { s3ClientOpts: { profile: options.profile } }
36148
+ },
36149
+ lockOwner: owner
36150
+ },
36151
+ options: {
36152
+ dryRun: options.dryRun,
36153
+ yes: options.yes,
36154
+ includeNonImportable: options.includeNonImportable,
36155
+ recreateImportUnsupported: options.recreateImportUnsupported
36156
+ }
36157
+ })).outcome === "success") printNextSteps({
36158
+ cfnStackName: options.cfnStackName ?? cdkd2cfnStackName(resolvedStackName),
36159
+ cdkStackName: resolvedStackName,
36160
+ contextOverrides: options.context ?? []
36161
+ });
36162
+ return;
36122
36163
  }
36123
36164
  if (phase1Imports.length === 0 && phase2Creates.length === 0 && recreateBeforePhase2.length === 0) {
36124
36165
  logger.warn("No resources to migrate — cdkd state is empty.");
@@ -36378,6 +36419,152 @@ function flattenCdkdStateTreeLeafFirst(tree) {
36378
36419
  }
36379
36420
  }
36380
36421
  /**
36422
+ * Map a cdkd stack name to a CloudFormation-compatible stack name.
36423
+ *
36424
+ * Required because the cdkd v6 state-key form `<parent>~<childLogicalId>`
36425
+ * contains `~`, which CFn rejects in stack names (CFn accepts
36426
+ * `[a-zA-Z][-a-zA-Z0-9]*` only). The substitution chooses `-` to mirror
36427
+ * CFn's own auto-naming convention for nested stacks
36428
+ * (`<Parent>-<ChildLogicalId>-<RandomSuffix>` minus the suffix).
36429
+ *
36430
+ * Top-level cdkd stack names that already conform to CFn's character set
36431
+ * are passed through unchanged — `cdkd2cfnStackName('MyApp') === 'MyApp'`.
36432
+ *
36433
+ * Issue #464 design §9 Q8.
36434
+ */
36435
+ function cdkd2cfnStackName(cdkdStackName) {
36436
+ return cdkdStackName.replace(/~/g, "-");
36437
+ }
36438
+ /**
36439
+ * Parse the repeatable `--cfn-child-stack-name <cdkdName>=<cfnName>` CLI
36440
+ * flag into a lookup map. Each entry maps a cdkd stack name (v6 form,
36441
+ * e.g. `MyApp~Database`) to the desired CFn stack name.
36442
+ *
36443
+ * Throws on a malformed entry (no `=`, empty cdkdName, empty cfnName, or
36444
+ * a CFn name that violates the CFn naming constraint) so the user sees
36445
+ * the problem before any AWS-side mutation. Duplicate cdkdName keys are
36446
+ * rejected to avoid silent last-wins behavior.
36447
+ *
36448
+ * Exported for unit testing.
36449
+ */
36450
+ function parseCfnChildStackNameOverrides(values) {
36451
+ const out = /* @__PURE__ */ new Map();
36452
+ if (!values || values.length === 0) return out;
36453
+ for (const raw of values) {
36454
+ const eq = raw.indexOf("=");
36455
+ if (eq < 0) throw new Error(`--cfn-child-stack-name '${raw}' is not in <cdkdName>=<cfnName> form. Example: --cfn-child-stack-name 'MyApp~Database=my-app-database'.`);
36456
+ const cdkdName = raw.slice(0, eq).trim();
36457
+ const cfnName = raw.slice(eq + 1).trim();
36458
+ if (!cdkdName) throw new Error(`--cfn-child-stack-name '${raw}' has an empty cdkd stack name.`);
36459
+ if (!cfnName) throw new Error(`--cfn-child-stack-name '${raw}' has an empty CFn stack name.`);
36460
+ if (!/^[a-zA-Z][-a-zA-Z0-9]*$/.test(cfnName)) throw new Error(`--cfn-child-stack-name '${raw}': CFn stack name '${cfnName}' must match [a-zA-Z][-a-zA-Z0-9]* (no '~', no '/', no '_', no '.').`);
36461
+ if (out.has(cdkdName)) throw new Error(`--cfn-child-stack-name: duplicate override for cdkd stack '${cdkdName}'. Pass it once with the final CFn name.`);
36462
+ out.set(cdkdName, cfnName);
36463
+ }
36464
+ return out;
36465
+ }
36466
+ /**
36467
+ * Read + parse a nested-stack child template from the synth cloud assembly.
36468
+ * The path is the absolute filesystem path AssemblyReader populated from
36469
+ * the parent template's `Metadata['aws:asset:path']` on each
36470
+ * `AWS::CloudFormation::Stack` resource.
36471
+ *
36472
+ * Format-aware: CDK 2.x always synthesizes JSON for nested templates, but
36473
+ * the codec recognizes both JSON and YAML so the helper is forward-compatible
36474
+ * with any synth output (and matches `parseTemplateFile` for `--template`).
36475
+ *
36476
+ * Sibling of `readNestedChildTemplate` in `import.ts`. Kept separate so
36477
+ * `export.ts` does not depend on `import.ts` internals and the return
36478
+ * shape (`Record<string, unknown>` + `TemplateFormat`) matches what
36479
+ * `executeImportChangeSet` / `executeUpdateChangeSet` consume. Consolidate
36480
+ * into a shared `nested-template-fs.ts` module if a third caller appears.
36481
+ */
36482
+ function readNestedChildTemplateFile(templatePath, childLogicalId) {
36483
+ let raw;
36484
+ try {
36485
+ raw = readFileSync(templatePath, "utf-8");
36486
+ } catch (err) {
36487
+ throw new Error(`cdkd export: failed to read nested-stack template for '${childLogicalId}' at '${templatePath}': ${err instanceof Error ? err.message : String(err)}`);
36488
+ }
36489
+ try {
36490
+ return parseCfnTemplateWithFormat(raw);
36491
+ } catch (err) {
36492
+ throw new Error(`cdkd export: failed to parse nested-stack template for '${childLogicalId}' at '${templatePath}': ${err instanceof Error ? err.message : String(err)}`);
36493
+ }
36494
+ }
36495
+ /**
36496
+ * Index every `AWS::CloudFormation::Stack` row in `template` by its
36497
+ * `Metadata['aws:asset:path']`, returning `{logicalId: <absolute-path>}`.
36498
+ * Mirrors `AssemblyReader.extractStackInfo`'s nested-template indexing
36499
+ * (line ~277 of `assembly-reader.ts`) so the same logic applies to
36500
+ * arbitrary depth — grandchild templates are siblings of their parent
36501
+ * child template in the same cdk.out subdirectory.
36502
+ *
36503
+ * Refuses absolute `aws:asset:path` values (CDK emits relative paths
36504
+ * only; an absolute path indicates hand-modified synth output or a
36505
+ * non-CDK toolchain — `path.join(dir, '/abs/foo')` would silently bypass
36506
+ * the `dir` argument).
36507
+ *
36508
+ * Sibling of `indexGrandchildTemplatePaths` in `import.ts`. Same rationale
36509
+ * for separate definitions as `readNestedChildTemplateFile` above.
36510
+ */
36511
+ function indexNestedTemplatePaths(template, templateDir) {
36512
+ const result = {};
36513
+ const resources = template["Resources"];
36514
+ if (!resources || typeof resources !== "object" || Array.isArray(resources)) return result;
36515
+ for (const [logicalId, resource] of Object.entries(resources)) {
36516
+ if (!resource || typeof resource !== "object" || Array.isArray(resource)) continue;
36517
+ const r = resource;
36518
+ if (r.Type !== "AWS::CloudFormation::Stack") continue;
36519
+ const assetPath = r.Metadata?.["aws:asset:path"];
36520
+ if (typeof assetPath !== "string" || assetPath.length === 0) continue;
36521
+ if (path.isAbsolute(assetPath)) throw new Error(`cdkd export: nested-stack '${logicalId}' has Metadata['aws:asset:path']='${assetPath}' which is absolute. CDK emits relative asset paths for nested templates.`);
36522
+ result[logicalId] = path.join(templateDir, assetPath);
36523
+ }
36524
+ return result;
36525
+ }
36526
+ /**
36527
+ * Walk the cdkd state tree alongside the synth output's nested-template
36528
+ * file paths, materializing each node's parsed template + format. The
36529
+ * walker indexes children's paths from the parent template's
36530
+ * `Metadata['aws:asset:path']` (same mechanism `AssemblyReader` uses for
36531
+ * the root parent), so grandchildren and deeper levels are loaded
36532
+ * recursively without any caller-side index plumbing.
36533
+ *
36534
+ * Throws if any tree node has a child whose template path is missing from
36535
+ * the parent template's nested-template index — that mismatch indicates
36536
+ * a torn synth output (the child state file exists but the parent CDK
36537
+ * code no longer references it) and the run cannot proceed safely.
36538
+ *
36539
+ * Returns a map keyed by cdkd stack name (matching tree node names) so
36540
+ * {@link runPerStackImportLoop} can look each node's template up by name
36541
+ * without re-traversing the tree.
36542
+ *
36543
+ * Exported so the recursion shape is unit-testable independently of the
36544
+ * orchestrator.
36545
+ */
36546
+ function buildPerStackImportNodes(rootStackName, rootTemplate, rootNestedTemplatePaths, rootTemplateFormat, tree) {
36547
+ if (tree.stackName !== rootStackName) throw new Error(`buildPerStackImportNodes: tree root '${tree.stackName}' does not match expected root stack name '${rootStackName}'.`);
36548
+ const out = /* @__PURE__ */ new Map();
36549
+ walk(tree, rootTemplate, rootNestedTemplatePaths, rootTemplateFormat);
36550
+ return out;
36551
+ function walk(node, nodeTemplate, nodeNestedTemplatePaths, nodeFormat) {
36552
+ out.set(node.stackName, {
36553
+ cdkdStackName: node.stackName,
36554
+ region: node.region,
36555
+ state: node.state,
36556
+ template: nodeTemplate,
36557
+ templateFormat: nodeFormat
36558
+ });
36559
+ for (const [childLogicalId, childNode] of node.nestedChildren) {
36560
+ const childTemplatePath = nodeNestedTemplatePaths[childLogicalId];
36561
+ if (!childTemplatePath) throw new Error(`cdkd export: nested-stack child '${childLogicalId}' under parent '${node.stackName}' has cdkd state but no Metadata['aws:asset:path'] in the parent template's '${childLogicalId}' row. The synth output and the cdkd state tree are out of sync — re-deploy the parent stack to refresh, or remove the cdkd state for the orphaned child via 'cdkd state orphan ${childNode.stackName}'.`);
36562
+ const { template: childTemplate, format: childFormat } = readNestedChildTemplateFile(childTemplatePath, childLogicalId);
36563
+ walk(childNode, childTemplate, indexNestedTemplatePaths(childTemplate, path.dirname(childTemplatePath)), childFormat);
36564
+ }
36565
+ }
36566
+ }
36567
+ /**
36381
36568
  * Build the import plan from cdkd state + the synthesized template.
36382
36569
  *
36383
36570
  * Classifies every template resource into one of:
@@ -36998,15 +37185,33 @@ async function runTemplateUploadCleanup(cleanup, bucket) {
36998
37185
  }
36999
37186
  }
37000
37187
  async function executeImportChangeSet(cfnClient, stackName, template, plan, parameters, templateFormat = "json", uploadOpts) {
37001
- const logger = getLogger();
37002
- const changeSetName = `cdkd-migrate-${Date.now()}`;
37003
- const templateBody = stringifyCfnTemplate(template, templateFormat);
37004
- const resourcesToImport = plan.map((entry) => ({
37188
+ await submitImportChangeSet(cfnClient, stackName, template, plan.map((entry) => ({
37005
37189
  ResourceType: entry.resourceType,
37006
37190
  LogicalResourceId: entry.logicalId,
37007
37191
  ResourceIdentifier: entry.resourceIdentifier
37008
- }));
37009
- logger.info(`Creating IMPORT changeset '${changeSetName}' for stack '${stackName}' (${plan.length} resource(s), ${templateBody.length} bytes)...`);
37192
+ })), parameters, templateFormat, uploadOpts);
37193
+ }
37194
+ /**
37195
+ * Submit one CloudFormation IMPORT changeset for `stackName`, awaiting
37196
+ * both `CreateChangeSet` and `ExecuteChangeSet` to completion. Accepts a
37197
+ * pre-built `resourcesToImport` array (caller-controlled) so non-leaf
37198
+ * parents in the per-stack IMPORT loop (issue #464 PR B2) can pass a
37199
+ * mixed list of leaf resources AND `AWS::CloudFormation::Stack` adoption
37200
+ * entries (with `ResourceIdentifier: { StackId: <child-CFn-arn> }`).
37201
+ *
37202
+ * Extracted from {@link executeImportChangeSet} so the leaf-only callers
37203
+ * (top-level export, single-stack flow) and the non-leaf-parent caller
37204
+ * (nested per-stack loop) share the exact same submit + wait + cleanup
37205
+ * code path — no risk of divergence on retry shape, changeset cleanup
37206
+ * on failure, or transient-upload reaping.
37207
+ *
37208
+ * Exported for unit testing.
37209
+ */
37210
+ async function submitImportChangeSet(cfnClient, stackName, template, resourcesToImport, parameters, templateFormat = "json", uploadOpts) {
37211
+ const logger = getLogger();
37212
+ const changeSetName = `cdkd-migrate-${Date.now()}`;
37213
+ const templateBody = stringifyCfnTemplate(template, templateFormat);
37214
+ logger.info(`Creating IMPORT changeset '${changeSetName}' for stack '${stackName}' (${resourcesToImport.length} resource(s), ${templateBody.length} bytes)...`);
37010
37215
  const source = await selectChangeSetTemplateSource(template, templateBody, uploadOpts, stackName, "Filtered phase-1 IMPORT", templateFormat);
37011
37216
  try {
37012
37217
  try {
@@ -37105,6 +37310,462 @@ async function collectImportFailureSummary(cfnClient, stackName) {
37105
37310
  return failures.map((f) => ` - ${f.logicalId} (${f.type}): ${f.reason}`).join("\n");
37106
37311
  }
37107
37312
  /**
37313
+ * Flip a CFn stack's status from `IMPORT_COMPLETE` to `UPDATE_COMPLETE`
37314
+ * via a no-op `UpdateStack` with a stack-level tag-only change. Used
37315
+ * after each non-root stack's Phase 1A IMPORT (and after each non-leaf
37316
+ * parent's Phase 1B IMPORT) so the stack can be referenced as a nested
37317
+ * member by its own parent's later Phase 1B adoption.
37318
+ *
37319
+ * Why this is needed: AWS's "Nest an existing stack" IMPORT changeset
37320
+ * validation rejects child stacks in `IMPORT_COMPLETE` status with
37321
+ * `Stack <arn> is not in an importable status, current stack status is
37322
+ * IMPORT_COMPLETE`. The accepted statuses for nesting are
37323
+ * `CREATE_COMPLETE` and `UPDATE_COMPLETE`. A no-op tag-only `UpdateStack`
37324
+ * with `UsePreviousTemplate: true` flips the status without mutating any
37325
+ * resources: the existing template is re-applied verbatim, only the
37326
+ * stack-level Tags collection changes (the timestamp-bearing
37327
+ * `cdkd:nested-export-flip` tag is the only delta). This is the canonical
37328
+ * AWS workaround for the IMPORT_COMPLETE → UPDATE_COMPLETE transition.
37329
+ *
37330
+ * Idempotency: each invocation uses a fresh ISO 8601 timestamp value so
37331
+ * AWS never returns "No updates are to be performed" (which would leave
37332
+ * the stack stuck in `IMPORT_COMPLETE`). The tag accumulates across
37333
+ * retries — harmless and easy to reap manually under the `cdkd:` prefix.
37334
+ *
37335
+ * Exported for unit testing.
37336
+ */
37337
+ async function flipStackToUpdateComplete(cfnClient, cfnStackName) {
37338
+ await cfnClient.send(new UpdateStackCommand({
37339
+ StackName: cfnStackName,
37340
+ UsePreviousTemplate: true,
37341
+ Tags: [{
37342
+ Key: "cdkd:nested-export-flip",
37343
+ Value: (/* @__PURE__ */ new Date()).toISOString()
37344
+ }],
37345
+ Capabilities: [
37346
+ "CAPABILITY_IAM",
37347
+ "CAPABILITY_NAMED_IAM",
37348
+ "CAPABILITY_AUTO_EXPAND"
37349
+ ]
37350
+ }));
37351
+ await waitUntilStackUpdateComplete({
37352
+ client: cfnClient,
37353
+ maxWaitTime: 1800
37354
+ }, { StackName: cfnStackName });
37355
+ }
37356
+ /**
37357
+ * Fetch a CFn stack's CURRENT template body via the CFn `GetTemplate`
37358
+ * `Processed` stage. Used by the non-leaf-parent branch of
37359
+ * {@link runPerStackImportLoop} to satisfy the AWS-docs "Nest an existing
37360
+ * stack" template-match requirement: the parent's
37361
+ * `AWS::CloudFormation::Stack.Properties.TemplateURL` must point at the
37362
+ * child stack's actual current template for the IMPORT changeset to
37363
+ * validate. Reading via `GetTemplate` (vs. the local cdk.out file) is
37364
+ * the only way to guarantee the AWS-side stored template byte-shape —
37365
+ * AWS may have normalized whitespace, parameter defaults, or intrinsic
37366
+ * shapes during the just-completed leaf IMPORT.
37367
+ *
37368
+ * `Processed` stage returns the template AWS has on file post-IMPORT
37369
+ * (with any macro / serverless-transform expansion already applied),
37370
+ * which is exactly what the parent's nested-stack row must reference.
37371
+ *
37372
+ * Exported for unit testing.
37373
+ */
37374
+ async function fetchCfnStackTemplate(cfnClient, cfnStackName) {
37375
+ const resp = await cfnClient.send(new GetTemplateCommand({
37376
+ StackName: cfnStackName,
37377
+ TemplateStage: "Processed"
37378
+ }));
37379
+ if (!resp.TemplateBody) throw new Error(`CFn GetTemplate returned no body for stack '${cfnStackName}'. The just-IMPORTed child stack may be in an unexpected state; check the CloudFormation console.`);
37380
+ return resp.TemplateBody;
37381
+ }
37382
+ /**
37383
+ * Extract a child-IMPORT Parameter map from the parent template's
37384
+ * `Resources[<childLogicalId>].Properties.Parameters` block.
37385
+ *
37386
+ * CDK nested stacks pass parent → child Parameter values via the parent's
37387
+ * `AWS::CloudFormation::Stack.Properties.Parameters` map. For the LEAF
37388
+ * child IMPORT (which submits the child as a fresh standalone CFn stack),
37389
+ * cdkd must forward those values — otherwise the child template's
37390
+ * `Parameters` block goes unresolved and CFn rejects the changeset.
37391
+ *
37392
+ * Only literal-string Parameter values are forwarded today. Intrinsic
37393
+ * values (`{Ref: ...}` / `{Fn::GetAtt: ...}` referencing the parent's
37394
+ * own resources) are skipped with a `logger.warn`; if the child Parameter
37395
+ * has no `Default`, CFn will reject the IMPORT with a clear error. The
37396
+ * intrinsic-resolution path is a deferred follow-up (tracked under #464
37397
+ * post-PR-B2 backlog) because resolving parent-side intrinsics at
37398
+ * leaf-IMPORT time requires the deploy engine's full
37399
+ * `IntrinsicFunctionResolver` against the parent's own (yet-to-be-IMPORTed)
37400
+ * state — not in PR B2's scope.
37401
+ *
37402
+ * Exported for unit testing.
37403
+ */
37404
+ function extractChildImportParameters(parentTemplate, childLogicalId) {
37405
+ const resources = parentTemplate["Resources"];
37406
+ if (!resources || typeof resources !== "object" || Array.isArray(resources)) return {
37407
+ params: [],
37408
+ intrinsicSkipped: []
37409
+ };
37410
+ const row = resources[childLogicalId];
37411
+ if (!row || typeof row !== "object" || Array.isArray(row)) return {
37412
+ params: [],
37413
+ intrinsicSkipped: []
37414
+ };
37415
+ const props = row.Properties;
37416
+ if (!props || typeof props !== "object" || Array.isArray(props)) return {
37417
+ params: [],
37418
+ intrinsicSkipped: []
37419
+ };
37420
+ const rawParams = props.Parameters;
37421
+ if (!rawParams || typeof rawParams !== "object" || Array.isArray(rawParams)) return {
37422
+ params: [],
37423
+ intrinsicSkipped: []
37424
+ };
37425
+ const out = [];
37426
+ const intrinsicSkipped = [];
37427
+ for (const [k, v] of Object.entries(rawParams)) if (typeof v === "string") out.push({
37428
+ ParameterKey: k,
37429
+ ParameterValue: v
37430
+ });
37431
+ else if (typeof v === "number" || typeof v === "boolean") out.push({
37432
+ ParameterKey: k,
37433
+ ParameterValue: String(v)
37434
+ });
37435
+ else intrinsicSkipped.push(k);
37436
+ return {
37437
+ params: out,
37438
+ intrinsicSkipped
37439
+ };
37440
+ }
37441
+ /**
37442
+ * Per-stack IMPORT loop driving `cdkd export` for nested-stack trees
37443
+ * (issue #464 PR B2). Submits one IMPORT changeset per cdkd-managed stack
37444
+ * in leaf-first order. For non-leaf parents, each `AWS::CloudFormation::Stack`
37445
+ * row in the parent's template is adopted as a nested reference via the
37446
+ * AWS-docs "Nest an existing stack" pattern: `DeletionPolicy: Retain` is
37447
+ * injected, the row's `TemplateURL` is rewritten to point at the just-IMPORTed
37448
+ * child stack's current template (fetched via `GetTemplate`), and the
37449
+ * row is added to `ResourcesToImport[]` with
37450
+ * `{ ResourceIdentifier: { StackId: <child-CFn-arn> } }`.
37451
+ *
37452
+ * The original "one atomic `--include-nested-stacks` IMPORT changeset"
37453
+ * design (#464 design doc §4.3 original) was empirically rejected by AWS:
37454
+ * `IncludeNestedStacks is not supported for changeSet type: IMPORT`. See
37455
+ * §4.0 of the design doc for the spike findings + per-stack loop rationale.
37456
+ *
37457
+ * Algorithm:
37458
+ * 1. Pre-flight (before any AWS mutation):
37459
+ * - Build per-stack import plans from the tree.
37460
+ * - Reject if any stack has blocked resources OR phase-2 non-importable
37461
+ * resources without `--include-non-importable`.
37462
+ * - Verify no CFn stack already exists under the resolved CFn name
37463
+ * for any tree node.
37464
+ * - Print the per-stack plan summary.
37465
+ * - In `--dry-run`: return without acquiring locks or submitting
37466
+ * changesets.
37467
+ * - Prompt the user once for the whole tree migration (unless `--yes`).
37468
+ * - Acquire per-non-root-child locks (root's lock is already held by
37469
+ * `exportCommand`'s outer scope).
37470
+ * 2. Main loop, leaf-first across the tree. Per stack:
37471
+ * - Build the filtered phase-1 template + `ResourcesToImport[]` array.
37472
+ * - For non-leaf parents: per nested-stack row, fetch the child's
37473
+ * current template via `GetTemplate`, upload to S3 (cleanup
37474
+ * accumulated for the outer `finally`), inject Retain + rewrite
37475
+ * TemplateURL, append to `ResourcesToImport[]` with
37476
+ * `{ StackId: <child-arn> }`.
37477
+ * - Submit IMPORT changeset via {@link submitImportChangeSet}.
37478
+ * - Capture the resulting CFn stack ARN via `DescribeStacks` for
37479
+ * the next parent iteration's reference.
37480
+ * - Per-stack phase-2: pre-delete IMPORT-unsupported resources, then
37481
+ * UPDATE changeset for Custom Resources (same shape as the
37482
+ * flat-stack code path, just scoped to this stack).
37483
+ * 3. After all stacks IMPORTed: delete cdkd state leaf-first.
37484
+ * 4. Always (success AND failure paths):
37485
+ * - Release per-non-root-child locks in reverse order.
37486
+ * - Drain transient template uploads.
37487
+ *
37488
+ * Failure semantics: each per-stack IMPORT is independent. If leaf A
37489
+ * succeeds but parent B fails, A is a standalone CFn stack with A's cdkd
37490
+ * state DELETED, while B's cdkd state is PRESERVED. The error message
37491
+ * names which stacks moved + which remain. Re-running `cdkd export
37492
+ * <parent>` after the failure cause is fixed adopts A as a nested
37493
+ * reference (since A is now an existing CFn stack) — the per-stack loop
37494
+ * is idempotent in that direction.
37495
+ *
37496
+ * Exported for unit testing.
37497
+ */
37498
+ async function runPerStackImportLoop(args) {
37499
+ const logger = getLogger();
37500
+ const { rootStackName, rootRegion, rootStackInfoNestedTemplates, rootTemplateFormat, tree, rootTemplate, cfnStackNameOverrides, rootParameters, deps, options } = args;
37501
+ const cfnStackNameOf = (cdkdName) => {
37502
+ if (cdkdName === rootStackName) return cfnStackNameOverrides.root ?? cdkd2cfnStackName(cdkdName);
37503
+ return cfnStackNameOverrides.childMap.get(cdkdName) ?? cdkd2cfnStackName(cdkdName);
37504
+ };
37505
+ const nodesByCdkdName = buildPerStackImportNodes(rootStackName, rootTemplate, rootStackInfoNestedTemplates, rootTemplateFormat, tree);
37506
+ const leafFirst = flattenCdkdStateTreeLeafFirst(tree);
37507
+ for (const n of leafFirst) if (!nodesByCdkdName.has(n.stackName)) throw new Error(`runPerStackImportLoop: missing per-stack template for '${n.stackName}' — tree node has cdkd state but no synth template was loaded. This is a cdkd bug.`);
37508
+ const perStackPlans = [];
37509
+ for (const node of leafFirst) {
37510
+ const meta = nodesByCdkdName.get(node.stackName);
37511
+ const plan = await buildImportPlan(meta.state, meta.template, deps.cfnClient, meta.cdkdStackName, { recreateImportUnsupported: options.recreateImportUnsupported });
37512
+ if (plan.blocked.length > 0) {
37513
+ const lines = plan.blocked.map((b) => ` - ${b.logicalId} (${b.resourceType}): ${b.reason}`);
37514
+ throw new Error(`Stack '${meta.cdkdStackName}' has ${plan.blocked.length} resource(s) that block migration:\n${lines.join("\n")}`);
37515
+ }
37516
+ perStackPlans.push({
37517
+ cdkdName: meta.cdkdStackName,
37518
+ cfnName: cfnStackNameOf(meta.cdkdStackName),
37519
+ region: meta.region,
37520
+ template: meta.template,
37521
+ templateFormat: meta.templateFormat,
37522
+ state: meta.state,
37523
+ phase1Imports: plan.phase1Imports,
37524
+ phase2Creates: plan.phase2Creates,
37525
+ recreateBeforePhase2: plan.recreateBeforePhase2,
37526
+ nestedStackRows: plan.nestedStackRows
37527
+ });
37528
+ }
37529
+ const totalPhase2Creates = perStackPlans.reduce((acc, p) => acc + p.phase2Creates.length, 0);
37530
+ const totalRecreate = perStackPlans.reduce((acc, p) => acc + p.recreateBeforePhase2.length, 0);
37531
+ logger.info("");
37532
+ logger.info(`Migrating cdkd nested-stack tree rooted at '${rootStackName}' → CloudFormation (${perStackPlans.length} stack(s), leaf-first):`);
37533
+ for (const plan of perStackPlans) logger.info(` [${plan.cdkdName}] → CFn stack '${plan.cfnName}': ${plan.phase1Imports.length} leaf import(s)` + (plan.nestedStackRows.length > 0 ? `, ${plan.nestedStackRows.length} nested-child adoption(s)` : "") + (plan.phase2Creates.length > 0 ? `, ${plan.phase2Creates.length} phase-2 CREATE(s)` : "") + (plan.recreateBeforePhase2.length > 0 ? `, ${plan.recreateBeforePhase2.length} pre-delete + re-CREATE` : ""));
37534
+ logger.info("");
37535
+ if (totalPhase2Creates > 0 && !options.includeNonImportable) {
37536
+ if (options.dryRun) {
37537
+ logger.warn(`${totalPhase2Creates} non-importable resource(s) (Custom::*) across the tree. A real run would require --include-non-importable for phase-2 CFn CREATE (re-invokes each Custom Resource's backing Lambda onCreate handler — ensure idempotent).`);
37538
+ logger.info("--dry-run: no CloudFormation changesets will be created.");
37539
+ return {
37540
+ outcome: "dry-run",
37541
+ importedStacks: []
37542
+ };
37543
+ }
37544
+ throw new Error(`${totalPhase2Creates} non-importable resource(s) (Custom::*) across the cdkd nested-stack tree. Pass --include-non-importable to run a per-stack 2-phase migration (phase 1 imports the importable resources for each stack; phase 2 CFn-CREATEs the non-importable ones per stack, which re-invokes each Custom Resource's onCreate handler — make sure those are idempotent). Or destroy the Custom Resources first.`);
37545
+ }
37546
+ for (const plan of perStackPlans) await assertCfnStackAbsent(deps.cfnClient, plan.cfnName);
37547
+ if (options.dryRun) {
37548
+ logger.info("--dry-run: no CloudFormation changesets will be created.");
37549
+ return {
37550
+ outcome: "dry-run",
37551
+ importedStacks: []
37552
+ };
37553
+ }
37554
+ if (!options.yes) {
37555
+ const phase2Note = totalPhase2Creates > 0 ? ` Phase 2 will CREATE ${totalPhase2Creates} non-importable resource(s) across the tree.` : "";
37556
+ const recreateNote = totalRecreate > 0 ? ` cdkd will also pre-DELETE + re-CREATE ${totalRecreate} IMPORT-unsupported resource(s) (brief unavailability window per resource).` : "";
37557
+ if (!await confirmPrompt$1(`Create ${perStackPlans.length} CloudFormation stack(s) by importing the cdkd nested-stack tree rooted at '${rootStackName}' (${rootRegion}) — leaf-first, per-stack IMPORT loop (#464 design §4.3).` + phase2Note + recreateNote + " AWS resources are unchanged. cdkd state for every adopted stack will be deleted on success.")) {
37558
+ logger.info("Migration cancelled. cdkd state and CloudFormation are unchanged.");
37559
+ return {
37560
+ outcome: "cancelled",
37561
+ importedStacks: []
37562
+ };
37563
+ }
37564
+ }
37565
+ const acquiredChildLocks = [];
37566
+ try {
37567
+ for (const node of leafFirst) {
37568
+ if (node.stackName === rootStackName) continue;
37569
+ if (!await deps.lockManager.acquireLock(node.stackName, node.region, deps.lockOwner, "export")) throw new Error(`Could not acquire lock for nested-stack child '${node.stackName}' (${node.region}) — another cdkd process holds it. Wait for it to finish, or run 'cdkd force-unlock ${node.stackName}' if you are certain no other process is active. No CloudFormation changeset has been submitted; cdkd state is unchanged.`);
37570
+ acquiredChildLocks.push({
37571
+ stackName: node.stackName,
37572
+ region: node.region
37573
+ });
37574
+ }
37575
+ const cfnArnByCdkdName = /* @__PURE__ */ new Map();
37576
+ const uploadCleanups = [];
37577
+ const importedStacks = [];
37578
+ try {
37579
+ for (let i = 0; i < perStackPlans.length; i++) {
37580
+ const plan = perStackPlans[i];
37581
+ logger.info(`[${i + 1}/${perStackPlans.length}] Importing cdkd stack '${plan.cdkdName}' → CFn stack '${plan.cfnName}' (${plan.phase1Imports.length} leaf, ${plan.nestedStackRows.length} nested-child adoption)`);
37582
+ let stackParameters;
37583
+ if (plan.cdkdName === rootStackName) stackParameters = rootParameters;
37584
+ else {
37585
+ const parentLogicalId = plan.state.parentLogicalId;
37586
+ const parentStackName = plan.state.parentStack;
37587
+ if (!parentLogicalId || !parentStackName) throw new Error(`runPerStackImportLoop: child '${plan.cdkdName}' state is missing parentLogicalId / parentStack (v6 fields). Re-deploy or re-import the parent stack to refresh.`);
37588
+ const parentPlan = perStackPlans.find((p) => p.cdkdName === parentStackName);
37589
+ if (!parentPlan) throw new Error(`runPerStackImportLoop: child '${plan.cdkdName}' references parent '${parentStackName}' which is not in the per-stack plan list — tree shape is inconsistent.`);
37590
+ const extracted = extractChildImportParameters(parentPlan.template, parentLogicalId);
37591
+ if (extracted.intrinsicSkipped.length > 0) logger.warn(` Child '${plan.cdkdName}': skipping intrinsic-valued Parameter(s) ${extracted.intrinsicSkipped.join(", ")} (intrinsic-resolution at leaf-IMPORT time is a deferred follow-up). The child template's Parameter Default values must cover these — otherwise CFn will reject the IMPORT.`);
37592
+ stackParameters = extracted.params;
37593
+ }
37594
+ const phase1ATemplate = filterTemplateForImport(plan.template, plan.phase1Imports);
37595
+ const phase1AResources = plan.phase1Imports.map((entry) => ({
37596
+ ResourceType: entry.resourceType,
37597
+ LogicalResourceId: entry.logicalId,
37598
+ ResourceIdentifier: entry.resourceIdentifier
37599
+ }));
37600
+ const injectedDeletion = injectDeletionPolicyForImport(phase1ATemplate);
37601
+ if (injectedDeletion > 0) logger.info(` Injected DeletionPolicy: Delete on ${injectedDeletion} resource(s) in '${plan.cdkdName}' (required by CFn IMPORT; matches CDK/CFn default).`);
37602
+ try {
37603
+ await submitImportChangeSet(deps.cfnClient, plan.cfnName, phase1ATemplate, phase1AResources, stackParameters, plan.templateFormat, deps.uploadOpts);
37604
+ } catch (err) {
37605
+ const importedSummary = importedStacks.length > 0 ? importedStacks.map((s) => `${s.cdkdStackName} → ${s.cfnStackName}`).join(", ") : "(none)";
37606
+ const remainingSummary = perStackPlans.slice(i).map((p) => p.cdkdName).join(", ");
37607
+ throw new Error(`Phase 1A IMPORT changeset failed for cdkd stack '${plan.cdkdName}' (CFn name '${plan.cfnName}'). Stacks imported successfully so far: ${importedSummary}. Stacks not yet imported (cdkd state preserved): ${remainingSummary}. After resolving the underlying cause, re-run 'cdkd export ${rootStackName}' — already-imported children will be adopted as nested references on retry. Cause: ${err instanceof Error ? err.message : String(err)}`, { cause: err instanceof Error ? err : void 0 });
37608
+ }
37609
+ const cfnArn = (await deps.cfnClient.send(new DescribeStacksCommand({ StackName: plan.cfnName }))).Stacks?.[0]?.StackId;
37610
+ if (!cfnArn) throw new Error(`runPerStackImportLoop: DescribeStacks returned no StackId for '${plan.cfnName}' immediately after Phase 1A IMPORT — AWS may be in an unexpected state.`);
37611
+ cfnArnByCdkdName.set(plan.cdkdName, cfnArn);
37612
+ importedStacks.push({
37613
+ cdkdStackName: plan.cdkdName,
37614
+ cfnStackName: plan.cfnName,
37615
+ cfnStackArn: cfnArn
37616
+ });
37617
+ logger.info(` ✓ Phase 1A: CFn stack '${plan.cfnName}' created via IMPORT (${plan.phase1Imports.length} leaf resource(s)).`);
37618
+ if (plan.cdkdName !== rootStackName) {
37619
+ logger.info(` Flipping '${plan.cfnName}' to UPDATE_COMPLETE so it can be adopted as a nested member by its parent's Phase 1B...`);
37620
+ await flipStackToUpdateComplete(deps.cfnClient, plan.cfnName);
37621
+ }
37622
+ const rewrittenNestedRows = /* @__PURE__ */ new Map();
37623
+ if (plan.nestedStackRows.length > 0) {
37624
+ const phase1BTemplate = filterTemplateForImport(plan.template, plan.phase1Imports);
37625
+ injectDeletionPolicyForImport(phase1BTemplate);
37626
+ const phase1BResources = [];
37627
+ for (const row of plan.nestedStackRows) {
37628
+ const childArn = cfnArnByCdkdName.get(row.childStackName);
37629
+ if (!childArn) throw new Error(`runPerStackImportLoop: nested-stack child '${row.childStackName}' has no recorded CFn ARN when processing parent '${plan.cdkdName}'. Leaf-first iteration order should have IMPORTed the child first — this is a cdkd bug.`);
37630
+ const childCfnName = cfnStackNameOf(row.childStackName);
37631
+ const childTemplateBody = await fetchCfnStackTemplate(deps.cfnClient, childCfnName);
37632
+ const uploaded = await uploadCfnTemplate({
37633
+ bucket: deps.uploadOpts.stateBucket,
37634
+ body: childTemplateBody,
37635
+ stackName: `${plan.cdkdName}__nested__${row.logicalId}`,
37636
+ format: "json",
37637
+ ...deps.uploadOpts.s3ClientOpts && { s3ClientOpts: deps.uploadOpts.s3ClientOpts }
37638
+ });
37639
+ uploadCleanups.push(uploaded.cleanup);
37640
+ const childActualTags = (await deps.cfnClient.send(new DescribeStacksCommand({ StackName: childCfnName }))).Stacks?.[0]?.Tags ?? [];
37641
+ const originalRow = getResourceFromTemplate(plan.template, row.logicalId);
37642
+ if (!originalRow) throw new Error(`runPerStackImportLoop: parent template for '${plan.cdkdName}' has no resource '${row.logicalId}'. State and template are out of sync.`);
37643
+ const rewrittenRow = injectRetainAndRewriteTemplateUrl(originalRow, uploaded.url, childActualTags);
37644
+ phase1BTemplate["Resources"][row.logicalId] = rewrittenRow;
37645
+ rewrittenNestedRows.set(row.logicalId, rewrittenRow);
37646
+ phase1BResources.push({
37647
+ ResourceType: NESTED_STACK_RESOURCE_TYPE,
37648
+ LogicalResourceId: row.logicalId,
37649
+ ResourceIdentifier: { StackId: childArn }
37650
+ });
37651
+ }
37652
+ try {
37653
+ await submitImportChangeSet(deps.cfnClient, plan.cfnName, phase1BTemplate, phase1BResources, stackParameters, plan.templateFormat, deps.uploadOpts);
37654
+ } catch (err) {
37655
+ const importedSummary = importedStacks.length > 0 ? importedStacks.map((s) => `${s.cdkdStackName} → ${s.cfnStackName}`).join(", ") : "(none)";
37656
+ throw new Error(`Phase 1B (nested-child adoption) IMPORT changeset failed for parent '${plan.cdkdName}' (CFn name '${plan.cfnName}'). The parent CFn stack exists with its ${plan.phase1Imports.length} leaf resource(s); ${plan.nestedStackRows.length} nested-child adoption(s) did NOT complete. Stacks IMPORTed so far (each is a standalone CFn stack): ${importedSummary}. cdkd state for every stack in the tree is preserved. To recover: (1) fix the underlying cause (typically a template-match validation error per AWS-docs "Nested stack import validation"); (2) clear cdkd state for the migrated stacks via 'cdkd state orphan <stack>' (do NOT 'cdkd destroy' — that would tear down the live AWS resources); (3) re-attempt the parent-side nested adoption manually via the AWS console or CLI per the AWS docs procedure. Cause: ${err instanceof Error ? err.message : String(err)}`, { cause: err instanceof Error ? err : void 0 });
37657
+ }
37658
+ logger.info(` ✓ Phase 1B: parent '${plan.cfnName}' adopted ${plan.nestedStackRows.length} nested child(ren) via UPDATE-IMPORT.`);
37659
+ if (plan.cdkdName !== rootStackName) {
37660
+ logger.info(` Flipping '${plan.cfnName}' back to UPDATE_COMPLETE so its own parent's Phase 1B can adopt it...`);
37661
+ await flipStackToUpdateComplete(deps.cfnClient, plan.cfnName);
37662
+ }
37663
+ }
37664
+ if (plan.recreateBeforePhase2.length > 0) for (const entry of plan.recreateBeforePhase2) {
37665
+ const handler = PRE_DELETE_HANDLERS[entry.resourceType];
37666
+ if (!handler) throw new Error(`No pre-delete handler registered for ${entry.resourceType} (${entry.logicalId}) in stack '${plan.cdkdName}'. This is a cdkd bug — the resource is in IMPORT_UNSUPPORTED_RECREATABLE_TYPES but lacks a PRE_DELETE_HANDLERS entry.`);
37667
+ logger.info(` Pre-deleting AWS resource for ${entry.logicalId} (${entry.resourceType}) so CFn can re-CREATE in phase 2...`);
37668
+ await handler(entry);
37669
+ logger.info(` ✓ deleted ${entry.physicalId}`);
37670
+ }
37671
+ if (plan.phase2Creates.length + plan.recreateBeforePhase2.length > 0) {
37672
+ const phase2Template = applyImportOverlayForPhase2(plan.template, plan.phase1Imports);
37673
+ for (const row of plan.nestedStackRows) {
37674
+ const overlaidResources = phase2Template["Resources"];
37675
+ const rewrittenRow = rewrittenNestedRows.get(row.logicalId);
37676
+ if (rewrittenRow !== void 0) overlaidResources[row.logicalId] = rewrittenRow;
37677
+ }
37678
+ await executeUpdateChangeSet(deps.cfnClient, plan.cfnName, phase2Template, stackParameters, plan.templateFormat, deps.uploadOpts);
37679
+ logger.info(` ✓ Phase 2: stack '${plan.cfnName}' updated (${plan.phase2Creates.length} non-importable CREATE, ${plan.recreateBeforePhase2.length} re-CREATE).`);
37680
+ }
37681
+ }
37682
+ const stateDeletionFailures = [];
37683
+ for (const node of leafFirst) try {
37684
+ await deps.stateBackend.deleteState(node.stackName, node.region);
37685
+ logger.info(`cdkd state for '${node.stackName}' (${node.region}) removed.`);
37686
+ } catch (err) {
37687
+ stateDeletionFailures.push({
37688
+ stackName: node.stackName,
37689
+ region: node.region,
37690
+ reason: err instanceof Error ? err.message : String(err)
37691
+ });
37692
+ logger.warn(`Failed to delete cdkd state for '${node.stackName}' (${node.region}): ${err instanceof Error ? err.message : String(err)}. The stack IS CFn-managed; clean up with 'cdkd state orphan ${node.stackName}'.`);
37693
+ }
37694
+ if (stateDeletionFailures.length > 0) {
37695
+ const lines = stateDeletionFailures.map((f) => ` - cdkd/${f.stackName}/${f.region}/state.json: ${f.reason}`).join("\n");
37696
+ throw new Error(`${stateDeletionFailures.length} cdkd state record(s) could not be deleted after a successful per-stack IMPORT loop. Every stack in the tree IS CFn-managed (the migration succeeded AWS-side); orphan state remains under:\n${lines}\nRecover with 'cdkd state orphan <stack>' per record.`);
37697
+ }
37698
+ return {
37699
+ outcome: "success",
37700
+ importedStacks
37701
+ };
37702
+ } finally {
37703
+ for (const cleanup of uploadCleanups) await runTemplateUploadCleanup(cleanup, deps.uploadOpts.stateBucket);
37704
+ }
37705
+ } finally {
37706
+ for (let i = acquiredChildLocks.length - 1; i >= 0; i--) {
37707
+ const lock = acquiredChildLocks[i];
37708
+ await deps.lockManager.releaseLock(lock.stackName, lock.region).catch((err) => {
37709
+ logger.warn(`Failed to release lock for '${lock.stackName}' (${lock.region}): ${err instanceof Error ? err.message : String(err)}`);
37710
+ });
37711
+ }
37712
+ }
37713
+ }
37714
+ /**
37715
+ * Helper for {@link runPerStackImportLoop}: pull a single resource out of
37716
+ * a template's Resources block by logical id, returning `undefined` if
37717
+ * the template is malformed or the resource is absent.
37718
+ */
37719
+ function getResourceFromTemplate(template, logicalId) {
37720
+ const resources = template["Resources"];
37721
+ if (!resources || typeof resources !== "object" || Array.isArray(resources)) return void 0;
37722
+ const r = resources[logicalId];
37723
+ if (!r || typeof r !== "object" || Array.isArray(r)) return void 0;
37724
+ return r;
37725
+ }
37726
+ /**
37727
+ * Helper for {@link runPerStackImportLoop}'s non-leaf-parent branch:
37728
+ * mutate-clone a nested-stack resource row to (a) inject `DeletionPolicy:
37729
+ * Retain` (AWS-docs "Nest an existing stack" requirement so a parent-side
37730
+ * rollback does NOT cascade-delete the just-imported child stack), (b)
37731
+ * overwrite `Properties.TemplateURL` to the uploaded child template's S3
37732
+ * URL, and (c) overwrite `Properties.Tags` with the child stack's actual
37733
+ * current tags. The Tags overwrite is required by AWS's "Nested stack
37734
+ * import validation": "The tags for the nested AWS::CloudFormation::Stack
37735
+ * definition in the parent stack template match the tags for the actual
37736
+ * nested stack resource." Without the overwrite, the parent's IMPORT
37737
+ * changeset fails with `Tags of resource [<id>] defined in the template
37738
+ * don't match with the actual tags of <arn>` — most commonly when
37739
+ * {@link flipStackToUpdateComplete} added a `cdkd:nested-export-flip`
37740
+ * tag to the child to escape the IMPORT_COMPLETE status. All other
37741
+ * Properties (Parameters, NotificationARNs) are preserved from the
37742
+ * original row.
37743
+ *
37744
+ * `childActualTags` is the result of `DescribeStacks(<child>).Stacks[0].Tags`
37745
+ * (empty array if the child has no tags). When the child has no actual
37746
+ * tags AND the original row has no Tags Properties either, the
37747
+ * Properties.Tags key is omitted from the output so the parent template
37748
+ * stays minimal (matches the CDK-synth shape for tag-free stacks).
37749
+ *
37750
+ * Exported for unit testing.
37751
+ */
37752
+ function injectRetainAndRewriteTemplateUrl(originalRow, newTemplateUrl, childActualTags) {
37753
+ const cloned = { ...originalRow };
37754
+ cloned["DeletionPolicy"] = "Retain";
37755
+ const existingProps = cloned["Properties"];
37756
+ const properties = existingProps && typeof existingProps === "object" && !Array.isArray(existingProps) ? { ...existingProps } : {};
37757
+ properties["TemplateURL"] = newTemplateUrl;
37758
+ if (childActualTags && childActualTags.length > 0) {
37759
+ const forwardable = childActualTags.filter((t) => t.Key !== void 0 && t.Value !== void 0 && !t.Key.startsWith("aws:"));
37760
+ if (forwardable.length > 0) properties["Tags"] = forwardable.map((t) => ({
37761
+ Key: t.Key,
37762
+ Value: t.Value
37763
+ }));
37764
+ }
37765
+ cloned["Properties"] = properties;
37766
+ return cloned;
37767
+ }
37768
+ /**
37108
37769
  * Phase 2 of the 2-phase migration: a CFn UPDATE changeset that ADDs the
37109
37770
  * non-importable resources (`Custom::*`) to the just-created stack. CFn
37110
37771
  * diffs against the phase-1 stack state, sees the new resources, and
@@ -37248,7 +37909,7 @@ async function confirmPrompt$1(prompt) {
37248
37909
  }
37249
37910
  }
37250
37911
  function createExportCommand() {
37251
- const cmd = new Command("export").description("Hand a cdkd-managed stack over to CloudFormation via CFn IMPORT (changeset). AWS resources are unchanged; cdkd state for the stack is deleted on success. Mirror of `cdkd import` (AWS → cdkd) in the reverse direction (cdkd → CFn). Accepts JSON and YAML templates (YAML via a CFn-aware codec that preserves !Ref / !GetAtt / !Sub shorthand). Aborts if any resource is not CFn-importable.").argument("[stack]", "Stack name to export (auto-detected for single-stack apps)").option("--cfn-stack-name <name>", "Name of the destination CloudFormation stack. Defaults to the cdkd stack name.").option("--template <path>", "Path to a pre-rendered CloudFormation template (JSON or YAML — format auto-detected). Skips synth.").option("--stack-region <region>", "Region of the cdkd state record to operate on. Required when the same stack name has state in multiple regions.").option("--dry-run", "Print the import plan without creating a changeset.", false).option("--accept-transient-context", "Allow CLI -c key=value overrides at export time even though they are not persisted to cdk.json / cdk.context.json (default: refuse). When set, the user is responsible for passing the same -c flags to every future cdk deploy.", false).option("--include-non-importable", "Run a 2-phase migration when the stack contains non-importable resources (Custom::*). Phase 1 imports the importable resources; phase 2 CFn-CREATEs the non-importable ones, which re-invokes each Custom Resource's onCreate handler. Make sure onCreate is idempotent before enabling.", false).option("--parameter <key=value...>", "CFn template Parameter override, repeatable. Required when the synthesized template has Parameters without Default values; otherwise overrides the template's default value. Format: --parameter Key=Value.").option("--strict-cross-stack", "Refuse to export when sibling cdkd stacks in the same CDK app reference the exporting stack via Fn::GetStackOutput. Without the flag, cdkd warns but proceeds — the user is expected to migrate the consumer stacks in a follow-up.", false).option("--no-recreate-import-unsupported", "Block instead of auto-handling resource types AWS does NOT support in IMPORT changesets (currently only AWS::ApiGatewayV2::Stage, emitted by CDK HttpApi). Default behavior: cdkd skips these from phase 1, deletes the AWS-side resource between phases, and lets CFn re-CREATE in phase 2 (brief unavailability window). With this flag, the export aborts with a clear error instead.").action(withErrorHandling(exportCommand));
37912
+ const cmd = new Command("export").description("Hand a cdkd-managed stack over to CloudFormation via CFn IMPORT (changeset). AWS resources are unchanged; cdkd state for the stack is deleted on success. Mirror of `cdkd import` (AWS → cdkd) in the reverse direction (cdkd → CFn). Accepts JSON and YAML templates (YAML via a CFn-aware codec that preserves !Ref / !GetAtt / !Sub shorthand). Aborts if any resource is not CFn-importable.").argument("[stack]", "Stack name to export (auto-detected for single-stack apps)").option("--cfn-stack-name <name>", "Name of the destination CloudFormation stack for the root stack. Defaults to the cdkd stack name (or the cdkd2cfnStackName mapping when the name contains '~'). For per-nested-child overrides, use --cfn-child-stack-name.").option("--cfn-child-stack-name <pair...>", "Per-nested-child CFn stack-name override. Repeatable. Format: '<cdkdName>=<cfnName>' (e.g. --cfn-child-stack-name 'MyApp~Database=my-app-db'). The cdkd stack name for a nested child is '<parent>~<childLogicalId>' (v6 state-key form). Without an override the CFn name is derived by replacing '~' with '-'. Only consulted when the exported stack tree contains nested children; ignored for flat stacks.").option("--template <path>", "Path to a pre-rendered CloudFormation template (JSON or YAML — format auto-detected). Skips synth.").option("--stack-region <region>", "Region of the cdkd state record to operate on. Required when the same stack name has state in multiple regions.").option("--dry-run", "Print the import plan without creating a changeset.", false).option("--accept-transient-context", "Allow CLI -c key=value overrides at export time even though they are not persisted to cdk.json / cdk.context.json (default: refuse). When set, the user is responsible for passing the same -c flags to every future cdk deploy.", false).option("--include-non-importable", "Run a 2-phase migration when the stack contains non-importable resources (Custom::*). Phase 1 imports the importable resources; phase 2 CFn-CREATEs the non-importable ones, which re-invokes each Custom Resource's onCreate handler. Make sure onCreate is idempotent before enabling.", false).option("--parameter <key=value...>", "CFn template Parameter override, repeatable. Required when the synthesized template has Parameters without Default values; otherwise overrides the template's default value. Format: --parameter Key=Value.").option("--strict-cross-stack", "Refuse to export when sibling cdkd stacks in the same CDK app reference the exporting stack via Fn::GetStackOutput. Without the flag, cdkd warns but proceeds — the user is expected to migrate the consumer stacks in a follow-up.", false).option("--no-recreate-import-unsupported", "Block instead of auto-handling resource types AWS does NOT support in IMPORT changesets (currently only AWS::ApiGatewayV2::Stage, emitted by CDK HttpApi). Default behavior: cdkd skips these from phase 1, deletes the AWS-side resource between phases, and lets CFn re-CREATE in phase 2 (brief unavailability window). With this flag, the export aborts with a clear error instead.").action(withErrorHandling(exportCommand));
37252
37913
  [
37253
37914
  ...commonOptions,
37254
37915
  ...appOptions,
@@ -56924,7 +57585,7 @@ function reorderArgs(argv) {
56924
57585
  */
56925
57586
  async function main() {
56926
57587
  const program = new Command();
56927
- program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.149.0");
57588
+ program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.150.0");
56928
57589
  program.addCommand(createBootstrapCommand());
56929
57590
  program.addCommand(createSynthCommand());
56930
57591
  program.addCommand(createListCommand());