@go-to-k/cdkd 0.134.0 → 0.135.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
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { _ as withSkipPrefix, a as runDockerStreaming, c as getLogger, d as getLiveRenderer, f as PATTERN_B_NAME_PROPERTIES, g as generateResourceNameWithFallback, h as generateResourceName, i as runDockerForeground, n as formatDockerLoginError, p as PATTERN_B_RESOURCE_TYPES, r as getDockerCmd, u as runStackBuffered, v as withStackName } from "./docker-cmd-EtWSTAje.js";
3
- import { $ as PartialFailureError, A as AssetPublisher, B as resolveStateBucketWithDefault, C as applyRoleArnIfSet, D as LockManager, E as TemplateParser, F as getDefaultStateBucketName, G as resolveBucketRegion, H as warnDeprecatedNoPrefixCliFlag, I as getLegacyStateBucketName, L as resolveApp, M as WorkGraph, N as buildDockerImage, O as S3StateBackend, P as Synthesizer, R as resolveCaptureObservedState, S as IntrinsicFunctionResolver, T as DagBuilder, U as AssemblyReader, V as resolveStateBucketWithDefaultAndSource, X as LocalInvokeBuildError, Z as LocalStartServiceError, _ as normalizeAwsTagsToCfn, a as withRetry, at as StackTerminationProtectionError, b as CloudControlProvider, c as cyan, d as red, dt as withErrorHandling, et as ProvisioningError, f as yellow, g as matchesCdkPath, h as CDK_PATH_TAG, i as withResourceDeadline, it as StackHasActiveImportsError, j as stringifyValue, k as shouldRetainResource, l as gray, m as collectInlinePolicyNamesManagedBySiblings, n as DEFAULT_RESOURCE_WARN_AFTER_MS, nt as ResourceUpdateNotSupportedError, o as IMPLICIT_DELETE_DEPENDENCIES, p as IAMRoleProvider, q as CdkdError, r as DeployEngine, rt as RouteDiscoveryError, s as bold, t as DEFAULT_RESOURCE_TIMEOUT_MS, tt as ResourceTimeoutError, u as green, ut as normalizeAwsError, v as resolveExplicitPhysicalId, w as DiffCalculator, x as assertRegionMatch, y as ProviderRegistry, z as resolveSkipPrefix } from "./deploy-engine-Dn7oV5rA.js";
3
+ import { A as AssetPublisher, B as resolveStateBucketWithDefault, C as applyRoleArnIfSet, D as LockManager, E as TemplateParser, F as getDefaultStateBucketName, G as resolveBucketRegion, H as warnDeprecatedNoPrefixCliFlag, I as getLegacyStateBucketName, L as resolveApp, M as WorkGraph, N as buildDockerImage, O as S3StateBackend, P as Synthesizer, Q as LocalStartServiceError, R as resolveCaptureObservedState, S as IntrinsicFunctionResolver, T as DagBuilder, U as AssemblyReader, V as resolveStateBucketWithDefaultAndSource, X as LocalInvokeBuildError, Z as LocalMigrateError, _ as normalizeAwsTagsToCfn, a as withRetry, at as RouteDiscoveryError, b as CloudControlProvider, c as cyan, d as red, et as MissingCdkCliError, f as yellow, ft as normalizeAwsError, g as matchesCdkPath, h as CDK_PATH_TAG, i as withResourceDeadline, it as ResourceUpdateNotSupportedError, j as stringifyValue, k as shouldRetainResource, l as gray, m as collectInlinePolicyNamesManagedBySiblings, n as DEFAULT_RESOURCE_WARN_AFTER_MS, nt as ProvisioningError, o as IMPLICIT_DELETE_DEPENDENCIES, ot as StackHasActiveImportsError, p as IAMRoleProvider, pt as withErrorHandling, q as CdkdError, r as DeployEngine, rt as ResourceTimeoutError, s as bold, st as StackTerminationProtectionError, t as DEFAULT_RESOURCE_TIMEOUT_MS, tt as PartialFailureError, u as green, v as resolveExplicitPhysicalId, w as DiffCalculator, x as assertRegionMatch, y as ProviderRegistry, z as resolveSkipPrefix } from "./deploy-engine-CX1x5ug1.js";
4
4
  import { a as setAwsClients, i as resetAwsClients, r as getAwsClients, t as AwsClients } from "./aws-clients-BF03Alpe.js";
5
5
  import { createHash, createHmac, createPublicKey, createVerify, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
6
6
  import { CopyObjectCommand, CreateBucketCommand, DeleteBucketAnalyticsConfigurationCommand, DeleteBucketCommand, DeleteBucketCorsCommand, DeleteBucketIntelligentTieringConfigurationCommand, DeleteBucketInventoryConfigurationCommand, DeleteBucketLifecycleCommand, DeleteBucketMetricsConfigurationCommand, DeleteBucketPolicyCommand, DeleteBucketReplicationCommand, DeleteBucketTaggingCommand, DeleteBucketWebsiteCommand, DeleteObjectCommand, DeleteObjectsCommand, GetBucketAccelerateConfigurationCommand, GetBucketCorsCommand, GetBucketEncryptionCommand, GetBucketLifecycleConfigurationCommand, GetBucketLocationCommand, GetBucketLoggingCommand, GetBucketNotificationConfigurationCommand, GetBucketPolicyCommand, GetBucketReplicationCommand, GetBucketTaggingCommand, GetBucketVersioningCommand, GetBucketWebsiteCommand, GetObjectCommand, GetObjectLockConfigurationCommand, GetPublicAccessBlockCommand, HeadBucketCommand, ListBucketAnalyticsConfigurationsCommand, ListBucketIntelligentTieringConfigurationsCommand, ListBucketInventoryConfigurationsCommand, ListBucketMetricsConfigurationsCommand, ListBucketsCommand, ListDirectoryBucketsCommand, ListObjectVersionsCommand, ListObjectsV2Command, NoSuchBucket, PutBucketAccelerateConfigurationCommand, PutBucketAnalyticsConfigurationCommand, PutBucketCorsCommand, PutBucketEncryptionCommand, PutBucketIntelligentTieringConfigurationCommand, PutBucketInventoryConfigurationCommand, PutBucketLifecycleConfigurationCommand, PutBucketLoggingCommand, PutBucketMetricsConfigurationCommand, PutBucketNotificationConfigurationCommand, PutBucketOwnershipControlsCommand, PutBucketPolicyCommand, PutBucketReplicationCommand, PutBucketTaggingCommand, PutBucketVersioningCommand, PutBucketWebsiteCommand, PutObjectCommand, PutObjectLockConfigurationCommand, PutPublicAccessBlockCommand, S3Client, S3ServiceException } from "@aws-sdk/client-s3";
@@ -20,7 +20,7 @@ import { CloudFrontClient, CreateCloudFrontOriginAccessIdentityCommand, CreateDi
20
20
  import { CloudWatchClient, DeleteAlarmsCommand, DescribeAlarmsCommand, ListTagsForResourceCommand as ListTagsForResourceCommand$4, PutMetricAlarmCommand, TagResourceCommand as TagResourceCommand$6, UntagResourceCommand as UntagResourceCommand$6 } from "@aws-sdk/client-cloudwatch";
21
21
  import { CloudWatchLogsClient, CreateLogGroupCommand, DeleteDataProtectionPolicyCommand, DeleteIndexPolicyCommand, DeleteLogGroupCommand, DeleteRetentionPolicyCommand, DescribeIndexPoliciesCommand, DescribeLogGroupsCommand, GetDataProtectionPolicyCommand, ListTagsForResourceCommand as ListTagsForResourceCommand$5, PutBearerTokenAuthenticationCommand, PutDataProtectionPolicyCommand, PutIndexPolicyCommand, PutLogGroupDeletionProtectionCommand, PutRetentionPolicyCommand, ResourceAlreadyExistsException, ResourceNotFoundException as ResourceNotFoundException$4, TagResourceCommand as TagResourceCommand$7, UntagResourceCommand as UntagResourceCommand$7 } from "@aws-sdk/client-cloudwatch-logs";
22
22
  import { BedrockAgentCoreControlClient, CreateAgentRuntimeCommand, DeleteAgentRuntimeCommand, GetAgentRuntimeCommand, ResourceNotFoundException as ResourceNotFoundException$5, UpdateAgentRuntimeCommand } from "@aws-sdk/client-bedrock-agentcore-control";
23
- import { cpSync, createWriteStream, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
23
+ import { cpSync, createWriteStream, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
24
24
  import * as path from "node:path";
25
25
  import { dirname, isAbsolute, join, normalize, resolve } from "node:path";
26
26
  import { execFile, spawn } from "node:child_process";
@@ -35304,10 +35304,15 @@ async function retireCloudFormationStack(options) {
35304
35304
  logger.info(`[3/4] Updating CloudFormation stack with Retain policies...`);
35305
35305
  let updateRan = false;
35306
35306
  try {
35307
+ const previousParameters = (stack.Parameters ?? []).map((p) => ({
35308
+ ParameterKey: p.ParameterKey,
35309
+ UsePreviousValue: true
35310
+ }));
35307
35311
  await cfnClient.send(new UpdateStackCommand({
35308
35312
  StackName: cfnStackName,
35309
35313
  ...updateInput,
35310
- Capabilities: capabilities
35314
+ Capabilities: capabilities,
35315
+ ...previousParameters.length > 0 && { Parameters: previousParameters }
35311
35316
  }));
35312
35317
  updateRan = true;
35313
35318
  } catch (err) {
@@ -35448,6 +35453,9 @@ async function confirmPrompt$2(prompt) {
35448
35453
 
35449
35454
  //#endregion
35450
35455
  //#region src/cli/commands/import.ts
35456
+ async function runImport(stackArg, options) {
35457
+ return importCommand(stackArg, options);
35458
+ }
35451
35459
  async function importCommand(stackArg, options) {
35452
35460
  const logger = getLogger();
35453
35461
  if (options.verbose) {
@@ -38761,7 +38769,7 @@ function resolveRuntimeCodeMountPath(runtime) {
38761
38769
 
38762
38770
  //#endregion
38763
38771
  //#region src/local/docker-runner.ts
38764
- const execFileAsync$2 = promisify(execFile);
38772
+ const execFileAsync$3 = promisify(execFile);
38765
38773
  /**
38766
38774
  * Wraps `docker pull` / `docker run` / `docker rm` for `cdkd local invoke`.
38767
38775
  *
@@ -38853,7 +38861,7 @@ async function runDetached(opts) {
38853
38861
  args.push(opts.image, ...entryPointTail, ...opts.cmd);
38854
38862
  getLogger().child("docker").debug(`${getDockerCmd()} ${redactAwsCredentialsInArgs(args).join(" ")}`);
38855
38863
  try {
38856
- const { stdout } = await execFileAsync$2(getDockerCmd(), args, { maxBuffer: 10 * 1024 * 1024 });
38864
+ const { stdout } = await execFileAsync$3(getDockerCmd(), args, { maxBuffer: 10 * 1024 * 1024 });
38857
38865
  return stdout.trim();
38858
38866
  } catch (error) {
38859
38867
  const err = error;
@@ -38890,7 +38898,7 @@ async function removeContainer(containerId) {
38890
38898
  if (!containerId) return;
38891
38899
  const logger = getLogger().child("docker");
38892
38900
  try {
38893
- await execFileAsync$2(getDockerCmd(), [
38901
+ await execFileAsync$3(getDockerCmd(), [
38894
38902
  "rm",
38895
38903
  "-f",
38896
38904
  containerId
@@ -38909,7 +38917,7 @@ async function removeContainer(containerId) {
38909
38917
  async function ensureDockerAvailable() {
38910
38918
  const cmd = getDockerCmd();
38911
38919
  try {
38912
- await execFileAsync$2(cmd, [
38920
+ await execFileAsync$3(cmd, [
38913
38921
  "version",
38914
38922
  "--format",
38915
38923
  "{{.Server.Version}}"
@@ -48052,7 +48060,7 @@ function createLocalStartApiCommand() {
48052
48060
 
48053
48061
  //#endregion
48054
48062
  //#region src/local/ecs-network.ts
48055
- const execFileAsync$1 = promisify(execFile);
48063
+ const execFileAsync$2 = promisify(execFile);
48056
48064
  /**
48057
48065
  * Docker network + AWS-published metadata-endpoints sidecar lifecycle for
48058
48066
  * `cdkd local run-task`. The sidecar (a small Go binary maintained by
@@ -48115,7 +48123,7 @@ async function createTaskNetwork(options = {}) {
48115
48123
  await pullImage(METADATA_ENDPOINT_IMAGE, options.skipPull ?? false);
48116
48124
  logger.info(`Creating docker network ${networkName} (subnet ${cidr})...`);
48117
48125
  try {
48118
- await execFileAsync$1(getDockerCmd(), [
48126
+ await execFileAsync$2(getDockerCmd(), [
48119
48127
  "network",
48120
48128
  "create",
48121
48129
  "--driver",
@@ -48151,7 +48159,7 @@ async function createTaskNetwork(options = {}) {
48151
48159
  logger.info(`Starting ECS local-container-endpoints sidecar at ${sidecarIp}...`);
48152
48160
  let sidecarContainerId;
48153
48161
  try {
48154
- const { stdout } = await execFileAsync$1(getDockerCmd(), sidecarArgs, { maxBuffer: 10 * 1024 * 1024 });
48162
+ const { stdout } = await execFileAsync$2(getDockerCmd(), sidecarArgs, { maxBuffer: 10 * 1024 * 1024 });
48155
48163
  sidecarContainerId = stdout.trim();
48156
48164
  } catch (err) {
48157
48165
  await destroyNetworkOnly(networkName);
@@ -48199,7 +48207,7 @@ async function destroyNetworkOnly(networkName) {
48199
48207
  if (!networkName) return;
48200
48208
  const logger = getLogger().child("ecs-network");
48201
48209
  try {
48202
- await execFileAsync$1(getDockerCmd(), [
48210
+ await execFileAsync$2(getDockerCmd(), [
48203
48211
  "network",
48204
48212
  "rm",
48205
48213
  networkName
@@ -48332,7 +48340,7 @@ async function resolveSsm(entry, shape, client) {
48332
48340
 
48333
48341
  //#endregion
48334
48342
  //#region src/local/ecs-task-runner.ts
48335
- const execFileAsync = promisify(execFile);
48343
+ const execFileAsync$1 = promisify(execFile);
48336
48344
  /**
48337
48345
  * Top-level orchestrator for `cdkd local run-task`. Coordinates image
48338
48346
  * preparation, secret resolution, docker-network bring-up, container
@@ -48392,7 +48400,7 @@ async function cleanupEcsRun(state, options) {
48392
48400
  await destroyTaskNetwork(state.network);
48393
48401
  state.network = void 0;
48394
48402
  for (const v of state.dockerVolumeNames) try {
48395
- await execFileAsync(getDockerCmd(), [
48403
+ await execFileAsync$1(getDockerCmd(), [
48396
48404
  "volume",
48397
48405
  "rm",
48398
48406
  v
@@ -48458,7 +48466,7 @@ async function runEcsTask(task, options, state) {
48458
48466
  logger.info(`Starting container '${container.name}' (image=${imagePlan.get(container.name)})`);
48459
48467
  let id;
48460
48468
  try {
48461
- const { stdout } = await execFileAsync(getDockerCmd(), args, { maxBuffer: 10 * 1024 * 1024 });
48469
+ const { stdout } = await execFileAsync$1(getDockerCmd(), args, { maxBuffer: 10 * 1024 * 1024 });
48462
48470
  id = stdout.trim();
48463
48471
  } catch (err) {
48464
48472
  const e = err;
@@ -48567,7 +48575,7 @@ async function waitForContainerHealthy(containerId, displayName) {
48567
48575
  let lastStatus = "";
48568
48576
  while (Date.now() < deadline) {
48569
48577
  try {
48570
- const { stdout } = await execFileAsync(getDockerCmd(), [
48578
+ const { stdout } = await execFileAsync$1(getDockerCmd(), [
48571
48579
  "inspect",
48572
48580
  "--format",
48573
48581
  "{{.State.Health.Status}}",
@@ -48590,7 +48598,7 @@ async function waitForContainerHealthy(containerId, displayName) {
48590
48598
  }
48591
48599
  async function waitForContainerExit(containerId) {
48592
48600
  try {
48593
- const { stdout } = await execFileAsync(getDockerCmd(), ["wait", containerId], { maxBuffer: 1024 * 1024 });
48601
+ const { stdout } = await execFileAsync$1(getDockerCmd(), ["wait", containerId], { maxBuffer: 1024 * 1024 });
48594
48602
  const code = Number.parseInt(stdout.trim(), 10);
48595
48603
  return Number.isFinite(code) ? code : 1;
48596
48604
  } catch (err) {
@@ -48600,7 +48608,7 @@ async function waitForContainerExit(containerId) {
48600
48608
  }
48601
48609
  async function stopContainer(containerId, graceSeconds) {
48602
48610
  try {
48603
- await execFileAsync(getDockerCmd(), [
48611
+ await execFileAsync$1(getDockerCmd(), [
48604
48612
  "stop",
48605
48613
  "-t",
48606
48614
  String(graceSeconds),
@@ -48725,7 +48733,7 @@ async function realizeDockerVolumes(volumes, state) {
48725
48733
  const dockerVolumeName = `cdkd-local-${v.name}-${randHex(4)}`;
48726
48734
  args.push(dockerVolumeName);
48727
48735
  try {
48728
- await execFileAsync(getDockerCmd(), args);
48736
+ await execFileAsync$1(getDockerCmd(), args);
48729
48737
  state.dockerVolumeNames.push(dockerVolumeName);
48730
48738
  logger.debug(`Created docker volume ${dockerVolumeName} for task volume '${v.name}'`);
48731
48739
  } catch (err) {
@@ -51859,6 +51867,1069 @@ function createExportCommand() {
51859
51867
  return cmd;
51860
51868
  }
51861
51869
 
51870
+ //#endregion
51871
+ //#region src/cli/commands/migrate/cdk-cli-check.ts
51872
+ const execFileAsync = promisify(execFile);
51873
+ /**
51874
+ * Minimum aws-cdk CLI version where `cdk migrate --from-stack` is
51875
+ * considered stable (per the design doc at docs/design/465-cfn-migrate.md
51876
+ * §4 — "Version requirement"). Below this we WARN but do not block,
51877
+ * because users may have a working older release and a hard rejection
51878
+ * would be more disruptive than a warning.
51879
+ */
51880
+ const RECOMMENDED_MIN_VERSION = "2.124.0";
51881
+ /**
51882
+ * Verify that the upstream `cdk` CLI is available on PATH (or at the
51883
+ * override path passed via `--cdk-bin`) and report its version.
51884
+ *
51885
+ * Hard-errors with {@link MissingCdkCliError} when `cdk` cannot be
51886
+ * spawned (ENOENT, permission denied, etc.). Emits a `warn` field on
51887
+ * the result when the version string is below the recommended minimum
51888
+ * — the caller decides how to surface it.
51889
+ *
51890
+ * `cdk --version` output format empirically (verified 2026-05-22
51891
+ * against cdk@2.1112.0): `<MAJOR.MINOR.PATCH> (build <id>)`. The
51892
+ * version is the first whitespace-separated token; the trailing
51893
+ * `(build <id>)` is informational and ignored.
51894
+ *
51895
+ * @param cdkBinPath - Path to the `cdk` binary. Defaults to `'cdk'`
51896
+ * which uses the system PATH for resolution.
51897
+ */
51898
+ async function verifyCdkCliAvailable(cdkBinPath = "cdk") {
51899
+ let stdout;
51900
+ try {
51901
+ stdout = (await execFileAsync(cdkBinPath, ["--version"])).stdout ?? "";
51902
+ } catch (err) {
51903
+ throw new MissingCdkCliError(`Failed to run '${cdkBinPath} --version': ${err instanceof Error ? err.message : String(err)}`, err instanceof Error ? err : void 0);
51904
+ }
51905
+ const version = parseCdkVersion(stdout);
51906
+ if (!version) throw new MissingCdkCliError(`'${cdkBinPath} --version' produced unexpected output: ${JSON.stringify(stdout.trim())}`);
51907
+ if (compareSemver(version, RECOMMENDED_MIN_VERSION) < 0) return {
51908
+ version,
51909
+ warn: `cdk CLI version ${version} is older than the recommended minimum ${RECOMMENDED_MIN_VERSION}. 'cdkd migrate' relies on the stabilized 'cdk migrate --from-stack' flag; upgrade with 'npm install -g aws-cdk@latest' if you hit codegen issues.`
51910
+ };
51911
+ return { version };
51912
+ }
51913
+ /**
51914
+ * Parse the first semver-shaped token (`<MAJOR>.<MINOR>.<PATCH>`) out of
51915
+ * `cdk --version` stdout. Tolerates a trailing `(build <id>)` suffix.
51916
+ *
51917
+ * Returns `undefined` when no semver-shaped token is present so the
51918
+ * caller can surface the raw stdout in the error message.
51919
+ */
51920
+ function parseCdkVersion(stdout) {
51921
+ const match = stdout.match(/(\d+)\.(\d+)\.(\d+)/);
51922
+ if (!match) return void 0;
51923
+ return `${match[1]}.${match[2]}.${match[3]}`;
51924
+ }
51925
+ /**
51926
+ * Compare two semver strings (`MAJOR.MINOR.PATCH`). Returns a negative
51927
+ * number when `a < b`, zero when `a === b`, positive when `a > b`.
51928
+ *
51929
+ * Strictly numeric comparison per component — no pre-release / build
51930
+ * suffix handling needed for this use case (`cdk --version` always
51931
+ * emits a stable `X.Y.Z` triple).
51932
+ */
51933
+ function compareSemver(a, b) {
51934
+ const aParts = a.split(".").map((n) => Number.parseInt(n, 10));
51935
+ const bParts = b.split(".").map((n) => Number.parseInt(n, 10));
51936
+ for (let i = 0; i < 3; i++) {
51937
+ const aN = aParts[i] ?? 0;
51938
+ const bN = bParts[i] ?? 0;
51939
+ if (aN !== bN) return aN - bN;
51940
+ }
51941
+ return 0;
51942
+ }
51943
+
51944
+ //#endregion
51945
+ //#region src/cli/commands/migrate/cfn-stack-prefetch.ts
51946
+ /**
51947
+ * Resource types that `cdkd migrate` refuses to adopt under any
51948
+ * circumstances. The rationale per type:
51949
+ *
51950
+ * - `AWS::CloudFormation::CustomResource` — Lambda-backed Custom
51951
+ * Resources whose response protocol (cfn-response over a
51952
+ * pre-signed S3 URL) is incompatible with cdkd's import flow. The
51953
+ * backing Lambda's onCreate handler would need to be re-invoked
51954
+ * for a cdkd-side adoption to make sense, and that's structurally
51955
+ * different from the metadata-transfer migration this command
51956
+ * provides.
51957
+ *
51958
+ * - `AWS::CloudFormation::Stack` — nested stacks. cdkd has no
51959
+ * provider for this type, and the matching `cdk migrate` output
51960
+ * flattens nested stacks into separate generated apps in a way
51961
+ * that doesn't round-trip cleanly. Out of scope for #465.
51962
+ *
51963
+ * - `Custom::*` — any user-defined Custom Resource type prefix.
51964
+ * Same rationale as `AWS::CloudFormation::CustomResource`.
51965
+ */
51966
+ const HARD_REJECT_RESOURCE_TYPES = new Set(["AWS::CloudFormation::CustomResource", "AWS::CloudFormation::Stack"]);
51967
+ /**
51968
+ * Fetch the source CFn stack's current state + resources + transform
51969
+ * info. Issues 3 read-only AWS API calls (`DescribeStacks`,
51970
+ * `DescribeStackResources`, `GetTemplate(Stage=Original)`); none of
51971
+ * them mutate AWS state. The result is fed into
51972
+ * {@link validatePrefetchResult} for the hard-reject check and
51973
+ * surfaced to {@link runMigrateLibrary}'s caller (PR B) as part of
51974
+ * `RunMigrateLibraryResult`.
51975
+ *
51976
+ * `DescribeStackResources` is preferred over `ListStackResources`
51977
+ * because the response is unpaginated up to 500 resources (CFn's hard
51978
+ * stack cap) — no pagination loop needed for the migration use case.
51979
+ */
51980
+ async function prefetchCfnStack(stackName, cfnClient) {
51981
+ const stack = (await cfnClient.send(new DescribeStacksCommand({ StackName: stackName }))).Stacks?.[0];
51982
+ if (!stack) throw new LocalMigrateError(`CloudFormation stack '${stackName}' not found.`);
51983
+ const stackStatus = stack.StackStatus ?? "";
51984
+ const resourcesResp = await cfnClient.send(new DescribeStackResourcesCommand({ StackName: stackName }));
51985
+ const resources = [];
51986
+ for (const r of resourcesResp.StackResources ?? []) {
51987
+ if (!r.LogicalResourceId || !r.PhysicalResourceId || !r.ResourceType) continue;
51988
+ resources.push({
51989
+ LogicalResourceId: r.LogicalResourceId,
51990
+ PhysicalResourceId: r.PhysicalResourceId,
51991
+ ResourceType: r.ResourceType
51992
+ });
51993
+ }
51994
+ let transformInfo = {
51995
+ hasSamTransform: false,
51996
+ hasIncludeTransform: false
51997
+ };
51998
+ let sourceCfnTemplate = null;
51999
+ try {
52000
+ const tplResp = await cfnClient.send(new GetTemplateCommand({
52001
+ StackName: stackName,
52002
+ TemplateStage: "Original"
52003
+ }));
52004
+ if (tplResp.TemplateBody) {
52005
+ try {
52006
+ sourceCfnTemplate = parseCfnTemplate(tplResp.TemplateBody);
52007
+ } catch {
52008
+ sourceCfnTemplate = null;
52009
+ }
52010
+ transformInfo = detectTransformsFromParsed(sourceCfnTemplate);
52011
+ }
52012
+ } catch (err) {
52013
+ const detail = err instanceof Error ? err.message : String(err);
52014
+ getLogger().warn(`[migrate] GetTemplate failed for '${stackName}': ${detail}. Skipping transform detection.`);
52015
+ }
52016
+ return {
52017
+ stackStatus,
52018
+ resources,
52019
+ transformInfo,
52020
+ sourceCfnTemplate
52021
+ };
52022
+ }
52023
+ /**
52024
+ * Pre-flight reject when the source CFn stack contains any
52025
+ * un-migratable resource type OR is in a non-terminal state. Surfaces
52026
+ * SAM / Include transforms via the result's `transformInfo` instead —
52027
+ * the caller is responsible for emitting INFO logs.
52028
+ *
52029
+ * Per #465 Q2 (parent-session decision): every hard-reject case
52030
+ * routes the user to the standalone `cdk migrate --from-stack <name>
52031
+ * --output-dir ./tmp` flow so they can manually inspect the generated
52032
+ * code and decide how to proceed (e.g. delete the Custom Resource
52033
+ * from the source CFn stack before re-running cdkd migrate).
52034
+ */
52035
+ function validatePrefetchResult(result) {
52036
+ if (!STABLE_TERMINAL_STATUSES.has(result.stackStatus)) throw new LocalMigrateError(`CloudFormation stack is in status '${result.stackStatus}', which is not a stable terminal state. Wait for the stack to settle (or roll back) before running 'cdkd migrate'.`);
52037
+ const offenders = [];
52038
+ for (const r of result.resources) if (isHardRejectType(r.ResourceType)) offenders.push({
52039
+ LogicalResourceId: r.LogicalResourceId,
52040
+ ResourceType: r.ResourceType
52041
+ });
52042
+ if (offenders.length === 0) return;
52043
+ throw new LocalMigrateError(`Source CloudFormation stack contains resource types that 'cdkd migrate' cannot adopt:\n${offenders.map((o) => ` - ${o.LogicalResourceId} (${o.ResourceType})`).join("\n")}\n\nLambda-backed Custom Resources and nested CloudFormation stacks are out of scope for #465.\nTo migrate the rest of the stack, either:\n 1. Delete the unsupported resources from the source CFn stack first, OR\n 2. Run upstream 'cdk migrate' standalone to inspect the generated code:\n cdk migrate --from-stack --stack-name <source-stack> --output-path ./tmp\n then hand-author CDK constructs to replace the Custom Resource semantics.`);
52044
+ }
52045
+ /**
52046
+ * Whether the given resource type is in the hard-reject set. Matches
52047
+ * literal types from {@link HARD_REJECT_RESOURCE_TYPES} AND the
52048
+ * `Custom::*` prefix pattern (user-defined Custom Resource types).
52049
+ */
52050
+ function isHardRejectType(resourceType) {
52051
+ if (HARD_REJECT_RESOURCE_TYPES.has(resourceType)) return true;
52052
+ if (resourceType.startsWith("Custom::")) return true;
52053
+ return false;
52054
+ }
52055
+ /**
52056
+ * Scan an already-parsed CFn template object for stack-level
52057
+ * Transform blocks. Accepts both the scalar form (`Transform:
52058
+ * AWS::Serverless-2016-10-31`) and the array form (`Transform:
52059
+ * [AWS::Serverless-2016-10-31, ...]`).
52060
+ *
52061
+ * The caller is responsible for parsing the raw template body via the
52062
+ * CFn-aware codec; this helper operates on the parsed object so the
52063
+ * `GetTemplate` response can be parsed once and reused.
52064
+ */
52065
+ function detectTransformsFromParsed(parsed) {
52066
+ if (!parsed || typeof parsed !== "object") return {
52067
+ hasSamTransform: false,
52068
+ hasIncludeTransform: false
52069
+ };
52070
+ const transformRaw = parsed["Transform"];
52071
+ const transforms = Array.isArray(transformRaw) ? transformRaw.map((t) => String(t)) : typeof transformRaw === "string" ? [transformRaw] : [];
52072
+ return {
52073
+ hasSamTransform: transforms.some((t) => t.startsWith("AWS::Serverless")),
52074
+ hasIncludeTransform: transforms.some((t) => t === "AWS::Include" || t.startsWith("AWS::Include"))
52075
+ };
52076
+ }
52077
+
52078
+ //#endregion
52079
+ //#region src/cli/commands/migrate/output-dir-guard.ts
52080
+ /**
52081
+ * Refuse to run `cdkd migrate` when the upstream `cdk migrate` would
52082
+ * write its output into an existing non-empty directory. `cdk migrate`
52083
+ * itself silently overwrites in that case; cdkd surfaces the collision
52084
+ * up-front with the exact recovery command so the user is not left
52085
+ * with a half-overwritten CDK app.
52086
+ *
52087
+ * Behavior matrix (`outputPath = <user-supplied dir>`, `stackName =
52088
+ * <CFn stack name>`; cdk writes to `<outputPath>/<stackName>`):
52089
+ * - target dir does not exist → OK
52090
+ * - target dir exists but is empty → OK (treated as fresh)
52091
+ * - target path is a file, not a directory → typed error
52092
+ * - target dir exists and contains entries → typed error with
52093
+ * the canonical
52094
+ * `rm -rf` recovery
52095
+ * command in the
52096
+ * message
52097
+ *
52098
+ * The parent `outputPath` is NOT checked — `cdk migrate` creates
52099
+ * `<outputPath>` itself if missing, so a non-existent parent is fine.
52100
+ */
52101
+ function assertOutputDirAvailable(outputPath, stackName) {
52102
+ const targetDir = resolve(outputPath, stackName);
52103
+ if (!existsSync(targetDir)) return;
52104
+ if (!statSync(targetDir).isDirectory()) throw new LocalMigrateError(`Output path '${targetDir}' exists but is not a directory. Remove it (or pass --output-dir <NEW_PATH>) and retry:\n rm -f ${targetDir} && cdkd migrate --from-cfn-stack ${stackName}`);
52105
+ const entries = readdirSync(targetDir);
52106
+ if (entries.length === 0) return;
52107
+ throw new LocalMigrateError(`Output dir '${targetDir}' already exists and is non-empty (e.g. '${join(targetDir, entries[0])}'); remove it or pass --output-dir <NEW_PATH>:\n rm -rf ${targetDir} && cdkd migrate --from-cfn-stack ${stackName}`);
52108
+ }
52109
+
52110
+ //#endregion
52111
+ //#region src/cli/commands/migrate/cdk-migrate-spawn.ts
52112
+ /**
52113
+ * Spawn `cdk migrate --from-stack` as a subprocess and stream its
52114
+ * output through cdkd's logger.
52115
+ *
52116
+ * Why subprocess (not Node module import): the upstream `cdk` CLI is
52117
+ * a bundled CommonJS app with no stable public API; pinning a
52118
+ * runtime-injected `aws-cdk-lib` from cdkd is impractical. Subprocess
52119
+ * isolation also gives us a clean failure boundary — non-zero exit
52120
+ * surfaces as {@link LocalMigrateError} with the captured streams
52121
+ * folded into the error message.
52122
+ *
52123
+ * Streaming is preferred over a buffered `execFile` because `cdk
52124
+ * migrate` emits progress lines (downloading the resource schema,
52125
+ * synthesizing the L1 code for each resource) that users expect to
52126
+ * see in real time. Captured streams are still returned for caller
52127
+ * inspection.
52128
+ */
52129
+ async function spawnCdkMigrate(opts) {
52130
+ const logger = getLogger();
52131
+ const cdkBin = opts.cdkBinPath ?? "cdk";
52132
+ const language = opts.language ?? "typescript";
52133
+ const outputDir = resolve(opts.outputPath, opts.stackName);
52134
+ const args = [
52135
+ "migrate",
52136
+ "--from-stack",
52137
+ "--stack-name",
52138
+ opts.fromStackName,
52139
+ "--output-path",
52140
+ opts.outputPath,
52141
+ "--language",
52142
+ language
52143
+ ];
52144
+ if (opts.region) args.push("--region", opts.region);
52145
+ if (opts.account) args.push("--account", opts.account);
52146
+ if (opts.profile) args.push("--profile", opts.profile);
52147
+ for (const filter of opts.filters ?? []) args.push("--filter", filter);
52148
+ const env = {
52149
+ ...process.env,
52150
+ ...opts.extraEnv ?? {}
52151
+ };
52152
+ logger.info(`[cdk-migrate] ${cdkBin} ${args.join(" ")}`);
52153
+ return await new Promise((resolvePromise, rejectPromise) => {
52154
+ let stdout = "";
52155
+ let stderr = "";
52156
+ let child;
52157
+ try {
52158
+ child = spawn(cdkBin, args, {
52159
+ env,
52160
+ stdio: [
52161
+ "ignore",
52162
+ "pipe",
52163
+ "pipe"
52164
+ ]
52165
+ });
52166
+ } catch (err) {
52167
+ rejectPromise(new LocalMigrateError(`Failed to spawn '${cdkBin} migrate': ${err instanceof Error ? err.message : String(err)}`, err instanceof Error ? err : void 0));
52168
+ return;
52169
+ }
52170
+ child.stdout?.on("data", (chunk) => {
52171
+ const text = chunk.toString("utf-8");
52172
+ stdout += text;
52173
+ const trimmed = text.replace(/\n$/, "");
52174
+ if (trimmed) logger.info(`[cdk-migrate] ${trimmed}`);
52175
+ });
52176
+ child.stderr?.on("data", (chunk) => {
52177
+ const text = chunk.toString("utf-8");
52178
+ stderr += text;
52179
+ const trimmed = text.replace(/\n$/, "");
52180
+ if (trimmed) logger.warn(`[cdk-migrate] ${trimmed}`);
52181
+ });
52182
+ child.on("error", (err) => {
52183
+ rejectPromise(new LocalMigrateError(`'${cdkBin} migrate' subprocess error: ${err.message}`, err));
52184
+ });
52185
+ child.on("close", (code, signal) => {
52186
+ if (code === 0) {
52187
+ resolvePromise({
52188
+ outputDir,
52189
+ stdout,
52190
+ stderr
52191
+ });
52192
+ return;
52193
+ }
52194
+ rejectPromise(new LocalMigrateError(`'${cdkBin} migrate' ${signal !== null ? `killed by signal ${signal}` : code !== null ? `exited with code ${code}` : "process closed without a code or signal"}.\n--- stdout ---\n${stdout || "(empty)"}\n--- stderr ---\n${stderr || "(empty)"}\n`));
52195
+ });
52196
+ });
52197
+ }
52198
+
52199
+ //#endregion
52200
+ //#region src/cli/commands/migrate/synth-after-migrate.ts
52201
+ /**
52202
+ * Run `npm install` inside the generated CDK app directory so the
52203
+ * `cdk synth` step (which dynamically loads `aws-cdk-lib`) has its
52204
+ * dependencies in place.
52205
+ *
52206
+ * Skippable via `skipInstall: true` — CI users with a pre-populated
52207
+ * node_modules cache can save the ~30s `npm install` round-trip.
52208
+ *
52209
+ * Streams stdout / stderr through cdkd's logger so users see progress
52210
+ * for the often-slow npm step. Non-zero exit surfaces as a typed
52211
+ * {@link LocalMigrateError} with the captured streams folded into
52212
+ * the error message.
52213
+ */
52214
+ async function installGeneratedAppDeps(outputDir, opts) {
52215
+ const logger = getLogger();
52216
+ if (opts.skipInstall) {
52217
+ logger.info(`[cdk-migrate] Skipping 'npm install' (--skip-install).`);
52218
+ return;
52219
+ }
52220
+ const absDir = resolve(outputDir);
52221
+ if (!existsSync(absDir)) throw new LocalMigrateError(`Generated app directory '${absDir}' does not exist; cannot run 'npm install'.`);
52222
+ logger.info(`[cdk-migrate] Running 'npm install' in ${absDir}...`);
52223
+ await runStreamingCommand("npm", ["install"], absDir, opts.extraEnv ? {
52224
+ ...process.env,
52225
+ ...opts.extraEnv
52226
+ } : void 0, "npm install");
52227
+ }
52228
+ /**
52229
+ * Run `cdk synth --quiet` inside the generated CDK app directory and
52230
+ * return both the assembly dir path and the parsed root-stack
52231
+ * template.
52232
+ *
52233
+ * `--quiet` suppresses the noisy template echo that `cdk synth`
52234
+ * defaults to on stdout — we read the template from disk
52235
+ * (`cdk.out/<StackName>.template.json`) instead.
52236
+ *
52237
+ * Skippable via `skipSynth: true` — useful when PR B's orchestrator
52238
+ * needs the codegen output without running synth (e.g. preview /
52239
+ * dry-run modes).
52240
+ */
52241
+ async function synthGeneratedApp(outputDir, opts) {
52242
+ const logger = getLogger();
52243
+ const absDir = resolve(outputDir);
52244
+ const assemblyDir = join(absDir, "cdk.out");
52245
+ if (opts.skipSynth) {
52246
+ logger.info(`[cdk-migrate] Skipping 'cdk synth' (--skip-synth).`);
52247
+ return {
52248
+ assemblyDir,
52249
+ templateBody: null
52250
+ };
52251
+ }
52252
+ if (!existsSync(absDir)) throw new LocalMigrateError(`Generated app directory '${absDir}' does not exist; cannot run 'cdk synth'.`);
52253
+ const cdkBin = opts.cdkBinPath ?? "cdk";
52254
+ const env = {
52255
+ ...process.env,
52256
+ ...opts.extraEnv ?? {}
52257
+ };
52258
+ logger.info(`[cdk-migrate] Running '${cdkBin} synth --quiet' in ${absDir}...`);
52259
+ await runStreamingCommand(cdkBin, ["synth", "--quiet"], absDir, env, `${cdkBin} synth`);
52260
+ if (!existsSync(assemblyDir)) throw new LocalMigrateError(`'cdk synth' completed but produced no '${assemblyDir}' directory.`);
52261
+ const templates = readdirSync(assemblyDir).filter((f) => f.endsWith(".template.json"));
52262
+ if (templates.length === 0) return {
52263
+ assemblyDir,
52264
+ templateBody: null
52265
+ };
52266
+ const templatePath = join(assemblyDir, templates[0]);
52267
+ let templateBody;
52268
+ try {
52269
+ const raw = readFileSync(templatePath, "utf-8");
52270
+ templateBody = JSON.parse(raw);
52271
+ } catch (err) {
52272
+ throw new LocalMigrateError(`Failed to parse generated template '${templatePath}': ${err instanceof Error ? err.message : String(err)}`, err instanceof Error ? err : void 0);
52273
+ }
52274
+ return {
52275
+ assemblyDir,
52276
+ templateBody
52277
+ };
52278
+ }
52279
+ /**
52280
+ * Helper — spawn a subprocess in the given working directory, stream
52281
+ * its output through cdkd's logger, and reject with a typed
52282
+ * {@link LocalMigrateError} on non-zero exit.
52283
+ *
52284
+ * Factored out so the `npm install` and `cdk synth` call sites use the
52285
+ * same shape; both expose progress to the user and surface failures
52286
+ * with the captured streams.
52287
+ */
52288
+ async function runStreamingCommand(bin, args, cwd, env, label) {
52289
+ const logger = getLogger();
52290
+ return await new Promise((resolvePromise, rejectPromise) => {
52291
+ let stdout = "";
52292
+ let stderr = "";
52293
+ let child;
52294
+ try {
52295
+ child = spawn(bin, args, {
52296
+ cwd,
52297
+ env: env ?? process.env,
52298
+ stdio: [
52299
+ "ignore",
52300
+ "pipe",
52301
+ "pipe"
52302
+ ]
52303
+ });
52304
+ } catch (err) {
52305
+ rejectPromise(new LocalMigrateError(`Failed to spawn ${label}: ${err instanceof Error ? err.message : String(err)}`, err instanceof Error ? err : void 0));
52306
+ return;
52307
+ }
52308
+ child.stdout?.on("data", (chunk) => {
52309
+ const text = chunk.toString("utf-8");
52310
+ stdout += text;
52311
+ const trimmed = text.replace(/\n$/, "");
52312
+ if (trimmed) logger.info(`[${label}] ${trimmed}`);
52313
+ });
52314
+ child.stderr?.on("data", (chunk) => {
52315
+ const text = chunk.toString("utf-8");
52316
+ stderr += text;
52317
+ const trimmed = text.replace(/\n$/, "");
52318
+ if (trimmed) logger.warn(`[${label}] ${trimmed}`);
52319
+ });
52320
+ child.on("error", (err) => {
52321
+ rejectPromise(new LocalMigrateError(`${label} subprocess error: ${err.message}`, err));
52322
+ });
52323
+ child.on("close", (code, signal) => {
52324
+ if (code === 0) {
52325
+ resolvePromise();
52326
+ return;
52327
+ }
52328
+ rejectPromise(new LocalMigrateError(`${label} ${signal !== null ? `killed by signal ${signal}` : code !== null ? `exited with code ${code}` : "process closed without a code or signal"}.\n--- stdout ---\n${stdout || "(empty)"}\n--- stderr ---\n${stderr || "(empty)"}\n`));
52329
+ });
52330
+ });
52331
+ }
52332
+
52333
+ //#endregion
52334
+ //#region src/cli/commands/migrate/index.ts
52335
+ /**
52336
+ * Build an extra-env bag for subprocesses (`npm install`, `cdk synth`)
52337
+ * so they inherit the AWS profile the caller selected via `--profile`.
52338
+ *
52339
+ * Without this, `cdk synth` falls back to the SDK default credential
52340
+ * chain (or no credentials at all) when the user runs cdkd under a
52341
+ * non-default profile — which silently produces a template synthesized
52342
+ * against the wrong account / region context.
52343
+ */
52344
+ function buildAwsEnv(opts) {
52345
+ return opts.profile ? { AWS_PROFILE: opts.profile } : {};
52346
+ }
52347
+ /**
52348
+ * PR A library entry point — orchestrates the codegen + synth half of
52349
+ * `cdkd migrate --from-cfn-stack` without writing cdkd state, running
52350
+ * import, or touching the source CFn stack.
52351
+ *
52352
+ * Flow:
52353
+ * 1. `verifyCdkCliAvailable` — hard-fail if `cdk` is missing.
52354
+ * 2. `prefetchCfnStack` — read source stack state + resources +
52355
+ * transform info.
52356
+ * 3. `validatePrefetchResult` — reject CR / nested-stack / non-
52357
+ * terminal state; INFO log SAM / Include transforms.
52358
+ * 4. `assertOutputDirAvailable` — refuse pre-existing non-empty
52359
+ * output dir.
52360
+ * 5. `spawnCdkMigrate` — run `cdk migrate --from-stack` subprocess.
52361
+ * 6. `installGeneratedAppDeps` (gated by `skipInstall`).
52362
+ * 7. `synthGeneratedApp` (gated by `skipSynth`).
52363
+ * 8. Return every artifact PR B will consume.
52364
+ *
52365
+ * Per #465 Q4 (parent-session decision): PR A is library-only. The CLI
52366
+ * command (`cdkd migrate`), state writes, retire flow, and resource-
52367
+ * mapping algorithm all live in PR B.
52368
+ */
52369
+ async function runMigrateLibrary(opts) {
52370
+ const logger = getLogger();
52371
+ const stackName = opts.fromCfnStack;
52372
+ const outputPath = opts.outputDir ?? resolve(process.cwd(), stackName);
52373
+ logger.info(`[migrate] Verifying upstream 'cdk' CLI...`);
52374
+ const cliCheck = await verifyCdkCliAvailable(opts.cdkBinPath);
52375
+ logger.info(`[migrate] Using cdk CLI v${cliCheck.version}`);
52376
+ if (cliCheck.warn) logger.warn(`[migrate] ${cliCheck.warn}`);
52377
+ logger.info(`[migrate] Pre-fetching CloudFormation stack '${stackName}'...`);
52378
+ const cfnClient = buildCfnClient(opts);
52379
+ let prefetch;
52380
+ try {
52381
+ prefetch = await prefetchCfnStack(stackName, cfnClient);
52382
+ } finally {
52383
+ cfnClient.destroy?.();
52384
+ }
52385
+ validatePrefetchResult(prefetch);
52386
+ if (prefetch.transformInfo.hasSamTransform) logger.info("[migrate] INFO: source CFn stack uses 'AWS::Serverless' transform; cdk migrate will expand it client-side and the generated CDK code will use plain Lambda + API Gateway L1 constructs (not SAM).");
52387
+ if (prefetch.transformInfo.hasIncludeTransform) logger.info("[migrate] INFO: source CFn stack uses 'AWS::Include' transform; cdk migrate will expand it client-side and the generated CDK code will inline the included template content.");
52388
+ assertOutputDirAvailable(outputPath, stackName);
52389
+ const spawnResult = await spawnCdkMigrate({
52390
+ stackName,
52391
+ fromStackName: stackName,
52392
+ outputPath,
52393
+ ...opts.language && { language: opts.language },
52394
+ ...opts.region && { region: opts.region },
52395
+ ...opts.account && { account: opts.account },
52396
+ ...opts.filters && { filters: opts.filters },
52397
+ ...opts.profile && { profile: opts.profile },
52398
+ ...opts.cdkBinPath && { cdkBinPath: opts.cdkBinPath }
52399
+ });
52400
+ await installGeneratedAppDeps(spawnResult.outputDir, {
52401
+ skipInstall: opts.skipInstall ?? false,
52402
+ extraEnv: buildAwsEnv(opts)
52403
+ });
52404
+ const synthResult = await synthGeneratedApp(spawnResult.outputDir, {
52405
+ skipSynth: opts.skipSynth ?? false,
52406
+ ...opts.cdkBinPath && { cdkBinPath: opts.cdkBinPath },
52407
+ extraEnv: buildAwsEnv(opts)
52408
+ });
52409
+ if (prefetch.sourceCfnTemplate === null) throw new LocalMigrateError(`Could not read the source CloudFormation template for '${stackName}' (GetTemplate failed during pre-flight). Re-run after granting cloudformation:GetTemplate on the stack, or check the earlier warning for details.`);
52410
+ return {
52411
+ outputDir: spawnResult.outputDir,
52412
+ assemblyDir: synthResult.assemblyDir,
52413
+ templateBody: synthResult.templateBody,
52414
+ sourceCfnTemplate: prefetch.sourceCfnTemplate,
52415
+ sourceResources: prefetch.resources
52416
+ };
52417
+ }
52418
+ /**
52419
+ * Build a CloudFormation client using cdkd's shared {@link AwsClients}
52420
+ * factory so the credential chain matches every other cdkd command.
52421
+ *
52422
+ * Keeping the construction in one place makes the per-command CFn-
52423
+ * client recipe a single readable hook for future plumbing (e.g.
52424
+ * threading PR B's `--role-arn` STS-assume).
52425
+ */
52426
+ function buildCfnClient(opts) {
52427
+ const config = {};
52428
+ if (opts.region) config.region = opts.region;
52429
+ if (opts.profile) config.profile = opts.profile;
52430
+ return new AwsClients(config).getCloudFormationClient();
52431
+ }
52432
+
52433
+ //#endregion
52434
+ //#region src/cli/commands/migrate/resource-mapper.ts
52435
+ /**
52436
+ * Run the 2-pass mapping algorithm against `(sourceCfnTemplate,
52437
+ * synthTemplate, sourceResources)` and return the resolved mapping plus
52438
+ * any unmatched entries.
52439
+ *
52440
+ * Pure-functional: no AWS calls, no I/O, no mutation of inputs. Safe to
52441
+ * call repeatedly in tests against shared fixtures.
52442
+ *
52443
+ * Throws when an override references a synth id that does not exist —
52444
+ * this is a user error in the hand-edited mapping JSON and surfacing
52445
+ * the available synth ids in the message is the path back to a working
52446
+ * config.
52447
+ */
52448
+ function buildResourceMapping(opts) {
52449
+ const sourceEntries = extractSourceResources(opts.sourceCfnTemplate);
52450
+ const synthEntries = extractSynthResources(opts.synthTemplate);
52451
+ const physicalIdByLogicalId = /* @__PURE__ */ new Map();
52452
+ for (const r of opts.sourceResources) physicalIdByLogicalId.set(r.LogicalResourceId, r.PhysicalResourceId);
52453
+ const overrides = opts.overrides ?? {};
52454
+ const synthLogicalIds = new Set(synthEntries.map((s) => s.logicalId));
52455
+ for (const [srcId, synthId] of Object.entries(overrides)) if (!synthLogicalIds.has(synthId)) throw new Error(`Resource-mapping override targets synth logical id '${synthId}' (for source id '${srcId}'), but that id is not in the synthesized template. Available synth ids: ${[...synthLogicalIds].sort().join(", ")}`);
52456
+ const missingPhysicalIds = [];
52457
+ for (const src of sourceEntries) if (!physicalIdByLogicalId.has(src.logicalId)) missingPhysicalIds.push(` - ${src.logicalId} (${src.type})`);
52458
+ if (missingPhysicalIds.length > 0) throw new Error(`Source CFn resource(s) not returned by DescribeStackResources — cannot migrate:\n${missingPhysicalIds.join("\n")}\n\nThe stack may have been mid-operation; wait for it to settle (CREATE_COMPLETE / UPDATE_COMPLETE / etc.) and retry.`);
52459
+ const synthByLastPathSegment = indexSynthByLastPathSegment(synthEntries);
52460
+ const synthByLogicalId = new Map(synthEntries.map((s) => [s.logicalId, s]));
52461
+ const claimedSynthIds = /* @__PURE__ */ new Set();
52462
+ const pairs = [];
52463
+ const unmatched = [];
52464
+ const sourcesHandledByOverride = /* @__PURE__ */ new Set();
52465
+ for (const [srcId, synthId] of Object.entries(overrides)) {
52466
+ const src = sourceEntries.find((e) => e.logicalId === srcId);
52467
+ if (!src) {
52468
+ const availableSrc = sourceEntries.map((e) => e.logicalId).sort();
52469
+ throw new Error(`Resource-mapping override references source logical id '${srcId}', but no resource with that id exists in the source CloudFormation template. Available source ids: ${availableSrc.join(", ")}`);
52470
+ }
52471
+ const synth = synthByLogicalId.get(synthId);
52472
+ if (!synth) continue;
52473
+ const physicalId = physicalIdByLogicalId.get(src.logicalId);
52474
+ pairs.push({
52475
+ sourceLogicalId: src.logicalId,
52476
+ synthLogicalId: synth.logicalId,
52477
+ physicalId,
52478
+ resourceType: src.type
52479
+ });
52480
+ claimedSynthIds.add(synth.logicalId);
52481
+ sourcesHandledByOverride.add(src.logicalId);
52482
+ }
52483
+ const passOnePending = [];
52484
+ for (const src of sourceEntries) {
52485
+ if (sourcesHandledByOverride.has(src.logicalId)) continue;
52486
+ const candidatesForLastSegment = synthByLastPathSegment.get(src.logicalId) ?? [];
52487
+ const rawCandidateCount = candidatesForLastSegment.length;
52488
+ const availableCandidates = candidatesForLastSegment.filter((c) => !claimedSynthIds.has(c.logicalId));
52489
+ if (rawCandidateCount === 1 && availableCandidates.length === 1) {
52490
+ const synth = availableCandidates[0];
52491
+ pairs.push({
52492
+ sourceLogicalId: src.logicalId,
52493
+ synthLogicalId: synth.logicalId,
52494
+ physicalId: physicalIdByLogicalId.get(src.logicalId),
52495
+ resourceType: src.type
52496
+ });
52497
+ claimedSynthIds.add(synth.logicalId);
52498
+ } else passOnePending.push(src);
52499
+ }
52500
+ for (const src of passOnePending) {
52501
+ const candidatesSameType = synthEntries.filter((s) => s.type === src.type && !claimedSynthIds.has(s.logicalId));
52502
+ const propertyMatches = candidatesSameType.filter((s) => deepEqualIgnoreNoValue(src.properties, s.properties));
52503
+ if (propertyMatches.length === 1) {
52504
+ const synth = propertyMatches[0];
52505
+ pairs.push({
52506
+ sourceLogicalId: src.logicalId,
52507
+ synthLogicalId: synth.logicalId,
52508
+ physicalId: physicalIdByLogicalId.get(src.logicalId),
52509
+ resourceType: src.type
52510
+ });
52511
+ claimedSynthIds.add(synth.logicalId);
52512
+ } else {
52513
+ const isCollision = (synthByLastPathSegment.get(src.logicalId) ?? []).length >= 2;
52514
+ unmatched.push({
52515
+ sourceLogicalId: src.logicalId,
52516
+ resourceType: src.type,
52517
+ candidates: candidatesSameType.map((c) => c.logicalId).sort(),
52518
+ reason: isCollision ? "logical-id-collision" : "no-match"
52519
+ });
52520
+ }
52521
+ }
52522
+ const mapping = {};
52523
+ for (const p of pairs) mapping[p.sourceLogicalId] = p.synthLogicalId;
52524
+ return {
52525
+ mapping,
52526
+ pairs,
52527
+ unmatched
52528
+ };
52529
+ }
52530
+ /**
52531
+ * Walk a parsed CFn template's `Resources` and return one entry per
52532
+ * resource. `Type` defaults to `''` and `Properties` to `{}` when the
52533
+ * template omits them (CFn allows resources with only `DeletionPolicy`
52534
+ * + `Type`); the mapper's downstream deep-equal handles `{}` cleanly.
52535
+ *
52536
+ * Top-level keys other than `Resources` (Conditions / Parameters /
52537
+ * Rules / Outputs / Mappings / Metadata) are NOT walked — the mapping
52538
+ * is resource-to-resource only, and §5.5 of the design doc spells out
52539
+ * the four CDK-synth injections that live in those sibling keys.
52540
+ */
52541
+ function extractSourceResources(template) {
52542
+ if (!template || typeof template !== "object") return [];
52543
+ const resources = template["Resources"];
52544
+ if (!resources || typeof resources !== "object") return [];
52545
+ const out = [];
52546
+ for (const [logicalId, raw] of Object.entries(resources)) {
52547
+ if (!raw || typeof raw !== "object") continue;
52548
+ const r = raw;
52549
+ const type = typeof r["Type"] === "string" ? r["Type"] : "";
52550
+ const props = r["Properties"] && typeof r["Properties"] === "object" ? r["Properties"] : {};
52551
+ out.push({
52552
+ logicalId,
52553
+ type,
52554
+ properties: props
52555
+ });
52556
+ }
52557
+ return out;
52558
+ }
52559
+ /**
52560
+ * Walk the synth template's `Resources` and return one entry per
52561
+ * resource, EXCLUDING `AWS::CDK::Metadata` (synth-only sentinel that has
52562
+ * no source counterpart). Captures `Metadata['aws:cdk:path']` so Pass 1
52563
+ * can match against the last `/`-separated segment.
52564
+ *
52565
+ * The four other CDK-synth injections (CDKMetadataAvailable Condition,
52566
+ * BootstrapVersion Parameter, CheckBootstrapVersion Rule) live outside
52567
+ * `Resources` and are structurally excluded by this function.
52568
+ */
52569
+ function extractSynthResources(template) {
52570
+ if (!template || typeof template !== "object") return [];
52571
+ const resources = template["Resources"];
52572
+ if (!resources || typeof resources !== "object") return [];
52573
+ const out = [];
52574
+ for (const [logicalId, raw] of Object.entries(resources)) {
52575
+ if (!raw || typeof raw !== "object") continue;
52576
+ const r = raw;
52577
+ const type = typeof r["Type"] === "string" ? r["Type"] : "";
52578
+ if (type === "AWS::CDK::Metadata") continue;
52579
+ const props = r["Properties"] && typeof r["Properties"] === "object" ? r["Properties"] : {};
52580
+ const meta = r["Metadata"] && typeof r["Metadata"] === "object" ? r["Metadata"] : {};
52581
+ const path = typeof meta["aws:cdk:path"] === "string" ? meta["aws:cdk:path"] : "";
52582
+ out.push({
52583
+ logicalId,
52584
+ type,
52585
+ properties: props,
52586
+ awsCdkPath: path
52587
+ });
52588
+ }
52589
+ return out;
52590
+ }
52591
+ /**
52592
+ * Group synth entries by the LAST `/`-separated segment of their
52593
+ * `aws:cdk:path` metadata so Pass 1 can look up the source logical id
52594
+ * directly. Per [docs/design/465-cfn-migrate.md](../../../../docs/design/465-cfn-migrate.md) §5.5:
52595
+ * `cdk migrate` emits `<StackName>/<LogicalId>` as the metadata value,
52596
+ * so the last segment IS the source logical id in the typical case.
52597
+ *
52598
+ * Entries without an `aws:cdk:path` are silently skipped — they cannot
52599
+ * be addressed by source logical id and will only be reachable via
52600
+ * Pass 2's Properties match.
52601
+ */
52602
+ function indexSynthByLastPathSegment(entries) {
52603
+ const out = /* @__PURE__ */ new Map();
52604
+ for (const e of entries) {
52605
+ if (!e.awsCdkPath) continue;
52606
+ const lastSegment = e.awsCdkPath.split("/").pop() ?? "";
52607
+ if (!lastSegment) continue;
52608
+ const bucket = out.get(lastSegment);
52609
+ if (bucket) bucket.push(e);
52610
+ else out.set(lastSegment, [e]);
52611
+ }
52612
+ return out;
52613
+ }
52614
+ /**
52615
+ * Recursive deep equality between two values, with two CFn-specific
52616
+ * accommodations:
52617
+ *
52618
+ * 1. Object key order is NOT significant — `JSON.stringify` would
52619
+ * diverge on the canonical-source-vs-synth-alphabetized case
52620
+ * (verified by PR A's empirical run, §5.5 point 3) so we walk the
52621
+ * keys explicitly.
52622
+ * 2. `AWS::NoValue` placeholder values are stripped before compare —
52623
+ * `cdk migrate`'s codegen sometimes emits the placeholder for a
52624
+ * property the source template simply omitted, and the structural
52625
+ * intent matches. Source side never has the placeholder; synth side
52626
+ * can on either side of a nested object.
52627
+ *
52628
+ * Arrays are compared positionally (order IS significant — Tags arrays
52629
+ * and similar carry ordering semantics).
52630
+ */
52631
+ function deepEqualIgnoreNoValue(a, b) {
52632
+ if (a === b) return true;
52633
+ if (isAwsNoValue(a) !== isAwsNoValue(b)) return false;
52634
+ if (isAwsNoValue(a) && isAwsNoValue(b)) return true;
52635
+ if (typeof a !== typeof b) return false;
52636
+ if (a === null || b === null) return a === b;
52637
+ if (typeof a !== "object") return a === b;
52638
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
52639
+ if (Array.isArray(a) && Array.isArray(b)) {
52640
+ if (a.length !== b.length) return false;
52641
+ for (let i = 0; i < a.length; i++) if (!deepEqualIgnoreNoValue(a[i], b[i])) return false;
52642
+ return true;
52643
+ }
52644
+ const aObj = a;
52645
+ const bObj = b;
52646
+ const aKeys = Object.keys(aObj).filter((k) => !isAwsNoValue(aObj[k]) && aObj[k] !== void 0);
52647
+ const bKeys = Object.keys(bObj).filter((k) => !isAwsNoValue(bObj[k]) && bObj[k] !== void 0);
52648
+ if (aKeys.length !== bKeys.length) return false;
52649
+ const aKeySet = new Set(aKeys);
52650
+ for (const k of bKeys) {
52651
+ if (!aKeySet.has(k)) return false;
52652
+ if (!deepEqualIgnoreNoValue(aObj[k], bObj[k])) return false;
52653
+ }
52654
+ return true;
52655
+ }
52656
+ /**
52657
+ * True when the value is the CFn `AWS::NoValue` placeholder
52658
+ * (`{Ref: 'AWS::NoValue'}`). `cdk migrate`'s codegen emits this for
52659
+ * conditional properties the source template omitted; the structural
52660
+ * intent matches and deep-equal must treat them as absent.
52661
+ */
52662
+ function isAwsNoValue(v) {
52663
+ if (!v || typeof v !== "object") return false;
52664
+ const obj = v;
52665
+ const keys = Object.keys(obj);
52666
+ return keys.length === 1 && keys[0] === "Ref" && obj["Ref"] === "AWS::NoValue";
52667
+ }
52668
+
52669
+ //#endregion
52670
+ //#region src/cli/commands/migrate/resource-mapping-file.ts
52671
+ /** Conventional filename inside the migrate output dir. */
52672
+ const RESOURCE_MAPPING_FILENAME = "cdkd-resource-mapping.json";
52673
+ /**
52674
+ * Build the on-disk file shape from a {@link ResourceMappingResult} +
52675
+ * the source / output stack names, then write it to
52676
+ * `<outputDir>/cdkd-resource-mapping.json` (overwriting any prior file
52677
+ * on the same path — same idempotency contract as `cdk migrate`'s own
52678
+ * codegen, and the user expects a re-run to refresh stale data).
52679
+ *
52680
+ * Returns the absolute on-disk path so the orchestrator can name it in
52681
+ * the error message when the mapping is incomplete.
52682
+ */
52683
+ function writeMappingFile(outputDir, args) {
52684
+ const path = join(outputDir, RESOURCE_MAPPING_FILENAME);
52685
+ const file = {
52686
+ version: 1,
52687
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
52688
+ sourceStack: args.sourceStack,
52689
+ outputStack: args.outputStack,
52690
+ mapping: args.result.mapping
52691
+ };
52692
+ if (args.result.unmatched.length > 0) file._unmatched = args.result.unmatched;
52693
+ writeFileSync(path, JSON.stringify(file, null, 2) + "\n", "utf-8");
52694
+ return path;
52695
+ }
52696
+ /**
52697
+ * Read a user-supplied resource-mapping file from disk and return the
52698
+ * `{<srcLogicalId>: <synthLogicalId>}` overrides for the mapper.
52699
+ *
52700
+ * Tolerates `_unmatched` on input (a user that re-runs against a
52701
+ * partial-failure file without editing should hit the same failure on
52702
+ * the resolution side — not a parse error here). Hard-errors on
52703
+ * structurally invalid input (missing `mapping`, non-object root,
52704
+ * non-string values, wrong `version`).
52705
+ *
52706
+ * Surfaces every shape error as `LocalMigrateError` (exit code 2) so
52707
+ * the CLI handler routes it through the normal error path.
52708
+ */
52709
+ function readMappingFile(path) {
52710
+ if (!existsSync(path)) throw new LocalMigrateError(`Resource-mapping file not found: ${path}. Drop the --resource-mapping flag or supply a valid path.`);
52711
+ let raw;
52712
+ try {
52713
+ raw = JSON.parse(readFileSync(path, "utf-8"));
52714
+ } catch (err) {
52715
+ throw new LocalMigrateError(`Resource-mapping file '${path}' is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
52716
+ }
52717
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new LocalMigrateError(`Resource-mapping file '${path}' must contain a JSON object at the top level.`);
52718
+ const obj = raw;
52719
+ if (obj["version"] !== 1) throw new LocalMigrateError(`Resource-mapping file '${path}' has unsupported version '${String(obj["version"])}'. Expected version 1.`);
52720
+ if (typeof obj["sourceStack"] !== "string" || obj["sourceStack"].length === 0) throw new LocalMigrateError(`Resource-mapping file '${path}' is missing the required 'sourceStack' field.`);
52721
+ if (typeof obj["outputStack"] !== "string" || obj["outputStack"].length === 0) throw new LocalMigrateError(`Resource-mapping file '${path}' is missing the required 'outputStack' field.`);
52722
+ if (typeof obj["generatedAt"] !== "string") throw new LocalMigrateError(`Resource-mapping file '${path}' is missing the required 'generatedAt' field.`);
52723
+ const mappingRaw = obj["mapping"];
52724
+ if (!mappingRaw || typeof mappingRaw !== "object" || Array.isArray(mappingRaw)) throw new LocalMigrateError(`Resource-mapping file '${path}' is missing the required 'mapping' object.`);
52725
+ const mapping = {};
52726
+ for (const [k, v] of Object.entries(mappingRaw)) {
52727
+ if (typeof v !== "string") throw new LocalMigrateError(`Resource-mapping file '${path}' has a non-string value for key '${k}'.`);
52728
+ mapping[k] = v;
52729
+ }
52730
+ const result = {
52731
+ version: 1,
52732
+ generatedAt: obj["generatedAt"],
52733
+ sourceStack: obj["sourceStack"],
52734
+ outputStack: obj["outputStack"],
52735
+ mapping
52736
+ };
52737
+ if (Array.isArray(obj["_unmatched"])) {
52738
+ const cleaned = [];
52739
+ for (const entry of obj["_unmatched"]) {
52740
+ if (!entry || typeof entry !== "object") continue;
52741
+ const e = entry;
52742
+ if (typeof e["sourceLogicalId"] !== "string" || typeof e["resourceType"] !== "string" || !Array.isArray(e["candidates"]) || e["reason"] !== "no-match" && e["reason"] !== "logical-id-collision") continue;
52743
+ cleaned.push({
52744
+ sourceLogicalId: e["sourceLogicalId"],
52745
+ resourceType: e["resourceType"],
52746
+ candidates: e["candidates"].filter((c) => typeof c === "string"),
52747
+ reason: e["reason"]
52748
+ });
52749
+ }
52750
+ if (cleaned.length > 0) result._unmatched = cleaned;
52751
+ }
52752
+ return result;
52753
+ }
52754
+
52755
+ //#endregion
52756
+ //#region src/cli/commands/migrate-command.ts
52757
+ /**
52758
+ * `cdkd migrate --from-cfn-stack <name>` end-to-end orchestrator.
52759
+ *
52760
+ * Closes the CLI half of [#465](https://github.com/go-to-k/cdkd/issues/465).
52761
+ * Reads the source CFn stack, runs upstream `cdk migrate` via the PR A
52762
+ * library, builds the source → synth logical-ID mapping with the 2-pass
52763
+ * algorithm, writes a `cdkd-resource-mapping.json` audit file, prompts
52764
+ * for confirmation, invokes `cdkd import` to write state under the
52765
+ * synth logical IDs, and (when `--retire-cfn-stack` is set) finally
52766
+ * retires the source CFn stack so management responsibility transfers
52767
+ * fully to cdkd.
52768
+ *
52769
+ * AWS resources are never modified — the migration is a metadata
52770
+ * transfer only. See [docs/design/465-cfn-migrate.md](../../../docs/design/465-cfn-migrate.md) §7
52771
+ * for the post-migration state matrix.
52772
+ */
52773
+ async function migrateCommandAction(positionalStack, options) {
52774
+ const logger = getLogger();
52775
+ if (options.verbose) {
52776
+ logger.setLevel("debug");
52777
+ process.env["CDKD_NO_LIVE"] = "1";
52778
+ }
52779
+ const sourceCfnStackName = options.fromCfnStack ?? positionalStack;
52780
+ if (!sourceCfnStackName) throw new LocalMigrateError("Missing required argument: --from-cfn-stack <name> (or pass the stack name positionally).");
52781
+ if (options.retireCfnStack && options.skipSynth) throw new LocalMigrateError("--retire-cfn-stack is incompatible with --skip-synth: the post-state-write retirement (UpdateStack + DeleteStack) cannot run without a synthesized template + cdkd state.");
52782
+ if (options.retireCfnStack && options.dryRun) throw new LocalMigrateError("--retire-cfn-stack is incompatible with --dry-run: retirement issues real AWS calls (UpdateStack injects Retain policies, then DeleteStack).");
52783
+ if (options.retireCfnStack && options.filter && options.filter.length > 0) throw new LocalMigrateError("--retire-cfn-stack is incompatible with --filter: a partial migration that retires the whole source CFn stack would strand the un-migrated resources. Drop one of the flags or migrate the rest of the stack first.");
52784
+ await applyRoleArnIfSet({
52785
+ roleArn: options.roleArn,
52786
+ region: options.region
52787
+ });
52788
+ const region = options.region || process.env["AWS_REGION"] || "us-east-1";
52789
+ const outputDir = resolve(options.outputDir ?? sourceCfnStackName);
52790
+ logger.info(`[migrate] Source CFn stack: ${sourceCfnStackName}`);
52791
+ logger.info(`[migrate] Output directory: ${outputDir}`);
52792
+ const libResult = await runMigrateLibrary({
52793
+ fromCfnStack: sourceCfnStackName,
52794
+ outputDir,
52795
+ language: options.language ?? "typescript",
52796
+ ...options.region && { region: options.region },
52797
+ ...options.account && { account: options.account },
52798
+ ...options.filter && options.filter.length > 0 && { filters: options.filter },
52799
+ ...options.profile && { profile: options.profile },
52800
+ ...options.cdkBin && { cdkBinPath: options.cdkBin },
52801
+ skipInstall: options.skipInstall ?? false,
52802
+ skipSynth: options.skipSynth ?? false
52803
+ });
52804
+ if (options.skipSynth || !libResult.templateBody) {
52805
+ logger.info(`[migrate] --skip-synth: generated CDK app at ${libResult.outputDir}. Re-run without --skip-synth to write cdkd state.`);
52806
+ return;
52807
+ }
52808
+ let overrides;
52809
+ if (options.resourceMapping) {
52810
+ const mappingPath = resolve(options.resourceMapping);
52811
+ logger.info(`[migrate] Loading user-supplied resource mapping from ${mappingPath}`);
52812
+ overrides = readMappingFile(mappingPath).mapping;
52813
+ }
52814
+ let mappingResult;
52815
+ try {
52816
+ mappingResult = buildResourceMapping({
52817
+ sourceCfnTemplate: libResult.sourceCfnTemplate,
52818
+ synthTemplate: libResult.templateBody,
52819
+ sourceResources: libResult.sourceResources,
52820
+ ...overrides && { overrides }
52821
+ });
52822
+ } catch (err) {
52823
+ throw new LocalMigrateError(err instanceof Error ? err.message : String(err));
52824
+ }
52825
+ const outputStack = sourceCfnStackName;
52826
+ const mappingFilePath = writeMappingFile(libResult.outputDir, {
52827
+ sourceStack: sourceCfnStackName,
52828
+ outputStack,
52829
+ result: mappingResult
52830
+ });
52831
+ logger.info(`[migrate] Resource mapping written to ${mappingFilePath}`);
52832
+ if (mappingResult.unmatched.length > 0) {
52833
+ const lines = mappingResult.unmatched.map((u) => {
52834
+ const candidatesStr = u.candidates.length > 0 ? ` (synth candidates of same Type: ${u.candidates.join(", ")})` : " (no synth resource has the same Type)";
52835
+ return ` - ${u.sourceLogicalId} (${u.resourceType}) [${u.reason}]${candidatesStr}`;
52836
+ });
52837
+ throw new LocalMigrateError(`Could not auto-map ${mappingResult.unmatched.length} of ${mappingResult.unmatched.length + mappingResult.pairs.length} source resource(s) to the synthesized CDK template:\n${lines.join("\n")}\n\nEdit '${mappingFilePath}'\nto add the correct '<sourceLogicalId>: <synthLogicalId>' pairs under "mapping", then re-run:\n cdkd migrate --from-cfn-stack ${sourceCfnStackName} --resource-mapping ${mappingFilePath} --output-dir ${libResult.outputDir}\n(The --output-dir override re-uses the already-generated CDK code; drop it if you let the default location be used.)`);
52838
+ }
52839
+ printMappingTable(mappingResult, sourceCfnStackName, outputStack);
52840
+ if (options.dryRun) {
52841
+ logger.info(`[migrate] --dry-run: would import ${mappingResult.pairs.length} resource(s) into cdkd state under stack '${outputStack}' (region '${region}'). State NOT written.`);
52842
+ return;
52843
+ }
52844
+ if (!options.yes) {
52845
+ if (!await promptConfirm(`Import ${mappingResult.pairs.length} resource(s) into cdkd state for stack '${outputStack}' (${region})?`)) {
52846
+ logger.info("[migrate] Cancelled by user. No state written.");
52847
+ return;
52848
+ }
52849
+ }
52850
+ const importMapping = {};
52851
+ for (const p of mappingResult.pairs) importMapping[p.synthLogicalId] = p.physicalId;
52852
+ const resolvedStateBucket = await resolveStateBucketWithDefault(options.stateBucket, region);
52853
+ await runImport(outputStack, {
52854
+ app: libResult.assemblyDir,
52855
+ statePrefix: options.statePrefix ?? "cdkd",
52856
+ resourceMappingInline: JSON.stringify(importMapping),
52857
+ auto: false,
52858
+ dryRun: false,
52859
+ yes: true,
52860
+ force: false,
52861
+ verbose: options.verbose ?? false,
52862
+ stateBucket: resolvedStateBucket,
52863
+ ...options.region && { region: options.region },
52864
+ ...options.profile && { profile: options.profile },
52865
+ ...options.roleArn && { roleArn: options.roleArn }
52866
+ });
52867
+ if (options.retireCfnStack) {
52868
+ logger.info(`[migrate] Retiring source CloudFormation stack '${sourceCfnStackName}'...`);
52869
+ const cfnConfig = {};
52870
+ if (options.region) cfnConfig.region = options.region;
52871
+ if (options.profile) cfnConfig.profile = options.profile;
52872
+ const awsClients = new AwsClients(cfnConfig);
52873
+ const cfnClient = awsClients.cloudFormation;
52874
+ try {
52875
+ await retireCloudFormationStack({
52876
+ cfnStackName: sourceCfnStackName,
52877
+ cfnClient,
52878
+ yes: options.yes ?? false,
52879
+ stateBucket: resolvedStateBucket,
52880
+ ...options.profile && { s3ClientOpts: { profile: options.profile } }
52881
+ });
52882
+ } finally {
52883
+ awsClients.destroy();
52884
+ }
52885
+ }
52886
+ logger.info(`[migrate] Migrated ${mappingResult.pairs.length} resource(s) from CloudFormation stack '${sourceCfnStackName}' to cdkd state for stack '${outputStack}'.`);
52887
+ logger.info(`[migrate] Generated CDK app at ${libResult.outputDir}`);
52888
+ if (!options.retireCfnStack) logger.info(`[migrate] Source CloudFormation stack '${sourceCfnStackName}' is unchanged. Re-run with --retire-cfn-stack to retire it (or do so manually later).`);
52889
+ }
52890
+ /**
52891
+ * Print a tabular `(sourceLogicalId → synthLogicalId physicalId)`
52892
+ * summary so users can see what's about to be imported before the
52893
+ * confirmation prompt. Matches the visual shape of `cdkd import`'s
52894
+ * summary table for consistency.
52895
+ */
52896
+ function printMappingTable(result, sourceStack, outputStack) {
52897
+ const logger = getLogger();
52898
+ logger.info("");
52899
+ logger.info(`[migrate] Resolved mapping (${sourceStack} → ${outputStack}):`);
52900
+ for (const p of result.pairs) logger.info(` ${p.sourceLogicalId} → ${p.synthLogicalId} [${p.resourceType}] ${p.physicalId}`);
52901
+ logger.info("");
52902
+ }
52903
+ /**
52904
+ * Minimal `(y/N)` confirmation prompt using `node:readline`. Mirrors
52905
+ * the shape of [src/cli/commands/import.ts](import.ts)'s `confirmPrompt`
52906
+ * so the user UX is consistent across the import + migrate surfaces.
52907
+ * Non-TTY callers (CI) should pass `--yes` to skip this entirely.
52908
+ */
52909
+ async function promptConfirm(message) {
52910
+ if (!process.stdin.isTTY) throw new LocalMigrateError(`Non-interactive shell detected and --yes was not supplied. Re-run with --yes to confirm: "${message}"`);
52911
+ const rl = (await import("node:readline/promises")).createInterface({
52912
+ input: process.stdin,
52913
+ output: process.stdout
52914
+ });
52915
+ try {
52916
+ const v = (await rl.question(`${message} (y/N) `)).trim().toLowerCase();
52917
+ return v === "y" || v === "yes";
52918
+ } finally {
52919
+ rl.close();
52920
+ }
52921
+ }
52922
+ /**
52923
+ * Commander factory for `cdkd migrate`. Registered in `src/cli/index.ts`
52924
+ * alongside the existing top-level commands. Matches the design doc §3
52925
+ * flag set; the action handler routes through {@link migrateCommandAction}
52926
+ * via `withErrorHandling` so library callers see exceptions but CLI
52927
+ * callers get the standard exit-code-2 routing.
52928
+ */
52929
+ function createMigrateCommand() {
52930
+ return new Command("migrate").description("Adopt a plain (non-CDK) CloudFormation stack into a cdkd-managed CDK app. Generates new CDK code via upstream `cdk migrate`, builds a logical-ID mapping between the source CFn template and the synth template, writes cdkd state, and (optionally) retires the source CFn stack. AWS resources are never modified.").argument("[stack]", "Source CFn stack name. Alias for --from-cfn-stack.").addOption(new Option("--from-cfn-stack <name>", "Source CloudFormation stack name to adopt. Required (or pass positionally).")).addOption(new Option("--output-dir <dir>", "Directory to write the generated CDK app to. Defaults to <cwd>/<CfnStackName>.")).addOption(new Option("--language <choice>", "Generated code language. v1: typescript only.").choices(["typescript"]).default("typescript")).addOption(new Option("--region <region>", "AWS region. Defaults to AWS_REGION env / profile.")).addOption(new Option("--account <id>", "AWS account ID. Auto-detected via STS when omitted.")).addOption(new Option("--retire-cfn-stack", "After cdkd state is written, inject DeletionPolicy=Retain on every resource in the source CFn stack and DeleteStack. AWS resources stay; the CFn stack record is gone. Off by default.").default(false)).addOption(new Option("--filter <key=value>", "Pass-through to `cdk migrate --filter` for resource subsetting. Repeatable.").argParser((value, previous) => [...previous ?? [], value]).default([])).addOption(new Option("--skip-install", "Skip `npm install` after codegen.").default(false)).addOption(new Option("--skip-synth", "Skip `cdk synth` (does NOT write cdkd state). Mutually exclusive with --retire-cfn-stack.").default(false)).addOption(new Option("--dry-run", "Print the import plan without writing state or retiring the CFn stack. Mutually exclusive with --retire-cfn-stack.").default(false)).addOption(new Option("-y, --yes", "Auto-confirm the import + retirement prompts.").default(false)).addOption(new Option("--cdk-bin <path>", "Override the `cdk` binary path.")).addOption(new Option("--resource-mapping <file>", `Path to a JSON file of {sourceLogicalId: synthLogicalId} overrides. Same shape as the auto-written ${RESOURCE_MAPPING_FILENAME}.`)).addOption(new Option("--state-bucket <name>", "cdkd state bucket. Defaults to cdkd-state-<accountId>.")).addOption(new Option("--state-prefix <prefix>", "cdkd state prefix inside the bucket.").default("cdkd")).addOption(new Option("--profile <name>", "AWS profile name.")).addOption(new Option("--role-arn <arn>", "IAM role to assume before any AWS call.")).addOption(new Option("--verbose", "Enable debug-level logging.").default(false)).action(withErrorHandling(migrateCommandAction));
52931
+ }
52932
+
51862
52933
  //#endregion
51863
52934
  //#region src/cli/index.ts
51864
52935
  const SUBCOMMANDS = new Set([
@@ -51876,7 +52947,8 @@ const SUBCOMMANDS = new Set([
51876
52947
  "publish-assets",
51877
52948
  "force-unlock",
51878
52949
  "state",
51879
- "local"
52950
+ "local",
52951
+ "migrate"
51880
52952
  ]);
51881
52953
  /**
51882
52954
  * Reorder args so options before the subcommand are moved after it.
@@ -51900,7 +52972,7 @@ function reorderArgs(argv) {
51900
52972
  */
51901
52973
  async function main() {
51902
52974
  const program = new Command();
51903
- program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.134.0");
52975
+ program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.135.0");
51904
52976
  program.addCommand(createBootstrapCommand());
51905
52977
  program.addCommand(createSynthCommand());
51906
52978
  program.addCommand(createListCommand());
@@ -51915,6 +52987,7 @@ async function main() {
51915
52987
  program.addCommand(createStateCommand());
51916
52988
  program.addCommand(createLocalCommand());
51917
52989
  program.addCommand(createExportCommand());
52990
+ program.addCommand(createMigrateCommand());
51918
52991
  const args = reorderArgs(process.argv);
51919
52992
  await program.parseAsync(args);
51920
52993
  }