@intentius/chant 0.124.48 → 0.124.50

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.
Files changed (167) hide show
  1. package/dist/cli/handlers/approve.d.ts.map +1 -1
  2. package/dist/cli/handlers/approve.js +20 -12
  3. package/dist/cli/handlers/graph-reader.d.ts +26 -0
  4. package/dist/cli/handlers/graph-reader.d.ts.map +1 -0
  5. package/dist/cli/handlers/graph-reader.js +41 -0
  6. package/dist/cli/handlers/graph.d.ts.map +1 -1
  7. package/dist/cli/handlers/graph.js +14 -0
  8. package/dist/cli/handlers/pr.d.ts.map +1 -1
  9. package/dist/cli/handlers/pr.js +40 -1
  10. package/dist/cli/main.d.ts.map +1 -1
  11. package/dist/cli/main.js +22 -0
  12. package/dist/cli/parse-args.d.ts.map +1 -1
  13. package/dist/cli/parse-args.js +11 -0
  14. package/dist/cli/registry.d.ts +8 -0
  15. package/dist/cli/registry.d.ts.map +1 -1
  16. package/dist/components/pr-run.d.ts +3 -0
  17. package/dist/components/pr-run.d.ts.map +1 -1
  18. package/dist/components/pr-run.js +1 -0
  19. package/dist/config.d.ts +16 -0
  20. package/dist/config.d.ts.map +1 -1
  21. package/dist/config.js +4 -0
  22. package/dist/gate-decision.d.ts +9 -0
  23. package/dist/gate-decision.d.ts.map +1 -1
  24. package/dist/gate-decision.js +22 -7
  25. package/dist/lexicon.d.ts +23 -0
  26. package/dist/lexicon.d.ts.map +1 -1
  27. package/dist/op/activities/activity-contracts.d.ts +4 -4
  28. package/dist/op/gate-approval-base.d.ts +25 -0
  29. package/dist/op/gate-approval-base.d.ts.map +1 -0
  30. package/dist/op/gate-approval-base.js +71 -0
  31. package/dist/op/gate-approval.d.ts +7 -4
  32. package/dist/op/gate-approval.d.ts.map +1 -1
  33. package/dist/op/gate-approval.js +2 -25
  34. package/dist/op/gate-name.d.ts +13 -0
  35. package/dist/op/gate-name.d.ts.map +1 -1
  36. package/dist/op/gate-name.js +14 -1
  37. package/dist/op/gate.d.ts +4 -3
  38. package/dist/op/gate.d.ts.map +1 -1
  39. package/dist/op/local-executor.d.ts +9 -2
  40. package/dist/op/local-executor.d.ts.map +1 -1
  41. package/dist/op/local-executor.js +24 -5
  42. package/dist/pr-loop.d.ts +10 -0
  43. package/dist/pr-loop.d.ts.map +1 -1
  44. package/dist/pr-loop.js +4 -0
  45. package/dist/pr-migrations.d.ts +62 -0
  46. package/dist/pr-migrations.d.ts.map +1 -0
  47. package/dist/pr-migrations.js +50 -0
  48. package/dist/pr-post.js +1 -1
  49. package/dist/wave-runtime.mjs +219 -218
  50. package/dist/workspace/check.schema.json +4 -2
  51. package/dist/workspace/checks/gate-approval.d.ts +58 -0
  52. package/dist/workspace/checks/gate-approval.d.ts.map +1 -0
  53. package/dist/workspace/checks/gate-approval.js +103 -0
  54. package/dist/workspace/checks.d.ts +1 -0
  55. package/dist/workspace/checks.d.ts.map +1 -1
  56. package/dist/workspace/checks.js +4 -0
  57. package/dist/workspace/declaration.d.ts +7 -0
  58. package/dist/workspace/declaration.d.ts.map +1 -1
  59. package/dist/workspace/declaration.js +13 -1
  60. package/dist/workspace/declaration.schema.json +33 -0
  61. package/dist/workspace/exit-cli.d.ts +44 -0
  62. package/dist/workspace/exit-cli.d.ts.map +1 -0
  63. package/dist/workspace/exit-cli.js +198 -0
  64. package/dist/workspace/exit.d.ts +159 -0
  65. package/dist/workspace/exit.d.ts.map +1 -0
  66. package/dist/workspace/exit.js +480 -0
  67. package/dist/workspace/exit.schema.json +257 -0
  68. package/dist/workspace/graph-cache.d.ts +3 -1
  69. package/dist/workspace/graph-cache.d.ts.map +1 -1
  70. package/dist/workspace/graph-cache.js +12 -1
  71. package/dist/workspace/import-fetch.d.ts +38 -0
  72. package/dist/workspace/import-fetch.d.ts.map +1 -0
  73. package/dist/workspace/import-fetch.js +99 -0
  74. package/dist/workspace/import-init.d.ts +26 -0
  75. package/dist/workspace/import-init.d.ts.map +1 -0
  76. package/dist/workspace/import-init.js +130 -0
  77. package/dist/workspace/import-source.d.ts +147 -0
  78. package/dist/workspace/import-source.d.ts.map +1 -0
  79. package/dist/workspace/import-source.js +370 -0
  80. package/dist/workspace/init.d.ts +4 -0
  81. package/dist/workspace/init.d.ts.map +1 -1
  82. package/dist/workspace/init.js +70 -3
  83. package/dist/workspace/kind-readers.d.ts +6 -0
  84. package/dist/workspace/kind-readers.d.ts.map +1 -1
  85. package/dist/workspace/kind-readers.js +18 -0
  86. package/dist/workspace/kinds.d.ts +24 -1
  87. package/dist/workspace/kinds.d.ts.map +1 -1
  88. package/dist/workspace/kinds.js +64 -4
  89. package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
  90. package/dist/workspace/lineage-upgrade.js +13 -4
  91. package/dist/workspace/new-migrations.d.ts +47 -0
  92. package/dist/workspace/new-migrations.d.ts.map +1 -0
  93. package/dist/workspace/new-migrations.js +91 -0
  94. package/dist/workspace/pr-report.schema.json +46 -0
  95. package/dist/workspace/promote.d.ts +96 -0
  96. package/dist/workspace/promote.d.ts.map +1 -0
  97. package/dist/workspace/promote.js +263 -0
  98. package/dist/workspace/reason-codes.d.ts +13 -0
  99. package/dist/workspace/reason-codes.d.ts.map +1 -1
  100. package/dist/workspace/reason-codes.js +14 -0
  101. package/dist/workspace/search-contract.d.ts +1 -1
  102. package/dist/workspace/search-contract.d.ts.map +1 -1
  103. package/dist/workspace/workspace-kinds.schema.json +50 -7
  104. package/package.json +1 -1
  105. package/src/cli/handlers/approve.ts +10 -2
  106. package/src/cli/handlers/graph-reader.test.ts +31 -0
  107. package/src/cli/handlers/graph-reader.ts +51 -0
  108. package/src/cli/handlers/graph.ts +14 -0
  109. package/src/cli/handlers/pr.ts +38 -1
  110. package/src/cli/main.test.ts +2 -0
  111. package/src/cli/main.ts +22 -0
  112. package/src/cli/parse-args.ts +8 -0
  113. package/src/cli/registry.ts +8 -0
  114. package/src/components/pr-env-run-bundle.test.ts +1 -0
  115. package/src/components/pr-run.ts +4 -0
  116. package/src/config.ts +16 -0
  117. package/src/gate-decision-bundle.test.ts +4 -2
  118. package/src/gate-decision-identity.test.ts +11 -0
  119. package/src/gate-decision.ts +26 -6
  120. package/src/lexicon.ts +24 -0
  121. package/src/op/gate-approval-at-base.test.ts +97 -0
  122. package/src/op/gate-approval-base.ts +72 -0
  123. package/src/op/gate-approval.ts +10 -21
  124. package/src/op/gate-name.ts +16 -1
  125. package/src/op/gate.ts +4 -3
  126. package/src/op/local-executor.ts +25 -5
  127. package/src/pr-loop-bundle.test.ts +3 -2
  128. package/src/pr-loop.test.ts +52 -0
  129. package/src/pr-loop.ts +12 -0
  130. package/src/pr-migrations.ts +97 -0
  131. package/src/pr-post.ts +1 -1
  132. package/src/workspace/__fixtures__/import/spacelift.json +9 -0
  133. package/src/workspace/__fixtures__/import/tfc.json +13 -0
  134. package/src/workspace/behold-kinds.test.ts +1 -0
  135. package/src/workspace/check-contract.test.ts +3 -2
  136. package/src/workspace/check.schema.json +4 -2
  137. package/src/workspace/checks/gate-approval.test.ts +77 -0
  138. package/src/workspace/checks/gate-approval.ts +121 -0
  139. package/src/workspace/checks.ts +4 -0
  140. package/src/workspace/declaration.schema.json +33 -0
  141. package/src/workspace/declaration.ts +19 -2
  142. package/src/workspace/exit-cli.ts +219 -0
  143. package/src/workspace/exit.schema.json +257 -0
  144. package/src/workspace/exit.test.ts +185 -0
  145. package/src/workspace/exit.ts +560 -0
  146. package/src/workspace/graph-cache.ts +12 -2
  147. package/src/workspace/identity.test.ts +18 -3
  148. package/src/workspace/import-fetch.ts +151 -0
  149. package/src/workspace/import-init.ts +147 -0
  150. package/src/workspace/import-source.test.ts +183 -0
  151. package/src/workspace/import-source.ts +462 -0
  152. package/src/workspace/init.test.ts +55 -1
  153. package/src/workspace/init.ts +66 -4
  154. package/src/workspace/kind-readers.test.ts +36 -0
  155. package/src/workspace/kind-readers.ts +18 -1
  156. package/src/workspace/kinds.test.ts +23 -0
  157. package/src/workspace/kinds.ts +81 -5
  158. package/src/workspace/lineage-upgrade.test.ts +20 -0
  159. package/src/workspace/lineage-upgrade.ts +16 -7
  160. package/src/workspace/new-migrations.test.ts +98 -0
  161. package/src/workspace/new-migrations.ts +109 -0
  162. package/src/workspace/pr-report.schema.json +46 -0
  163. package/src/workspace/promote.test.ts +114 -0
  164. package/src/workspace/promote.ts +302 -0
  165. package/src/workspace/reason-codes.test.ts +4 -0
  166. package/src/workspace/reason-codes.ts +14 -0
  167. package/src/workspace/workspace-kinds.schema.json +50 -7
@@ -0,0 +1,151 @@
1
+ /**
2
+ * The network half of `chant workspace init --from tfc|spacelift` (#3178).
3
+ *
4
+ * Reads the Terraform Cloud API or the Spacelift GraphQL API into the input
5
+ * documents `import-source.ts` maps. The transport is a parameter, so a test
6
+ * feeds fixture responses and nothing here is reached without a token. A
7
+ * token is read from `--token`, else `TFE_TOKEN` / `TF_TOKEN_app_terraform_io`
8
+ * for Terraform Cloud and `SPACELIFT_API_TOKEN` for Spacelift. Only reads:
9
+ * every request is a GET, or a GraphQL query with no mutation.
10
+ */
11
+
12
+ import type { SpaceliftInput, TfcInput } from "./import-source";
13
+
14
+ export type Fetcher = (url: string, init?: { method?: string; headers?: Record<string, string>; body?: string }) => Promise<{ ok: boolean; status: number; json(): Promise<unknown> }>;
15
+
16
+ export class ImportFetchError extends Error {}
17
+
18
+ const defaultFetch: Fetcher = (url, init) => fetch(url, init);
19
+
20
+ // ── Terraform Cloud ──────────────────────────────────────────────────────────
21
+
22
+ interface JsonApiResource {
23
+ id: string;
24
+ type?: string;
25
+ attributes?: Record<string, unknown>;
26
+ relationships?: Record<string, { data?: { id: string } | { id: string }[] | null }>;
27
+ }
28
+
29
+ export interface TfcFetchOptions {
30
+ organization: string;
31
+ token: string;
32
+ /** Default `https://app.terraform.io`; a Terraform Enterprise host works too. */
33
+ host?: string;
34
+ fetcher?: Fetcher;
35
+ }
36
+
37
+ export async function fetchTfc(options: TfcFetchOptions): Promise<TfcInput> {
38
+ const fetcher = options.fetcher ?? defaultFetch;
39
+ const base = (options.host ?? "https://app.terraform.io").replace(/\/$/, "");
40
+ const headers = { Authorization: `Bearer ${options.token}`, "Content-Type": "application/vnd.api+json" };
41
+
42
+ const list = async (path: string): Promise<JsonApiResource[]> => {
43
+ const out: JsonApiResource[] = [];
44
+ let url: string | undefined = `${base}/api/v2${path}${path.includes("?") ? "&" : "?"}page%5Bsize%5D=100`;
45
+ while (url) {
46
+ const res = await fetcher(url, { method: "GET", headers });
47
+ if (!res.ok) throw new ImportFetchError(`Terraform Cloud answered ${res.status} for ${url.replace(base, "")}`);
48
+ const body = (await res.json()) as { data?: JsonApiResource[]; links?: { next?: string | null } };
49
+ out.push(...(body.data ?? []));
50
+ url = body.links?.next ?? undefined;
51
+ }
52
+ return out;
53
+ };
54
+
55
+ const org = encodeURIComponent(options.organization);
56
+ const [projects, workspaces, policySets] = await Promise.all([list(`/organizations/${org}/projects`), list(`/organizations/${org}/workspaces`), list(`/organizations/${org}/policy-sets`)]);
57
+
58
+ const full: TfcInput["workspaces"] = [];
59
+ for (const w of workspaces) {
60
+ const a = w.attributes ?? {};
61
+ const [vars, triggers] = await Promise.all([list(`/workspaces/${w.id}/vars`), list(`/workspaces/${w.id}/run-triggers?filter%5Brun-trigger%5D%5Btype%5D=inbound`)]);
62
+ const project = w.relationships?.project?.data;
63
+ full.push({
64
+ id: w.id,
65
+ name: String(a.name ?? w.id),
66
+ ...(project && !Array.isArray(project) ? { project: project.id } : {}),
67
+ ...(a["working-directory"] ? { workingDirectory: String(a["working-directory"]) } : {}),
68
+ ...(a["terraform-version"] ? { terraformVersion: String(a["terraform-version"]) } : {}),
69
+ ...((a["vcs-repo"] as { identifier?: string } | null)?.identifier ? { vcsRepo: (a["vcs-repo"] as { identifier: string }).identifier } : {}),
70
+ tags: Array.isArray(a["tag-names"]) ? (a["tag-names"] as string[]) : [],
71
+ variables: vars.map((v) => ({ key: String(v.attributes?.key ?? ""), category: String(v.attributes?.category ?? ""), sensitive: v.attributes?.sensitive === true })),
72
+ runTriggers: triggers
73
+ .map((t) => (t.relationships?.sourceable?.data as { id: string } | undefined)?.id)
74
+ .filter((id): id is string => typeof id === "string"),
75
+ });
76
+ }
77
+
78
+ return {
79
+ organization: options.organization,
80
+ projects: projects.map((p) => ({ id: p.id, name: String(p.attributes?.name ?? p.id) })),
81
+ workspaces: full,
82
+ policySets: policySets.map((p) => ({
83
+ id: p.id,
84
+ name: String(p.attributes?.name ?? p.id),
85
+ kind: String(p.attributes?.kind ?? "sentinel"),
86
+ global: p.attributes?.global === true,
87
+ workspaces: ((p.relationships?.workspaces?.data as { id: string }[] | undefined) ?? []).map((x) => x.id),
88
+ })),
89
+ };
90
+ }
91
+
92
+ // ── Spacelift ────────────────────────────────────────────────────────────────
93
+
94
+ export interface SpaceliftFetchOptions {
95
+ /** The account's endpoint, e.g. `https://acme.app.spacelift.io/graphql`. */
96
+ endpoint: string;
97
+ token: string;
98
+ fetcher?: Fetcher;
99
+ }
100
+
101
+ const SPACELIFT_QUERY = `query ChantImport {
102
+ stacks { id name space repository branch projectRoot vendor { __typename } labels
103
+ attachedPolicies: attachedPolicies { id }
104
+ dependsOn: dependencies { stackId references { outputName inputName } } }
105
+ policies { id name type }
106
+ }`;
107
+
108
+ export async function fetchSpacelift(options: SpaceliftFetchOptions): Promise<SpaceliftInput> {
109
+ const fetcher = options.fetcher ?? defaultFetch;
110
+ const res = await fetcher(options.endpoint, {
111
+ method: "POST",
112
+ headers: { Authorization: `Bearer ${options.token}`, "Content-Type": "application/json" },
113
+ body: JSON.stringify({ query: SPACELIFT_QUERY }),
114
+ });
115
+ if (!res.ok) throw new ImportFetchError(`Spacelift answered ${res.status} for ${options.endpoint}`);
116
+ const body = (await res.json()) as {
117
+ errors?: { message: string }[];
118
+ data?: {
119
+ stacks?: {
120
+ id: string;
121
+ name: string;
122
+ space?: string;
123
+ repository?: string;
124
+ branch?: string;
125
+ projectRoot?: string;
126
+ vendor?: { __typename?: string } | null;
127
+ labels?: string[];
128
+ attachedPolicies?: { id: string }[];
129
+ dependsOn?: { stackId: string; references?: { outputName: string; inputName?: string }[] }[];
130
+ }[];
131
+ policies?: { id: string; name: string; type?: string }[];
132
+ };
133
+ };
134
+ if (body.errors?.length) throw new ImportFetchError(`Spacelift: ${body.errors.map((e) => e.message).join("; ")}`);
135
+ return {
136
+ account: new URL(options.endpoint).hostname.split(".")[0],
137
+ stacks: (body.data?.stacks ?? []).map((s) => ({
138
+ id: s.id,
139
+ name: s.name,
140
+ ...(s.space ? { space: s.space } : {}),
141
+ ...(s.repository ? { repository: s.repository } : {}),
142
+ ...(s.branch ? { branch: s.branch } : {}),
143
+ ...(s.projectRoot ? { projectRoot: s.projectRoot } : {}),
144
+ vendor: (s.vendor?.__typename ?? "TerraformStackVendor").replace(/StackVendor$|Vendor$/, "").toLowerCase() || "terraform",
145
+ labels: s.labels ?? [],
146
+ policies: (s.attachedPolicies ?? []).map((p) => p.id),
147
+ dependsOn: (s.dependsOn ?? []).map((d) => ({ stack: d.stackId, references: d.references ?? [] })),
148
+ })),
149
+ policies: body.data?.policies ?? [],
150
+ };
151
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * `chant workspace init --from tfc|spacelift|backstage` (#3178): the command
3
+ * around `import-source.ts`. It reads the source, prints the proposal and what
4
+ * was left out, and writes only on confirmation, as `init` does (#2525 rule 1).
5
+ * Besides `chant.workspace.json` it writes `chant-import.<source>.json`: the
6
+ * environments and constraint records the declaration has no field for.
7
+ */
8
+
9
+ import { existsSync, writeFileSync } from "node:fs";
10
+ import { join, resolve } from "node:path";
11
+ import { formatError, formatSuccess } from "../cli/format";
12
+ import type { CommandContext } from "../cli/registry";
13
+ import { fetchSpacelift, fetchTfc, ImportFetchError, type Fetcher } from "./import-fetch";
14
+ import {
15
+ IMPORT_SOURCES,
16
+ proposeFromBackstage,
17
+ proposeFromSpacelift,
18
+ proposeFromTfc,
19
+ readInputFile,
20
+ type ImportProposal,
21
+ type ImportSource,
22
+ type SpaceliftInput,
23
+ type TfcInput,
24
+ } from "./import-source";
25
+ import { WorkspaceReadError } from "./declaration";
26
+
27
+ export const IMPORT_USAGE =
28
+ "chant workspace init --from tfc --org <organization> [--input <export.json>] [--token <token>] [dir] [--name <name>] [--yes]\n" +
29
+ " chant workspace init --from spacelift --endpoint <https://acme.app.spacelift.io/graphql> [--input <export.json>] [--token <token>] [dir] [--yes]\n" +
30
+ " chant workspace init --from backstage [dir] [--name <name>] [--yes]";
31
+
32
+ export const isImportSource = (v: string | undefined): v is ImportSource => (IMPORT_SOURCES as readonly string[]).includes(v ?? "");
33
+
34
+ export interface ImportDeps {
35
+ /** For tests: the transport the API readers use. */
36
+ fetcher?: Fetcher;
37
+ env?: Record<string, string | undefined>;
38
+ /** For tests: answers the confirmation. */
39
+ confirm?: (question: string) => Promise<boolean>;
40
+ isTTY?: boolean;
41
+ }
42
+
43
+ /** Read the source into a proposal. Throws {@link ImportFetchError} or an Error naming what is missing. */
44
+ export async function loadProposal(source: ImportSource, root: string, args: CommandContext["args"], deps: ImportDeps = {}): Promise<ImportProposal> {
45
+ const env = deps.env ?? process.env;
46
+ if (source === "backstage") return proposeFromBackstage(root, { name: args.selectName });
47
+ if (source === "tfc") {
48
+ let input: TfcInput;
49
+ if (args.input) input = readInputFile("tfc", resolve(args.input)) as TfcInput;
50
+ else {
51
+ const token = args.token ?? env.TFE_TOKEN ?? env.TF_TOKEN_app_terraform_io;
52
+ if (!token) throw new Error("--from tfc needs --input <export.json>, or a token in --token, TFE_TOKEN or TF_TOKEN_app_terraform_io");
53
+ if (!args.org) throw new Error("--from tfc needs --org <organization>");
54
+ input = await fetchTfc({ organization: args.org, token, fetcher: deps.fetcher });
55
+ }
56
+ return proposeFromTfc(input, { name: args.selectName });
57
+ }
58
+ let input: SpaceliftInput;
59
+ if (args.input) input = readInputFile("spacelift", resolve(args.input)) as SpaceliftInput;
60
+ else {
61
+ const token = args.token ?? env.SPACELIFT_API_TOKEN;
62
+ if (!token) throw new Error("--from spacelift needs --input <export.json>, or a token in --token or SPACELIFT_API_TOKEN");
63
+ if (!args.endpoint) throw new Error("--from spacelift needs --endpoint <https://<account>.app.spacelift.io/graphql>");
64
+ input = await fetchSpacelift({ endpoint: args.endpoint, token, fetcher: deps.fetcher });
65
+ }
66
+ return proposeFromSpacelift(input, { name: args.selectName });
67
+ }
68
+
69
+ export function formatImportProposal(p: ImportProposal): string[] {
70
+ const lines: string[] = [];
71
+ const links = p.declaration.members.reduce((n, m) => n + (m.links?.length ?? 0), 0);
72
+ lines.push(`${p.declaration.members.length} member(s), ${links} link(s), ${p.environments.length} environment(s), ${p.records.length} constraint record(s).`);
73
+ if (p.environments.length > 0) {
74
+ lines.push("", "Environments:");
75
+ for (const e of p.environments) lines.push(` ${e.name}: ${e.members.join(", ")}`);
76
+ }
77
+ if (p.records.length > 0) {
78
+ lines.push("", "Constraint records:");
79
+ for (const r of p.records) lines.push(` ${r.id}${r.policyKind ? ` (${r.policyKind})` : ""} applies to ${r.appliesTo.length === 0 ? "every member" : r.appliesTo.join(", ")}`);
80
+ }
81
+ lines.push("", "Left out:");
82
+ if (p.leftOut.length === 0) lines.push(" nothing");
83
+ for (const l of p.leftOut) lines.push(` ${l.count} ${l.what}: ${l.why}`);
84
+ for (const n of p.notes) lines.push("", n);
85
+ return lines;
86
+ }
87
+
88
+ async function defaultConfirm(question: string): Promise<boolean> {
89
+ const { createInterface } = await import("node:readline");
90
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
91
+ return new Promise((done) => {
92
+ rl.question(question, (a) => {
93
+ done(/^y(es)?$/i.test(a.trim()));
94
+ rl.close();
95
+ });
96
+ rl.on("close", () => done(false));
97
+ });
98
+ }
99
+
100
+ export async function runImportInit(source: ImportSource, root: string, args: CommandContext["args"], deps: ImportDeps = {}): Promise<number> {
101
+ let proposal: ImportProposal;
102
+ try {
103
+ proposal = await loadProposal(source, root, args, deps);
104
+ } catch (err) {
105
+ if (err instanceof WorkspaceReadError) {
106
+ console.error(formatError({ message: `the proposed declaration is not valid: ${err.describe()}`, hint: "Write chant.workspace.json by hand for now." }));
107
+ return 1;
108
+ }
109
+ console.error(formatError({ message: err instanceof Error ? err.message : String(err), hint: IMPORT_USAGE }));
110
+ return 1;
111
+ }
112
+
113
+ const target = join(root, "chant.workspace.json");
114
+ const sidecar = join(root, `chant-import.${source}.json`);
115
+ const text = `${JSON.stringify(proposal.declaration, null, 2)}\n`;
116
+ const sideText = `${JSON.stringify({ source, environments: proposal.environments, records: proposal.records, leftOut: proposal.leftOut }, null, 2)}\n`;
117
+ console.log(`Proposed ${target}:`);
118
+ console.log("");
119
+ console.log(text);
120
+ for (const line of formatImportProposal(proposal)) console.log(line);
121
+ console.log("");
122
+ console.log(`Confirming also writes ${sidecar}, holding the environments, constraint records and the left-out list.`);
123
+ console.log("");
124
+
125
+ let write = !!args.yes;
126
+ if (!write) {
127
+ if (!(deps.isTTY ?? process.stdin.isTTY)) {
128
+ console.log("Nothing written. Re-run with --yes to write it, or save the proposal above and edit it first.");
129
+ return 0;
130
+ }
131
+ write = await (deps.confirm ?? defaultConfirm)("Write chant.workspace.json? [y/N] ");
132
+ }
133
+ if (!write) {
134
+ console.log("Nothing written.");
135
+ return 0;
136
+ }
137
+ if (existsSync(sidecar)) {
138
+ console.error(formatError({ message: `${sidecar} already exists`, hint: "Move it aside and run again." }));
139
+ return 1;
140
+ }
141
+ writeFileSync(target, text, { flag: "wx" });
142
+ writeFileSync(sidecar, sideText, { flag: "wx" });
143
+ console.log(formatSuccess(`Wrote ${target} and ${sidecar}. chant workspace ls lists what it declares.`));
144
+ return 0;
145
+ }
146
+
147
+ export { ImportFetchError };
@@ -0,0 +1,183 @@
1
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import { afterEach, describe, expect, test, vi } from "vitest";
5
+ import { parseArgs } from "../cli/parse-args";
6
+ import { fetchSpacelift, fetchTfc, type Fetcher } from "./import-fetch";
7
+ import { runImportInit } from "./import-init";
8
+ import { proposeFromBackstage, proposeFromSpacelift, proposeFromTfc, readInputFile, type SpaceliftInput, type TfcInput } from "./import-source";
9
+
10
+ const FIX = join(__dirname, "__fixtures__", "import");
11
+ const tfc = (): TfcInput => readInputFile("tfc", join(FIX, "tfc.json")) as TfcInput;
12
+ const spacelift = (): SpaceliftInput => readInputFile("spacelift", join(FIX, "spacelift.json")) as SpaceliftInput;
13
+ const noNetwork = vi.fn(() => {
14
+ throw new Error("a test reached the network");
15
+ });
16
+
17
+ describe("workspace init --from tfc (#3178)", () => {
18
+ test("workspaces become members, run triggers become links, tags become environments", () => {
19
+ const p = proposeFromTfc(tfc());
20
+ expect(p.declaration.members.map((m) => [m.name, m.kind, m.dir])).toEqual([
21
+ ["network-prod", "terraform", "infra/network"],
22
+ ["app-prod", "terraform", "infra/app"],
23
+ ]);
24
+ expect(p.declaration.members[0].outputs).toEqual(["applied"]);
25
+ expect(p.declaration.members[1].links).toEqual([{ member: "network-prod", output: "applied" }]);
26
+ expect(p.environments).toEqual([
27
+ { name: "dev", members: ["app-prod"] },
28
+ { name: "prod", members: ["network-prod", "app-prod"] },
29
+ ]);
30
+ });
31
+
32
+ test("policy sets become constraint records and the left-out list names what was not carried", () => {
33
+ const p = proposeFromTfc(tfc());
34
+ expect(p.records.map((r) => [r.title, r.kind, r.appliesTo])).toEqual([
35
+ ["no-public-s3", "constraint", ["app-prod"]],
36
+ ["tags-required", "constraint", []],
37
+ ]);
38
+ const out = Object.fromEntries(p.leftOut.map((l) => [l.what, l.count]));
39
+ expect(out.variables).toBe(2);
40
+ expect(out["run triggers"]).toBe(1);
41
+ expect(out["VCS connections"]).toBe(1);
42
+ expect(out["workspaces sharing a directory"]).toBe(1);
43
+ expect(p.notes.join(" ")).toContain("chant-lexicon-terraform");
44
+ });
45
+
46
+ test("the API reader builds the same input from fixture responses, with no network", async () => {
47
+ const calls: string[] = [];
48
+ const fetcher: Fetcher = async (url, init) => {
49
+ calls.push(`${init?.method} ${url}`);
50
+ const u = new URL(url);
51
+ const body = (data: unknown[]) => ({ ok: true, status: 200, json: async () => ({ data }) });
52
+ if (u.pathname.endsWith("/projects")) return body([{ id: "prj-1", attributes: { name: "platform" } }]);
53
+ if (u.pathname.endsWith("/policy-sets")) return body([{ id: "pol-1", attributes: { name: "p", global: true } }]);
54
+ if (u.pathname.endsWith("/organizations/acme/workspaces"))
55
+ return body([
56
+ { id: "ws-a", attributes: { name: "a", "working-directory": "a", "tag-names": ["env:x"] } },
57
+ { id: "ws-b", attributes: { name: "b" } },
58
+ ]);
59
+ if (u.pathname.endsWith("/workspaces/ws-b/run-triggers")) return body([{ id: "rt", relationships: { sourceable: { data: { id: "ws-a" } } } }]);
60
+ if (u.pathname.endsWith("/vars")) return body([{ id: "v", attributes: { key: "k", sensitive: true } }]);
61
+ return body([]);
62
+ };
63
+ const input = await fetchTfc({ organization: "acme", token: "t", fetcher });
64
+ expect(calls.every((c) => c.startsWith("GET "))).toBe(true);
65
+ const p = proposeFromTfc(input);
66
+ expect(p.declaration.members[1].links).toEqual([{ member: "a", output: "applied" }]);
67
+ expect(p.records[0].appliesTo).toEqual([]);
68
+ });
69
+
70
+ test("a bad status is an error that names the path", async () => {
71
+ const fetcher: Fetcher = async () => ({ ok: false, status: 401, json: async () => ({}) });
72
+ await expect(fetchTfc({ organization: "acme", token: "t", fetcher })).rejects.toThrow(/401/);
73
+ });
74
+ });
75
+
76
+ describe("workspace init --from spacelift (#3178)", () => {
77
+ test("dependent stacks become members with links on the named outputs", () => {
78
+ const p = proposeFromSpacelift(spacelift());
79
+ expect(p.declaration.members.map((m) => [m.name, m.kind])).toEqual([
80
+ ["vpc", "terraform"],
81
+ ["database", "terraform"],
82
+ ["ansible-config", "other"],
83
+ ]);
84
+ expect(p.declaration.members[0].outputs).toEqual(["subnet_ids"]);
85
+ expect(p.declaration.members[1].links).toEqual([{ member: "vpc", output: "subnet_ids" }]);
86
+ expect(p.declaration.members[2].links).toEqual([{ member: "database", output: "applied" }]);
87
+ expect(p.environments).toEqual([{ name: "prod", members: ["vpc", "database"] }]);
88
+ expect(p.records).toEqual([expect.objectContaining({ kind: "constraint", policyKind: "APPROVAL", appliesTo: ["database"] })]);
89
+ expect(p.leftOut.map((l) => l.what)).toEqual(expect.arrayContaining(["context attachments", "stack dependencies"]));
90
+ });
91
+
92
+ test("the GraphQL reader only sends a query, and maps the response", async () => {
93
+ let sent = "";
94
+ const fetcher: Fetcher = async (_url, init) => {
95
+ sent = init?.body ?? "";
96
+ return {
97
+ ok: true,
98
+ status: 200,
99
+ json: async () => ({ data: { stacks: [{ id: "a", name: "a", dependsOn: [] }, { id: "b", name: "b", dependsOn: [{ stackId: "a", references: [] }] }], policies: [] } }),
100
+ };
101
+ };
102
+ const input = await fetchSpacelift({ endpoint: "https://acme.app.spacelift.io/graphql", token: "t", fetcher });
103
+ expect(sent).not.toContain("mutation");
104
+ expect(input.account).toBe("acme");
105
+ expect(proposeFromSpacelift(input).declaration.members[1].links).toEqual([{ member: "a", output: "applied" }]);
106
+ });
107
+ });
108
+
109
+ describe("workspace init --from backstage (#3178)", () => {
110
+ const dirs: string[] = [];
111
+ afterEach(() => dirs.splice(0).forEach((d) => rmSync(d, { recursive: true, force: true })));
112
+ function catalog(files: Record<string, string>): string {
113
+ const root = mkdtempSync(join(tmpdir(), "chant-backstage-"));
114
+ dirs.push(root);
115
+ for (const [f, text] of Object.entries(files)) {
116
+ mkdirSync(dirname(join(root, f)), { recursive: true });
117
+ writeFileSync(join(root, f), text);
118
+ }
119
+ return root;
120
+ }
121
+
122
+ test("components become members, dependsOn and consumesApis become links, lifecycle becomes an environment", () => {
123
+ const root = catalog({
124
+ "api/catalog-info.yaml": "apiVersion: backstage.io/v1alpha1\nkind: Component\nmetadata:\n name: api\nspec:\n lifecycle: production\n dependsOn: [resource:default/db]\n consumesApis: [billing-api]\n---\napiVersion: backstage.io/v1alpha1\nkind: Group\nmetadata:\n name: team\n",
125
+ "db/catalog-info.yaml": "apiVersion: backstage.io/v1alpha1\nkind: Resource\nmetadata:\n name: db\nspec:\n lifecycle: production\n",
126
+ "billing/catalog-info.yml": "apiVersion: backstage.io/v1alpha1\nkind: Component\nmetadata:\n name: billing\nspec:\n lifecycle: experimental\n providesApis: [billing-api]\n",
127
+ });
128
+ const p = proposeFromBackstage(root, { name: "shop" });
129
+ expect(p.declaration.name).toBe("shop");
130
+ expect(p.declaration.members.map((m) => [m.name, m.kind, m.dir])).toEqual([
131
+ ["api", "other", "api"],
132
+ ["billing", "other", "billing"],
133
+ ["db", "other", "db"],
134
+ ]);
135
+ expect(p.declaration.members[0].links).toEqual([
136
+ { member: "db", output: "catalog" },
137
+ { member: "billing", output: "catalog" },
138
+ ]);
139
+ expect(p.environments.map((e) => e.name)).toEqual(["experimental", "production"]);
140
+ expect(p.leftOut.map((l) => l.what)).toContain("Group");
141
+ });
142
+ });
143
+
144
+ describe("the command writes nothing without confirmation (#3178)", () => {
145
+ const dirs: string[] = [];
146
+ afterEach(() => {
147
+ dirs.splice(0).forEach((d) => rmSync(d, { recursive: true, force: true }));
148
+ vi.restoreAllMocks();
149
+ });
150
+ const run = async (extra: string[], deps: Parameters<typeof runImportInit>[3] = {}) => {
151
+ const root = mkdtempSync(join(tmpdir(), "chant-import-"));
152
+ dirs.push(root);
153
+ vi.spyOn(console, "log").mockImplementation(() => {});
154
+ const args = parseArgs(["workspace", "init", root, "--from", "tfc", "--input", join(FIX, "tfc.json"), ...extra]);
155
+ const code = await runImportInit("tfc", root, args, { fetcher: noNetwork as never, ...deps });
156
+ return { code, root };
157
+ };
158
+
159
+ test("no terminal and no --yes: nothing written", async () => {
160
+ const { code, root } = await run([], { isTTY: false });
161
+ expect(code).toBe(0);
162
+ expect(existsSync(join(root, "chant.workspace.json"))).toBe(false);
163
+ });
164
+
165
+ test("declining writes nothing; --yes writes the declaration and the sidecar", async () => {
166
+ const declined = await run([], { isTTY: true, confirm: async () => false });
167
+ expect(existsSync(join(declined.root, "chant.workspace.json"))).toBe(false);
168
+ const { code, root } = await run(["--yes"]);
169
+ expect(code).toBe(0);
170
+ expect(JSON.parse(readFileSync(join(root, "chant.workspace.json"), "utf-8")).members).toHaveLength(2);
171
+ const side = JSON.parse(readFileSync(join(root, "chant-import.tfc.json"), "utf-8"));
172
+ expect(side.records).toHaveLength(2);
173
+ expect(noNetwork).not.toHaveBeenCalled();
174
+ });
175
+
176
+ test("without --input or a token it says what is missing and reaches nothing", async () => {
177
+ const err = vi.spyOn(console, "error").mockImplementation(() => {});
178
+ const args = parseArgs(["workspace", "init", "--from", "tfc", "--org", "acme"]);
179
+ expect(await runImportInit("tfc", tmpdir(), args, { env: {}, fetcher: noNetwork as never })).toBe(1);
180
+ expect(err.mock.calls.join(" ")).toContain("TFE_TOKEN");
181
+ expect(noNetwork).not.toHaveBeenCalled();
182
+ });
183
+ });