@go-to-k/cdkd 0.123.0 → 0.124.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/README.md CHANGED
@@ -552,11 +552,14 @@ adopt nor recreate them).
552
552
  cdkd export MyStack # confirmation prompt; CFn stack name = cdkd stack name
553
553
  cdkd export MyStack --cfn-stack-name MyStack-CFn
554
554
  cdkd export MyStack --dry-run # print the import plan, do not call CFn
555
- cdkd export MyStack --template path.json # skip synth, use a pre-rendered JSON template
555
+ cdkd export MyStack --template path.json # skip synth, use a pre-rendered template (JSON or YAML — format auto-detected)
556
556
  cdkd export MyStack --include-non-importable # 2-phase: IMPORT importable + CFn-CREATE Custom Resources
557
557
  ```
558
558
 
559
- MVP scope: JSON templates only (CDK-generated).
559
+ Accepts JSON and YAML templates. YAML round-trips through a CFn-aware codec
560
+ (`src/cli/yaml-cfn.ts`) that preserves every shorthand intrinsic (`!Ref` /
561
+ `!GetAtt` / `!Sub` / `!Join` / etc.), so a YAML-authored CFn stack stays YAML
562
+ on the phase-1 IMPORT and phase-2 UPDATE changesets.
560
563
 
561
564
  ## Drift detection
562
565
 
package/dist/cli.js CHANGED
@@ -56,6 +56,7 @@ import { CreateVectorBucketCommand, DeleteIndexCommand, DeleteVectorBucketComman
56
56
  import { CreateNamespaceCommand, CreateTableBucketCommand, CreateTableCommand as CreateTableCommand$2, DeleteNamespaceCommand as DeleteNamespaceCommand$1, DeleteTableBucketCommand, DeleteTableCommand as DeleteTableCommand$2, GetTableBucketCommand, GetTableCommand as GetTableCommand$1, ListNamespacesCommand as ListNamespacesCommand$1, ListTableBucketsCommand, ListTablesCommand as ListTablesCommand$1, ListTagsForResourceCommand as ListTagsForResourceCommand$19, NotFoundException as NotFoundException$5, S3TablesClient } from "@aws-sdk/client-s3tables";
57
57
  import { AttachTrafficSourcesCommand, AutoScalingClient, CreateAutoScalingGroupCommand, DeleteAutoScalingGroupCommand, DeleteLifecycleHookCommand, DeleteNotificationConfigurationCommand, DescribeAutoScalingGroupsCommand, DescribeLifecycleHooksCommand, DescribeNotificationConfigurationsCommand, DescribeTrafficSourcesCommand, DetachTrafficSourcesCommand, DisableMetricsCollectionCommand, EnableMetricsCollectionCommand, PutLifecycleHookCommand, PutNotificationConfigurationCommand, UpdateAutoScalingGroupCommand } from "@aws-sdk/client-auto-scaling";
58
58
  import * as readline from "node:readline/promises";
59
+ import { Document, Pair, Scalar, YAMLMap, YAMLSeq, parse as parse$1, stringify } from "yaml";
59
60
  import { createServer } from "node:net";
60
61
  import { promisify } from "node:util";
61
62
  import { setTimeout as setTimeout$1 } from "node:timers/promises";
@@ -34744,6 +34745,307 @@ function createStateCommand() {
34744
34745
  return cmd;
34745
34746
  }
34746
34747
 
34748
+ //#endregion
34749
+ //#region src/cli/yaml-cfn.ts
34750
+ /**
34751
+ * CloudFormation-aware YAML codec.
34752
+ *
34753
+ * Parses + serializes CFn templates while preserving every CFn shorthand
34754
+ * intrinsic tag (`!Ref`, `!GetAtt`, `!Sub`, `!Join`, ...) — generic YAML
34755
+ * libraries silently strip these tags on parse + re-emit, corrupting the
34756
+ * template. cdkd uses this codec on both directions of CFn migration:
34757
+ * `cdkd export` (cdkd → CFn IMPORT) and
34758
+ * `cdkd import --migrate-from-cloudformation` (CFn → cdkd state).
34759
+ *
34760
+ * Algorithm: each `!Foo` tag is registered as a YAML custom tag whose
34761
+ * resolver returns the long-form object shape `{Fn::Foo: <args>}` (or
34762
+ * `{Ref: <name>}` for the bare `!Ref`). On stringify, the same objects
34763
+ * are detected and emitted back to shorthand tag form. The internal
34764
+ * model cdkd carries between parse and stringify is the long-form object
34765
+ * shape — same shape JSON produces — so every downstream consumer
34766
+ * (`injectRetainPolicies`, `executeImportChangeSet`, etc.) reads one
34767
+ * representation.
34768
+ *
34769
+ * Each tag is registered three times (scalar / sequence / map) per the
34770
+ * `yaml` library's API contract — a single registration only matches one
34771
+ * collection shape. Nested intrinsics resolve recursively via
34772
+ * `node.toJSON()` (the yaml lib resolves child customTags before invoking
34773
+ * the parent resolver).
34774
+ *
34775
+ * Format detection sniffs the first non-whitespace byte: `{` or `[` →
34776
+ * JSON; anything else → YAML. Empty input rejected as JSON so the
34777
+ * caller's existing JSON-empty-input error path fires.
34778
+ *
34779
+ * Tag set supported (matches the AWS docs canonical list):
34780
+ * `!Ref`, `!GetAtt`, `!Sub`, `!Join`, `!Select`, `!Split`,
34781
+ * `!If`, `!Equals`, `!And`, `!Or`, `!Not`,
34782
+ * `!FindInMap`, `!Base64`, `!Cidr`, `!GetAZs`, `!ImportValue`,
34783
+ * `!Transform`, `!Condition` (used inside `Conditions:` references).
34784
+ *
34785
+ * `!GetAtt` accepts BOTH the scalar dot-delimited shape
34786
+ * (`!GetAtt LogicalId.Attribute.Path`) and the sequence shape
34787
+ * (`!GetAtt [LogicalId, Attribute]`); both round-trip back to the scalar
34788
+ * form when the attribute is a single string segment (the AWS-published
34789
+ * canonical shape), or to the sequence form when the attribute is itself
34790
+ * a sequence.
34791
+ */
34792
+ /**
34793
+ * Tags whose long-form key uses the `Fn::` prefix. `!Ref`, `!Condition`,
34794
+ * and `!Transform` are special-cased separately because their long-form
34795
+ * key shape differs.
34796
+ */
34797
+ const FN_TAGS = [
34798
+ "GetAtt",
34799
+ "Sub",
34800
+ "Join",
34801
+ "Select",
34802
+ "Split",
34803
+ "If",
34804
+ "Equals",
34805
+ "And",
34806
+ "Or",
34807
+ "Not",
34808
+ "FindInMap",
34809
+ "Base64",
34810
+ "Cidr",
34811
+ "GetAZs",
34812
+ "ImportValue",
34813
+ "Length",
34814
+ "ToJsonString",
34815
+ "ForEach"
34816
+ ];
34817
+ function nodeJs(node) {
34818
+ if (node === null || node === void 0) return null;
34819
+ if (typeof node === "object" && node !== null && "toJSON" in node) return node.toJSON();
34820
+ return node;
34821
+ }
34822
+ /**
34823
+ * Build the set of custom YAML tags cdkd registers. Each tag may need
34824
+ * up to 3 entries (scalar / seq / map) so every shape an AWS-published
34825
+ * template carries is accepted; un-templated shapes for a given tag
34826
+ * fall through to the lib's default tag resolution (which the lib
34827
+ * surfaces as a warning — never an error — by design).
34828
+ *
34829
+ * `identify: () => false` on every entry: we never want the lib to
34830
+ * auto-tag a parsed result on stringify. We build the YAML output via
34831
+ * our own `jsToYamlNode` walk that emits the tag explicitly.
34832
+ */
34833
+ function buildCustomTags() {
34834
+ const tags = [];
34835
+ tags.push({
34836
+ tag: "!Ref",
34837
+ resolve(value) {
34838
+ return { Ref: value };
34839
+ },
34840
+ identify: () => false
34841
+ });
34842
+ tags.push({
34843
+ tag: "!Condition",
34844
+ resolve(value) {
34845
+ return { Condition: value };
34846
+ },
34847
+ identify: () => false
34848
+ });
34849
+ tags.push({
34850
+ tag: "!Transform",
34851
+ collection: "map",
34852
+ resolve(node) {
34853
+ return { "Fn::Transform": nodeJs(node) };
34854
+ },
34855
+ identify: () => false
34856
+ });
34857
+ tags.push({
34858
+ tag: "!GetAtt",
34859
+ resolve(value) {
34860
+ const dot = value.indexOf(".");
34861
+ if (dot < 0) throw new Error(`!GetAtt requires '<LogicalId>.<Attribute>'; got '${value}'`);
34862
+ return { "Fn::GetAtt": [value.slice(0, dot), value.slice(dot + 1)] };
34863
+ },
34864
+ identify: () => false
34865
+ });
34866
+ tags.push({
34867
+ tag: "!GetAtt",
34868
+ collection: "seq",
34869
+ resolve(node) {
34870
+ return { "Fn::GetAtt": nodeJs(node) };
34871
+ },
34872
+ identify: () => false
34873
+ });
34874
+ for (const name of FN_TAGS) {
34875
+ if (name === "GetAtt") continue;
34876
+ const longKey = `Fn::${name}`;
34877
+ tags.push({
34878
+ tag: `!${name}`,
34879
+ resolve(value) {
34880
+ return { [longKey]: value };
34881
+ },
34882
+ identify: () => false
34883
+ });
34884
+ tags.push({
34885
+ tag: `!${name}`,
34886
+ collection: "seq",
34887
+ resolve(node) {
34888
+ return { [longKey]: nodeJs(node) };
34889
+ },
34890
+ identify: () => false
34891
+ });
34892
+ tags.push({
34893
+ tag: `!${name}`,
34894
+ collection: "map",
34895
+ resolve(node) {
34896
+ return { [longKey]: nodeJs(node) };
34897
+ },
34898
+ identify: () => false
34899
+ });
34900
+ }
34901
+ return tags;
34902
+ }
34903
+ const CUSTOM_TAGS = buildCustomTags();
34904
+ /**
34905
+ * Parse a CFn template string. JSON or YAML, auto-detected.
34906
+ *
34907
+ * @throws Error with a clear message when the input is empty, malformed,
34908
+ * or does not produce an object root.
34909
+ */
34910
+ function parseCfnTemplate(text) {
34911
+ const format = detectTemplateFormat(text);
34912
+ const body = text.charCodeAt(0) === 65279 ? text.slice(1) : text;
34913
+ let parsed;
34914
+ if (format === "json") try {
34915
+ parsed = JSON.parse(body);
34916
+ } catch (err) {
34917
+ throw new Error(`Template is not valid JSON. Cause: ${err instanceof Error ? err.message : String(err)}`);
34918
+ }
34919
+ else try {
34920
+ parsed = parse$1(body, { customTags: CUSTOM_TAGS });
34921
+ } catch (err) {
34922
+ throw new Error(`Template is not valid YAML. Cause: ${err instanceof Error ? err.message : String(err)}`);
34923
+ }
34924
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error(`Template root is not an object.`);
34925
+ return parsed;
34926
+ }
34927
+ /**
34928
+ * Serialize a CFn template back to JSON or YAML. The `format` parameter
34929
+ * controls the output shape; callers should pass the result of the
34930
+ * original `detectTemplateFormat` (or remembered from `parseCfnTemplate`)
34931
+ * so a YAML-authored template stays YAML on round-trip.
34932
+ *
34933
+ * YAML output emits CFn intrinsics back to shorthand tags (`!Ref`,
34934
+ * `!GetAtt`, etc.) by walking the JS object tree before passing it to
34935
+ * `yaml.stringify`. Long-form `{Fn::Foo: <args>}` objects are converted
34936
+ * to `Document` nodes carrying the appropriate `!Foo` tag.
34937
+ *
34938
+ * JSON output is two-space-indented to match cdkd's existing canonical
34939
+ * shape.
34940
+ */
34941
+ function stringifyCfnTemplate(template, format) {
34942
+ if (format === "json") return JSON.stringify(template, null, 2);
34943
+ const doc = new Document();
34944
+ doc.contents = jsToYamlNode(template, doc);
34945
+ return stringify(doc, {
34946
+ aliasDuplicateObjects: false,
34947
+ lineWidth: 0
34948
+ });
34949
+ }
34950
+ /**
34951
+ * Convert a plain JS value into a YAML node, detecting CFn intrinsics and
34952
+ * emitting them with their shorthand tag. Recursive — every sub-tree is
34953
+ * inspected.
34954
+ */
34955
+ function jsToYamlNode(value, doc) {
34956
+ if (value === null || value === void 0) return new Scalar(null);
34957
+ if (typeof value !== "object") return doc.createNode(value);
34958
+ if (Array.isArray(value)) {
34959
+ const seq = new YAMLSeq();
34960
+ for (const item of value) seq.items.push(jsToYamlNode(item, doc));
34961
+ return seq;
34962
+ }
34963
+ const keys = Object.keys(value);
34964
+ if (keys.length === 1) {
34965
+ const k = keys[0];
34966
+ const v = value[k];
34967
+ const tag = intrinsicShorthandFor(k);
34968
+ if (tag) return makeIntrinsicNode(tag, v, doc);
34969
+ }
34970
+ const map = new YAMLMap();
34971
+ for (const [k, v] of Object.entries(value)) map.items.push(new Pair(doc.createNode(k), jsToYamlNode(v, doc)));
34972
+ return map;
34973
+ }
34974
+ /**
34975
+ * Map a long-form intrinsic key (`Ref` / `Fn::Foo` / `Condition`) to its
34976
+ * shorthand tag (`!Ref` / `!Foo` / `!Condition`). Returns null when the
34977
+ * key is not a recognized intrinsic.
34978
+ */
34979
+ function intrinsicShorthandFor(key) {
34980
+ if (key === "Ref") return "!Ref";
34981
+ if (key === "Condition") return "!Condition";
34982
+ if (!key.startsWith("Fn::")) return null;
34983
+ const name = key.slice(4);
34984
+ if (name === "Transform") return "!Transform";
34985
+ if (name === "GetAtt") return "!GetAtt";
34986
+ if (FN_TAGS.includes(name)) return `!${name}`;
34987
+ return null;
34988
+ }
34989
+ /**
34990
+ * Build a YAML node carrying a CFn shorthand tag. Tag-specific shapes:
34991
+ * - `!Ref` / `!Condition` → scalar (the referenced name).
34992
+ * - `!GetAtt` → scalar `LogicalId.Attribute` when the long-form arg
34993
+ * is `[LogicalId, Attribute]` of strings; sequence form otherwise.
34994
+ * - Everything else → whatever shape the long-form arg has
34995
+ * (scalar / sequence / map).
34996
+ */
34997
+ function makeIntrinsicNode(tag, value, doc) {
34998
+ if (tag === "!Ref" || tag === "!Condition") {
34999
+ const s = new Scalar(typeof value === "string" ? value : String(value));
35000
+ s.tag = tag;
35001
+ return s;
35002
+ }
35003
+ if (tag === "!GetAtt") {
35004
+ if (Array.isArray(value) && value.length === 2 && typeof value[0] === "string" && typeof value[1] === "string") {
35005
+ const s = new Scalar(`${value[0]}.${value[1]}`);
35006
+ s.tag = tag;
35007
+ return s;
35008
+ }
35009
+ const node = jsToYamlNode(value, doc);
35010
+ node.tag = tag;
35011
+ return node;
35012
+ }
35013
+ const node = jsToYamlNode(value, doc);
35014
+ node.tag = tag;
35015
+ return node;
35016
+ }
35017
+ /**
35018
+ * Sniff the input text and decide whether to parse it as JSON or YAML.
35019
+ *
35020
+ * Rule (matches AWS docs / CDK CLI): if the first non-whitespace byte is
35021
+ * `{` or `[`, parse as JSON; otherwise YAML. Empty input is treated as
35022
+ * JSON so the caller's existing JSON-empty-input error path fires.
35023
+ *
35024
+ * UTF-8 BOM (``) is stripped before the sniff — some editors /
35025
+ * scripts emit BOM-prefixed files, and the BOM is NOT whitespace under
35026
+ * `trimStart()`, so a BOM-prefixed JSON file would otherwise route to
35027
+ * the YAML parser and fail.
35028
+ */
35029
+ function detectTemplateFormat(text) {
35030
+ const trimmed = (text.charCodeAt(0) === 65279 ? text.slice(1) : text).trimStart();
35031
+ if (trimmed.length === 0) return "json";
35032
+ const first = trimmed.charCodeAt(0);
35033
+ if (first === 123 || first === 91) return "json";
35034
+ return "yaml";
35035
+ }
35036
+ /**
35037
+ * Parse a CFn template + return both the parsed object AND the source
35038
+ * format, so the caller can later re-emit in the same shape. Convenience
35039
+ * wrapper around `parseCfnTemplate` + `detectTemplateFormat`.
35040
+ */
35041
+ function parseCfnTemplateWithFormat(text) {
35042
+ const format = detectTemplateFormat(text);
35043
+ return {
35044
+ template: parseCfnTemplate(text),
35045
+ format
35046
+ };
35047
+ }
35048
+
34747
35049
  //#endregion
34748
35050
  //#region src/cli/commands/retire-cfn-stack.ts
34749
35051
  /**
@@ -34823,7 +35125,7 @@ async function retireCloudFormationStack(options) {
34823
35125
  TemplateStage: "Original"
34824
35126
  }));
34825
35127
  if (!tpl.TemplateBody) throw new Error(`GetTemplate returned no body for '${cfnStackName}'.`);
34826
- const { body: newBody, modified } = injectRetainPolicies(tpl.TemplateBody, cfnStackName);
35128
+ const { body: newBody, modified, format } = injectRetainPolicies(tpl.TemplateBody, cfnStackName);
34827
35129
  if (!yes) {
34828
35130
  if (!await confirmPrompt$2(`Set DeletionPolicy=Retain and UpdateReplacePolicy=Retain on every resource in CloudFormation stack '${cfnStackName}', then delete the stack? AWS resources will NOT be deleted (cdkd state has been written).`)) {
34829
35131
  logger.info("CloudFormation stack retirement cancelled. cdkd state is unaffected.");
@@ -34843,6 +35145,7 @@ async function retireCloudFormationStack(options) {
34843
35145
  bucket: stateBucket,
34844
35146
  body: newBody,
34845
35147
  cfnStackName,
35148
+ format,
34846
35149
  ...s3ClientOpts && { s3ClientOpts }
34847
35150
  });
34848
35151
  updateInput = { TemplateURL: uploaded.url };
@@ -34904,7 +35207,7 @@ async function retireCloudFormationStack(options) {
34904
35207
  * Exported for unit testing.
34905
35208
  */
34906
35209
  async function uploadTemplateForUpdateStack(args) {
34907
- const { bucket, body, cfnStackName, s3ClientOpts } = args;
35210
+ const { bucket, body, cfnStackName, format, s3ClientOpts } = args;
34908
35211
  const region = await resolveBucketRegion(bucket, {
34909
35212
  ...s3ClientOpts?.profile && { profile: s3ClientOpts.profile },
34910
35213
  ...s3ClientOpts?.credentials && { credentials: s3ClientOpts.credentials }
@@ -34914,13 +35217,15 @@ async function uploadTemplateForUpdateStack(args) {
34914
35217
  ...s3ClientOpts?.profile && { profile: s3ClientOpts.profile },
34915
35218
  ...s3ClientOpts?.credentials && { credentials: s3ClientOpts.credentials }
34916
35219
  });
34917
- const key = `${MIGRATE_TMP_PREFIX}/${cfnStackName}/${Date.now()}.json`;
35220
+ const ext = format === "yaml" ? "yaml" : "json";
35221
+ const contentType = format === "yaml" ? "application/x-yaml" : "application/json";
35222
+ const key = `${MIGRATE_TMP_PREFIX}/${cfnStackName}/${Date.now()}.${ext}`;
34918
35223
  try {
34919
35224
  await s3.send(new PutObjectCommand({
34920
35225
  Bucket: bucket,
34921
35226
  Key: key,
34922
35227
  Body: body,
34923
- ContentType: "application/json"
35228
+ ContentType: contentType
34924
35229
  }));
34925
35230
  } catch (err) {
34926
35231
  s3.destroy();
@@ -34943,33 +35248,32 @@ async function uploadTemplateForUpdateStack(args) {
34943
35248
  };
34944
35249
  }
34945
35250
  /**
34946
- * Parse a CloudFormation template body (JSON), set `DeletionPolicy: Retain`
34947
- * and `UpdateReplacePolicy: Retain` on every resource that doesn't already
34948
- * have those exact values, and re-serialize.
35251
+ * Parse a CloudFormation template body (JSON or YAML), set
35252
+ * `DeletionPolicy: Retain` and `UpdateReplacePolicy: Retain` on every
35253
+ * resource that doesn't already have those exact values, and re-serialize
35254
+ * in the SAME format as the input. Returns the resulting body, a
35255
+ * `modified` flag, and the detected source format so callers can stamp
35256
+ * the right content type / S3 key extension on follow-up uploads.
34949
35257
  *
34950
- * JSON-only by design. The `--migrate-from-cloudformation` flow's primary
34951
- * upstream is `cdk migrate` (which produces a CDK app whose synthesized
34952
- * template is always JSON) followed by `cdk deploy` / `cdkd deploy` (also
34953
- * JSON). Adding YAML support would require parsing CloudFormation
34954
- * shorthand intrinsics (`!Ref`, `!Sub`, `!GetAtt`, …) which round-trip
34955
- * incorrectly through generic YAML libraries — a generic YAML
34956
- * unmarshal/remarshal silently strips the custom tags and corrupts the
34957
- * template. Until a CFn-aware YAML codec is in scope, hand-written YAML
34958
- * stacks are best retired with the manual 3-step procedure.
35258
+ * YAML templates are routed through cdkd's CFn-aware YAML codec
35259
+ * (`src/cli/yaml-cfn.ts`), which preserves every CFn shorthand intrinsic
35260
+ * (`!Ref`, `!GetAtt`, `!Sub`, etc.) on round-trip. JSON templates take
35261
+ * the canonical two-space-indented JSON path.
34959
35262
  *
34960
35263
  * Exported for unit testing (the AWS round-trips are mocked, but the
34961
35264
  * mutation logic itself is pure and worth exercising directly).
34962
35265
  */
34963
35266
  function injectRetainPolicies(templateBody, cfnStackName) {
35267
+ const format = detectTemplateFormat(templateBody);
34964
35268
  let parsed;
34965
35269
  try {
34966
- parsed = JSON.parse(templateBody);
35270
+ parsed = parseCfnTemplate(templateBody);
34967
35271
  } catch (err) {
34968
- throw new Error(`Template for '${cfnStackName}' is not valid JSON. cdkd's --migrate-from-cloudformation flow only supports CDK-generated (JSON) templates. Cause: ${err instanceof Error ? err.message : String(err)}`);
35272
+ throw new Error(`Template for '${cfnStackName}' is not a valid CloudFormation template. cdkd's --migrate-from-cloudformation flow supports both JSON and YAML templates (YAML via a CFn-aware codec that preserves !Ref / !GetAtt / !Sub shorthand). Cause: ${err instanceof Error ? err.message : String(err)}`);
34969
35273
  }
34970
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || !("Resources" in parsed) || typeof parsed.Resources !== "object" || parsed.Resources === null) throw new Error(`Template for '${cfnStackName}' has no Resources section — refusing to retire.`);
35274
+ if (!("Resources" in parsed) || typeof parsed["Resources"] !== "object" || parsed["Resources"] === null || Array.isArray(parsed["Resources"])) throw new Error(`Template for '${cfnStackName}' has no Resources section — refusing to retire.`);
34971
35275
  let modified = false;
34972
- const resources = parsed.Resources;
35276
+ const resources = parsed["Resources"];
34973
35277
  for (const [, resource] of Object.entries(resources)) {
34974
35278
  if (!resource || typeof resource !== "object" || Array.isArray(resource)) continue;
34975
35279
  const r = resource;
@@ -34983,8 +35287,9 @@ function injectRetainPolicies(templateBody, cfnStackName) {
34983
35287
  }
34984
35288
  }
34985
35289
  return {
34986
- body: JSON.stringify(parsed, null, 2),
34987
- modified
35290
+ body: stringifyCfnTemplate(parsed, format),
35291
+ modified,
35292
+ format
34988
35293
  };
34989
35294
  }
34990
35295
  /**
@@ -46128,10 +46433,13 @@ async function exportCommand(stackArg, options) {
46128
46433
  let template;
46129
46434
  let resolvedStackName;
46130
46435
  let synthedRegion;
46436
+ let templateFormat = "json";
46131
46437
  let allSynthStacks = [];
46132
46438
  if (options.template) {
46133
46439
  if (!stackArg) throw new Error("--template requires a stack name as a positional argument to identify the cdkd state record.");
46134
- template = parseTemplateFile(options.template);
46440
+ const parsed = parseTemplateFile(options.template);
46441
+ template = parsed.template;
46442
+ templateFormat = parsed.format;
46135
46443
  resolvedStackName = stackArg;
46136
46444
  } else {
46137
46445
  const appCmd = options.app || resolveApp();
@@ -46228,7 +46536,7 @@ async function exportCommand(stackArg, options) {
46228
46536
  const phase1Template = filterTemplateForImport(template, phase1Imports);
46229
46537
  const injectedCount = injectDeletionPolicyForImport(phase1Template);
46230
46538
  if (injectedCount > 0) logger.info(`Injected DeletionPolicy: Delete on ${injectedCount} resource(s) without an explicit DeletionPolicy (required by CFn IMPORT — matches the CDK/CFn default for resources without RemovalPolicy).`);
46231
- await executeImportChangeSet(awsClients.cloudFormation, cfnStackName, phase1Template, phase1Imports, cfnParameters);
46539
+ await executeImportChangeSet(awsClients.cloudFormation, cfnStackName, phase1Template, phase1Imports, cfnParameters, templateFormat);
46232
46540
  logger.info(`✓ Phase 1: CloudFormation stack '${cfnStackName}' created via IMPORT. ${phase1Imports.length} resource(s) imported.`);
46233
46541
  if (recreateBeforePhase2.length > 0) for (const entry of recreateBeforePhase2) {
46234
46542
  const handler = PRE_DELETE_HANDLERS[entry.resourceType];
@@ -46245,7 +46553,7 @@ async function exportCommand(stackArg, options) {
46245
46553
  const phase2Count = phase2Creates.length + recreateBeforePhase2.length;
46246
46554
  if (phase2Count > 0) try {
46247
46555
  const phase2Template = applyImportOverlayForPhase2(template, phase1Imports);
46248
- await executeUpdateChangeSet(awsClients.cloudFormation, cfnStackName, phase2Template, cfnParameters);
46556
+ await executeUpdateChangeSet(awsClients.cloudFormation, cfnStackName, phase2Template, cfnParameters, templateFormat);
46249
46557
  const parts = [];
46250
46558
  if (phase2Creates.length > 0) parts.push(`${phase2Creates.length} non-importable resource(s) CREATEd`);
46251
46559
  if (recreateBeforePhase2.length > 0) parts.push(`${recreateBeforePhase2.length} IMPORT-unsupported resource(s) re-CREATEd`);
@@ -46305,14 +46613,15 @@ function parseTemplateFile(path) {
46305
46613
  } catch (err) {
46306
46614
  throw new Error(`Failed to read template file '${path}': ` + (err instanceof Error ? err.message : String(err)));
46307
46615
  }
46308
- let parsed;
46309
46616
  try {
46310
- parsed = JSON.parse(raw);
46617
+ const { template, format } = parseCfnTemplateWithFormat(raw);
46618
+ return {
46619
+ template,
46620
+ format
46621
+ };
46311
46622
  } catch (err) {
46312
- throw new Error(`Template file '${path}' is not valid JSON. cdkd export only supports JSON templates (CDK-generated). Cause: ` + (err instanceof Error ? err.message : String(err)));
46623
+ throw new Error(`Template file '${path}' is not a valid CloudFormation template. cdkd export accepts JSON and YAML (YAML via a CFn-aware codec that preserves !Ref / !GetAtt / !Sub shorthand). Cause: ` + (err instanceof Error ? err.message : String(err)));
46313
46624
  }
46314
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error(`Template file '${path}' is not a JSON object.`);
46315
- return parsed;
46316
46625
  }
46317
46626
  async function assertCfnStackAbsent(cfnClient, stackName) {
46318
46627
  try {
@@ -46860,10 +47169,10 @@ function printPlan(plan, cfnStackName) {
46860
47169
  }
46861
47170
  logger.info("");
46862
47171
  }
46863
- async function executeImportChangeSet(cfnClient, stackName, template, plan, parameters) {
47172
+ async function executeImportChangeSet(cfnClient, stackName, template, plan, parameters, templateFormat = "json") {
46864
47173
  const logger = getLogger();
46865
47174
  const changeSetName = `cdkd-migrate-${Date.now()}`;
46866
- const templateBody = JSON.stringify(template, null, 2);
47175
+ const templateBody = stringifyCfnTemplate(template, templateFormat);
46867
47176
  const resourcesToImport = plan.map((entry) => ({
46868
47177
  ResourceType: entry.resourceType,
46869
47178
  LogicalResourceId: entry.logicalId,
@@ -46974,10 +47283,10 @@ async function collectImportFailureSummary(cfnClient, stackName) {
46974
47283
  * (cdkd state is intentionally NOT deleted between phases, so a phase-2
46975
47284
  * failure leaves a recoverable state).
46976
47285
  */
46977
- async function executeUpdateChangeSet(cfnClient, stackName, template, parameters) {
47286
+ async function executeUpdateChangeSet(cfnClient, stackName, template, parameters, templateFormat = "json") {
46978
47287
  const logger = getLogger();
46979
47288
  const changeSetName = `cdkd-phase2-${Date.now()}`;
46980
- const templateBody = JSON.stringify(template, null, 2);
47289
+ const templateBody = stringifyCfnTemplate(template, templateFormat);
46981
47290
  if (templateBody.length > 51200) throw new Error(`Full template is ${templateBody.length} bytes, over the 51,200-byte inline TemplateBody limit for phase-2 UPDATE. TemplateURL upload is not yet implemented.`);
46982
47291
  logger.info(`Creating UPDATE changeset '${changeSetName}' for phase 2 (${templateBody.length} bytes)...`);
46983
47292
  try {
@@ -47103,7 +47412,7 @@ async function confirmPrompt(prompt) {
47103
47412
  }
47104
47413
  }
47105
47414
  function createExportCommand() {
47106
- 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). JSON templates only. 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). 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));
47415
+ 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));
47107
47416
  [
47108
47417
  ...commonOptions,
47109
47418
  ...appOptions,
@@ -47155,7 +47464,7 @@ function reorderArgs(argv) {
47155
47464
  */
47156
47465
  async function main() {
47157
47466
  const program = new Command();
47158
- program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.123.0");
47467
+ program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.124.0");
47159
47468
  program.addCommand(createBootstrapCommand());
47160
47469
  program.addCommand(createSynthCommand());
47161
47470
  program.addCommand(createListCommand());