nexarch 0.12.6 → 0.12.8

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.
@@ -0,0 +1,319 @@
1
+ import { readFileSync, existsSync, readdirSync } from "fs";
2
+ import { execFileSync } from "child_process";
3
+ import { resolve } from "path";
4
+ import { callMcpTool } from "../lib/mcp.js";
5
+ import { parseTerraformStateBuffer, projectTerraformState, inferEnvironmentFromTags, environmentSubtypeFor, } from "../lib/terraform-projection.js";
6
+ import { TERRAFORM_CATALOGUE_SEED } from "../lib/terraform-catalogue-seed.js";
7
+ import { discoverRootModules, environmentFromPath } from "../lib/terraform-detect.js";
8
+ const ENTITY_BATCH = 50;
9
+ function readResult(raw) {
10
+ return JSON.parse(raw.content?.[0]?.text ?? "{}");
11
+ }
12
+ function slug(value) {
13
+ return value
14
+ .trim()
15
+ .toLowerCase()
16
+ .replace(/[^a-z0-9]+/g, "_")
17
+ .replace(/^_+|_+$/g, "")
18
+ .slice(0, 80);
19
+ }
20
+ function parseArgs(args) {
21
+ const get = (flag) => {
22
+ const index = args.indexOf(flag);
23
+ return index >= 0 && args[index + 1] ? args[index + 1] : null;
24
+ };
25
+ return {
26
+ statePath: get("--state"),
27
+ environmentOverride: get("--environment"),
28
+ dir: get("--dir") ?? process.cwd(),
29
+ projectRef: get("--project-ref"),
30
+ dryRun: args.includes("--dry-run"),
31
+ asJson: args.includes("--json"),
32
+ };
33
+ }
34
+ /**
35
+ * Chooses which root module to read.
36
+ *
37
+ * A repository routinely holds a root module per environment plus separate
38
+ * stacks for shared services, so the working directory is frequently not a root
39
+ * module at all. When it isn't, discovery decides: one candidate is used, and
40
+ * several are reported with the exact command for each — the caller is usually
41
+ * an agent, and an agent can act on a list of commands but not on a request to
42
+ * choose.
43
+ */
44
+ function resolveRootModule(dir) {
45
+ const hasLocalTerraform = existsSync(dir) && readdirSync(dir).some((entry) => entry.endsWith(".tf"));
46
+ if (hasLocalTerraform)
47
+ return { dir, note: null };
48
+ const roots = discoverRootModules(dir);
49
+ if (roots.length === 1) {
50
+ return { dir: roots[0].dir, note: `no Terraform in the working directory; using the only root module found, ${roots[0].relative}` };
51
+ }
52
+ if (roots.length > 1) {
53
+ // Annotations go on their own line: the caller is usually an agent, and a
54
+ // command line with a parenthetical glued to it is not runnable as copied.
55
+ const lines = roots.flatMap((root) => {
56
+ const notes = [
57
+ root.environmentHint ? `${root.environmentHint} environment` : "shared stack",
58
+ root.initialised ? "initialised" : "run `terraform init` here first",
59
+ ].join(", ");
60
+ return [` # ${root.relative} — ${notes}`, ` nexarch ingest-infra --dir ${root.relative}`];
61
+ });
62
+ throw new Error([
63
+ `This repository has ${roots.length} root modules, each with its own state.`,
64
+ ` Every root module is a separate estate, so ingest them one at a time:`,
65
+ ...lines,
66
+ ].join("\n"));
67
+ }
68
+ throw new Error([
69
+ `No Terraform root module found at or below ${dir}.`,
70
+ ` Run this inside the repository that provisions the estate, or pass --dir <path> or --state <file>.`,
71
+ ].join("\n"));
72
+ }
73
+ /** Reads state from a file, or asks Terraform for it when running inside the repo. */
74
+ function loadState(statePath, dir) {
75
+ if (statePath) {
76
+ const full = resolve(statePath);
77
+ if (!existsSync(full))
78
+ throw new Error(`State file not found: ${full}`);
79
+ return { document: parseTerraformStateBuffer(readFileSync(full)), source: full };
80
+ }
81
+ try {
82
+ const stdout = execFileSync("terraform", ["show", "-json"], {
83
+ cwd: dir,
84
+ maxBuffer: 256 * 1024 * 1024,
85
+ encoding: "buffer",
86
+ stdio: ["ignore", "pipe", "pipe"],
87
+ });
88
+ return { document: parseTerraformStateBuffer(stdout), source: "terraform show -json" };
89
+ }
90
+ catch (error) {
91
+ const detail = error instanceof Error ? error.message : String(error);
92
+ throw new Error(`Could not read Terraform state. Run inside the infrastructure repo with terraform available, or pass --state <file>.\n ${detail.split("\n")[0]}`);
93
+ }
94
+ }
95
+ function resolveEnvironment(projection, override, dir, rootModuleDir) {
96
+ if (override)
97
+ return { environment: override.trim().toLowerCase(), how: "--environment flag" };
98
+ // A root module living in environments/prod states which estate is being read
99
+ // more plainly than anything inside the state, and it is available whether or
100
+ // not anyone remembered to tag.
101
+ const fromPath = rootModuleDir ? environmentFromPath(rootModuleDir) : null;
102
+ if (fromPath)
103
+ return { environment: fromPath, how: "root module path" };
104
+ const inferred = inferEnvironmentFromTags(projection.resources);
105
+ if (inferred && inferred.confidence >= 0.6) {
106
+ return { environment: inferred.environment, how: `inferred from resource tags (${Math.round(inferred.confidence * 100)}% agreement)` };
107
+ }
108
+ try {
109
+ const workspace = execFileSync("terraform", ["workspace", "show"], { cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
110
+ if (workspace && workspace !== "default")
111
+ return { environment: workspace.toLowerCase(), how: "terraform workspace" };
112
+ }
113
+ catch {
114
+ // Terraform not available; fall through to the error below.
115
+ }
116
+ // Say what was actually seen. A pipeline failure that only restates the
117
+ // options costs an hour; one that reports the evidence costs a minute.
118
+ const tagged = projection.resources.filter((r) => Object.keys(r.tags).length > 0).length;
119
+ const detail = [
120
+ `read ${projection.stats.resourcesSeen} resources from state`,
121
+ `${projection.stats.resourcesProjected} matched the catalogue`,
122
+ `${tagged} of those carried any tags`,
123
+ ];
124
+ if (inferred)
125
+ detail.push(`environment tags disagreed (strongest: "${inferred.environment}" at ${Math.round(inferred.confidence * 100)}%)`);
126
+ else if (projection.stats.resourcesProjected > 0)
127
+ detail.push("none carried an environment or env tag");
128
+ throw new Error(`Could not determine the environment — ${detail.join("; ")}.\n` +
129
+ ` Fix by tagging resources with \`environment\`, selecting a named Terraform workspace, or passing --environment <name>.`);
130
+ }
131
+ /** Builds the entity and relationship payloads for the existing upsert contract. */
132
+ export function buildIngestPayload(params) {
133
+ const { projection, environment, platformRef, platformName } = params;
134
+ const environmentRef = `environment:${slug(environment)}`;
135
+ const entities = [
136
+ {
137
+ entityRef: platformRef,
138
+ entityTypeCode: "platform",
139
+ name: platformName,
140
+ description: `Cloud platform hosting the ${environment} environment.`,
141
+ attributes: { source: "terraform_ingest" },
142
+ },
143
+ {
144
+ entityRef: environmentRef,
145
+ entityTypeCode: "environment",
146
+ entitySubtypeCode: environmentSubtypeFor(environment),
147
+ name: environment,
148
+ description: `The ${environment} environment, as provisioned by Terraform.`,
149
+ attributes: { source: "terraform_ingest", terraform_version: projection.terraformVersion },
150
+ },
151
+ ];
152
+ const relationships = [];
153
+ for (const resource of projection.resources) {
154
+ const entityRef = `${resource.entityTypeCode}:${slug(resource.name ?? resource.address)}`;
155
+ entities.push({
156
+ entityRef,
157
+ entityTypeCode: resource.entityTypeCode,
158
+ ...(resource.entitySubtypeCode ? { entitySubtypeCode: resource.entitySubtypeCode } : {}),
159
+ name: resource.name ?? resource.address,
160
+ description: `${resource.role.replace(/_/g, " ")} provisioned by Terraform (${resource.resourceType}) in ${environment}.`,
161
+ attributes: {
162
+ source: "terraform_ingest",
163
+ terraform_address: resource.address,
164
+ terraform_type: resource.resourceType,
165
+ terraform_mode: resource.mode,
166
+ infrastructure_role: resource.role,
167
+ environment,
168
+ ...resource.attributes,
169
+ ...(Object.keys(resource.tags).length > 0 ? { tags: resource.tags } : {}),
170
+ ...(resource.identityIds.length > 0 ? { identity_ids: resource.identityIds } : {}),
171
+ },
172
+ });
173
+ // Only the directions the ontology permits: components deploy to an
174
+ // environment and belong to a platform; data stores run on both.
175
+ if (resource.entityTypeCode === "platform_component") {
176
+ relationships.push({ relationshipTypeCode: "deployed_to", fromEntityRef: entityRef, toEntityRef: environmentRef });
177
+ relationships.push({ relationshipTypeCode: "part_of", fromEntityRef: entityRef, toEntityRef: platformRef });
178
+ }
179
+ else if (resource.entityTypeCode === "data_store") {
180
+ relationships.push({ relationshipTypeCode: "runs_on", fromEntityRef: entityRef, toEntityRef: environmentRef });
181
+ relationships.push({ relationshipTypeCode: "runs_on", fromEntityRef: entityRef, toEntityRef: platformRef });
182
+ }
183
+ }
184
+ return { entities, relationships, environmentRef };
185
+ }
186
+ function chunk(items, size) {
187
+ const out = [];
188
+ for (let i = 0; i < items.length; i += size)
189
+ out.push(items.slice(i, i + size));
190
+ return out;
191
+ }
192
+ export async function ingestInfra(args) {
193
+ const { statePath, environmentOverride, dir, projectRef, dryRun, asJson } = parseArgs(args);
194
+ // Only resolve a root module when Terraform must be run; an explicit --state
195
+ // file is authoritative and needs no repository at all.
196
+ let workingDir = dir;
197
+ if (!statePath) {
198
+ const resolved = resolveRootModule(dir);
199
+ workingDir = resolved.dir;
200
+ if (resolved.note && !asJson)
201
+ console.log(` ${resolved.note}`);
202
+ }
203
+ const { document, source } = loadState(statePath, workingDir);
204
+ const projection = projectTerraformState({ document, catalogue: TERRAFORM_CATALOGUE_SEED, environment: "unknown" });
205
+ // Terraform emits a valid but empty document when it cannot see state, which
206
+ // is a different problem from anything downstream and deserves its own
207
+ // message rather than surfacing later as a confusing tagging complaint.
208
+ if (projection.stats.resourcesSeen === 0) {
209
+ throw new Error(`No resources found in Terraform state (read from ${source}).\n` +
210
+ ` Terraform ran but reported an empty state. Establish which, in this order:\n` +
211
+ ` 1. run \`terraform state list\` in ${workingDir} — empty output confirms there is no state here\n` +
212
+ ` 2. if there is no .terraform directory, run \`terraform init\`, then retry\n` +
213
+ ` 3. run \`terraform workspace list\` and select the one that was applied\n` +
214
+ ` 4. if this repository has several root modules, ingest each separately with --dir\n` +
215
+ ` The estate may also simply not have been applied yet.`);
216
+ }
217
+ const { environment, how } = resolveEnvironment(projection, environmentOverride, workingDir, statePath ? null : workingDir);
218
+ projection.environment = environment;
219
+ // Azure is the only provider the seed catalogue covers today; resolving it
220
+ // through the reference library keeps the platform entity canonical rather
221
+ // than inventing a second "Azure" for every tenant.
222
+ let platformRef = "platform:azure";
223
+ let platformName = "Microsoft Azure";
224
+ if (!dryRun) {
225
+ try {
226
+ const raw = await callMcpTool("nexarch_resolve_reference", { names: ["azure"] });
227
+ const resolved = readResult(raw);
228
+ const hit = (resolved.results ?? []).find((r) => r.resolved && r.entityTypeCode === "platform" && r.canonicalExternalRef);
229
+ if (hit?.canonicalExternalRef) {
230
+ platformRef = hit.canonicalExternalRef;
231
+ platformName = hit.canonicalName ?? platformName;
232
+ }
233
+ }
234
+ catch {
235
+ // Reference resolution is an optimisation, not a requirement.
236
+ }
237
+ }
238
+ const { entities, relationships } = buildIngestPayload({ projection, environment, platformRef, platformName });
239
+ const report = {
240
+ ok: true,
241
+ source,
242
+ environment,
243
+ environmentSource: how,
244
+ terraformVersion: projection.terraformVersion,
245
+ resourcesSeen: projection.stats.resourcesSeen,
246
+ resourcesProjected: projection.stats.resourcesProjected,
247
+ attributesDropped: {
248
+ notAllowlisted: projection.stats.attributesDroppedNotAllowlisted,
249
+ flaggedSensitive: projection.stats.attributesDroppedSensitive,
250
+ },
251
+ entities: entities.length,
252
+ relationships: relationships.length,
253
+ unknownTypes: projection.unknownTypes,
254
+ dryRun,
255
+ projectRef: projectRef ?? null,
256
+ written: null,
257
+ };
258
+ if (dryRun) {
259
+ if (asJson)
260
+ process.stdout.write(`${JSON.stringify({ ...report, payload: { entities, relationships } }, null, 2)}\n`);
261
+ else
262
+ printHuman(report, entities.length, relationships.length);
263
+ return;
264
+ }
265
+ const policiesRaw = await callMcpTool("nexarch_get_applied_policies", {});
266
+ const policyBundleHash = readResult(policiesRaw).policyBundleHash ?? null;
267
+ if (!policyBundleHash)
268
+ throw new Error("Policy bootstrap is missing for this workspace. Complete workspace setup before ingesting infrastructure.");
269
+ const agentContext = {
270
+ agentId: "nexarch-cli:ingest-infra",
271
+ agentRunId: `ingest-infra-${Date.now()}`,
272
+ repoRef: projectRef ?? dir,
273
+ repoPath: dir,
274
+ observedAt: new Date().toISOString(),
275
+ source: "nexarch-cli",
276
+ model: "n/a",
277
+ provider: "n/a",
278
+ };
279
+ const policyContext = { policyBundleHash, alignmentSummary: { score: 1, violations: [], waivers: [] } };
280
+ let failed = 0;
281
+ for (const batch of chunk(entities, ENTITY_BATCH)) {
282
+ const raw = await callMcpTool("nexarch_upsert_entities", { entities: batch, agentContext, policyContext });
283
+ failed += (readResult(raw).summary?.failed ?? 0);
284
+ }
285
+ for (const batch of chunk(relationships, ENTITY_BATCH)) {
286
+ const raw = await callMcpTool("nexarch_upsert_relationships", { relationships: batch, agentContext, policyContext });
287
+ failed += (readResult(raw).summary?.failed ?? 0);
288
+ }
289
+ report.written = { entities: entities.length, relationships: relationships.length, failed };
290
+ if (asJson)
291
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
292
+ else
293
+ printHuman(report, entities.length, relationships.length);
294
+ }
295
+ function printHuman(report, entityCount, relationshipCount) {
296
+ const dropped = report.attributesDropped;
297
+ console.log(`\nInfrastructure ingest — ${report.environment} (${report.environmentSource})`);
298
+ console.log(` Source: ${report.source}${report.terraformVersion ? ` · terraform ${report.terraformVersion}` : ""}`);
299
+ console.log(` Projected ${report.resourcesProjected} of ${report.resourcesSeen} resources to the contract surface.`);
300
+ console.log(` Dropped ${dropped.notAllowlisted} attributes not on the allowlist, ${dropped.flaggedSensitive} flagged sensitive by Terraform.`);
301
+ const unknown = report.unknownTypes;
302
+ if (unknown.length > 0) {
303
+ console.log(`\n Not yet in the catalogue (reported so coverage can grow):`);
304
+ for (const item of unknown.slice(0, 8))
305
+ console.log(` ${String(item.count).padStart(3)} ${item.resourceType}`);
306
+ if (unknown.length > 8)
307
+ console.log(` …and ${unknown.length - 8} more types`);
308
+ }
309
+ if (report.dryRun) {
310
+ console.log(`\n Dry run — nothing was written. Would send ${entityCount} entities and ${relationshipCount} relationships.`);
311
+ return;
312
+ }
313
+ const written = report.written;
314
+ if (written) {
315
+ console.log(`\n Wrote ${written.entities} entities and ${written.relationships} relationships.`);
316
+ if (written.failed > 0)
317
+ console.log(` ${written.failed} were refused — see the workspace dashboard for what governance blocked.`);
318
+ }
319
+ }
@@ -0,0 +1,170 @@
1
+ import { basename } from "path";
2
+ import { createInterface } from "readline";
3
+ import { callMcpTool } from "../lib/mcp.js";
4
+ import { detectInfrastructureProject, ciSnippetFor } from "../lib/terraform-detect.js";
5
+ import { environmentSubtypeFor } from "../lib/terraform-projection.js";
6
+ import { ingestInfra } from "./ingest-infra.js";
7
+ function readResult(raw) {
8
+ return JSON.parse(raw.content?.[0]?.text ?? "{}");
9
+ }
10
+ function slugify(value) {
11
+ return value
12
+ .trim()
13
+ .toLowerCase()
14
+ .replace(/[^a-z0-9]+/g, "_")
15
+ .replace(/^_+|_+$/g, "")
16
+ .slice(0, 80);
17
+ }
18
+ async function confirm(question) {
19
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
20
+ try {
21
+ const answer = await new Promise((resolveAnswer) => rl.question(`${question} [Y/n] `, resolveAnswer));
22
+ return !/^n(o)?$/i.test(answer.trim());
23
+ }
24
+ finally {
25
+ rl.close();
26
+ }
27
+ }
28
+ /**
29
+ * Shows what a real run would write.
30
+ *
31
+ * A preview that prints "✓ Registered" is worse than no preview: the caller
32
+ * reads a completed action and moves on, when the whole point was to decide
33
+ * whether to take it. Everything here is phrased as intent, and no MCP call is
34
+ * made — not even the policy read, since a dry run should be inert.
35
+ */
36
+ function reportDryRun(params) {
37
+ const { asJson, projectRef, detection, entities, relationships } = params;
38
+ if (asJson) {
39
+ process.stdout.write(`${JSON.stringify({ ok: true, dryRun: true, wrote: false, projectRef, detection, plan: { entities, relationships } }, null, 2)}\n`);
40
+ return;
41
+ }
42
+ console.log(`\nWould write ${entities.length} entit${entities.length === 1 ? "y" : "ies"}:`);
43
+ for (const entity of entities)
44
+ console.log(` ${entity.entityRef} (${entity.entityTypeCode} / ${entity.entitySubtypeCode})`);
45
+ if (relationships.length > 0) {
46
+ console.log(`\nWould write ${relationships.length} relationship${relationships.length === 1 ? "" : "s"}:`);
47
+ for (const rel of relationships)
48
+ console.log(` ${rel.fromEntityRef} --${rel.relationshipTypeCode}--> ${rel.toEntityRef}`);
49
+ }
50
+ console.log(`\nNothing was written. Re-run without --dry-run to register.`);
51
+ }
52
+ export async function runInfrastructureOnboarding(options, detection) {
53
+ const { dir, nameOverride, asJson, nonInteractive, skipIngest, dryRun } = options;
54
+ const displayName = nameOverride ?? basename(dir);
55
+ const projectRef = `project:${slugify(displayName)}`;
56
+ if (!asJson) {
57
+ console.log(`\nDetected an infrastructure repository — ${detection.signals.join(", ")}.`);
58
+ console.log(dryRun
59
+ ? `Dry run — nothing below is written. ${displayName} would be registered as ${projectRef} (project_infrastructure).`
60
+ : `Registering ${displayName} as ${projectRef} (project_infrastructure).`);
61
+ console.log("The dependency scan is skipped: this repo provisions infrastructure rather than consuming it.");
62
+ }
63
+ // Every environment the repository defines is sourced from it. Recording this
64
+ // at registration is what stops the project being an orphan: without it the
65
+ // graph holds a node that provisions nothing, and no reader can get from an
66
+ // environment back to the code that defines it. The root module paths carry
67
+ // this before any state is read, so it costs no Terraform run.
68
+ const environments = [...new Set(detection.rootModules.map((root) => root.environmentHint).filter((hint) => Boolean(hint)))];
69
+ const entities = [
70
+ {
71
+ entityRef: projectRef,
72
+ entityTypeCode: "project",
73
+ entitySubtypeCode: "project_infrastructure",
74
+ name: displayName,
75
+ description: `Infrastructure-as-code repository provisioning the estate (${detection.signals.join(", ")}).`,
76
+ attributes: {
77
+ source: "nexarch_cli_init_project",
78
+ iac_tool: "terraform",
79
+ terraform_file_count: detection.terraformFileCount,
80
+ has_remote_backend: detection.hasRemoteBackend,
81
+ root_module_count: detection.rootModules.length,
82
+ ...(detection.ciSystem ? { ci_system: detection.ciSystem } : {}),
83
+ ...(detection.moduleDirectories.length > 0 ? { terraform_modules: detection.moduleDirectories.slice(0, 25) } : {}),
84
+ },
85
+ },
86
+ ...environments.map((environment) => ({
87
+ entityRef: `environment:${environment}`,
88
+ entityTypeCode: "environment",
89
+ entitySubtypeCode: environmentSubtypeFor(environment),
90
+ name: environment,
91
+ description: `Environment defined by the ${displayName} infrastructure repository.`,
92
+ attributes: { source: "nexarch_cli_init_project", iac_tool: "terraform" },
93
+ })),
94
+ ];
95
+ const relationships = environments.map((environment) => ({
96
+ fromEntityRef: `environment:${environment}`,
97
+ toEntityRef: projectRef,
98
+ relationshipTypeCode: "sourced_from",
99
+ attributes: { source: "nexarch_cli_init_project" },
100
+ }));
101
+ if (dryRun) {
102
+ reportDryRun({ asJson, projectRef, detection, entities, relationships });
103
+ return;
104
+ }
105
+ const policiesRaw = await callMcpTool("nexarch_get_applied_policies", {});
106
+ const policyBundleHash = readResult(policiesRaw).policyBundleHash ?? null;
107
+ if (!policyBundleHash) {
108
+ throw new Error("Policy bootstrap is missing for this workspace. Complete workspace setup before registering a project.");
109
+ }
110
+ const agentContext = {
111
+ agentId: "nexarch-cli:init-project",
112
+ agentRunId: `init-project-infra-${Date.now()}`,
113
+ repoRef: dir,
114
+ repoPath: dir,
115
+ observedAt: new Date().toISOString(),
116
+ source: "nexarch-cli",
117
+ model: "n/a",
118
+ provider: "n/a",
119
+ };
120
+ const policyContext = { policyBundleHash, alignmentSummary: { score: 1, violations: [], waivers: [] } };
121
+ await callMcpTool("nexarch_upsert_entities", { entities, agentContext, policyContext });
122
+ if (relationships.length > 0) {
123
+ await callMcpTool("nexarch_upsert_relationships", { relationships, agentContext, policyContext });
124
+ }
125
+ if (!asJson) {
126
+ console.log(` ✓ Registered ${projectRef}`);
127
+ for (const environment of environments) {
128
+ console.log(` ✓ Registered environment:${environment} (sourced_from ${projectRef})`);
129
+ }
130
+ }
131
+ // Each root module owns its own state, so each is a separate ingest. Showing
132
+ // the map up front — rather than letting the caller discover it through a
133
+ // failure — is what lets an agent finish the job unaided.
134
+ const roots = detection.rootModules;
135
+ if (!asJson && roots.length > 1) {
136
+ console.log(`\nThis repository has ${roots.length} root modules, each with its own state.`);
137
+ console.log("Ingest them one at a time — every root module is a separate estate:\n");
138
+ for (const root of roots) {
139
+ const notes = [root.environmentHint ? `${root.environmentHint} environment` : "shared stack", root.initialised ? "initialised" : "run `terraform init` here first"].join(", ");
140
+ console.log(` # ${root.relative} — ${notes}`);
141
+ console.log(` nexarch ingest-infra --dir ${root.relative} --project-ref ${projectRef}`);
142
+ }
143
+ }
144
+ let ingested = false;
145
+ if (!skipIngest && roots.length <= 1) {
146
+ const shouldIngest = nonInteractive ? false : await confirm("\nRead the current state and populate the graph now?");
147
+ if (shouldIngest) {
148
+ try {
149
+ await ingestInfra(["--dir", dir, "--project-ref", projectRef]);
150
+ ingested = true;
151
+ }
152
+ catch (error) {
153
+ const message = error instanceof Error ? error.message : String(error);
154
+ console.log(`\n Could not ingest now — ${message}`);
155
+ console.log(" This is not a problem: the pipeline step below is what keeps the graph true.");
156
+ }
157
+ }
158
+ }
159
+ const { file, snippet } = ciSnippetFor(detection.ciSystem, detection.ciFile);
160
+ if (asJson) {
161
+ process.stdout.write(`${JSON.stringify({ ok: true, projectRef, detection, ingested, ci: { file, snippet } }, null, 2)}\n`);
162
+ return;
163
+ }
164
+ console.log(`\nInfrastructure changes continuously, so ingestion belongs in the pipeline, not in someone's memory.`);
165
+ console.log(`Add this to ${file}:\n`);
166
+ console.log(snippet);
167
+ console.log(`\nNEXARCH_TOKEN is a service credential — create one in the workspace under`);
168
+ console.log(`Discovery → Agents, then store it as a masked CI variable.`);
169
+ }
170
+ export { detectInfrastructureProject };
@@ -5,6 +5,8 @@ import { basename, join, relative, resolve as resolvePath } from "node:path";
5
5
  import { requireCredentials } from "../lib/credentials.js";
6
6
  import { callMcpTool } from "../lib/mcp.js";
7
7
  import { buildVersionAttributes } from "../lib/version-normalization.js";
8
+ import { detectInfrastructureProject } from "../lib/terraform-detect.js";
9
+ import { runInfrastructureOnboarding } from "./init-project-infra.js";
8
10
  // ─── Helpers ─────────────────────────────────────────────────────────────────
9
11
  function parseFlag(args, flag) {
10
12
  return args.includes(flag);
@@ -1115,6 +1117,23 @@ export async function initProject(args) {
1115
1117
  const refreshMode = parseFlag(args, "--refresh");
1116
1118
  const creds = requireCredentials();
1117
1119
  const mcpOpts = { companyId: creds.companyId };
1120
+ // An infrastructure repository is onboarded differently: its content lives in
1121
+ // Terraform state, not in a manifest, so the dependency scan below would
1122
+ // describe the wrong thing entirely (ADR: infrastructure-as-code ingestion).
1123
+ if (!parseFlag(args, "--as-application")) {
1124
+ const infrastructure = detectInfrastructureProject(dir);
1125
+ if (infrastructure.isInfrastructure) {
1126
+ await runInfrastructureOnboarding({
1127
+ dir,
1128
+ nameOverride,
1129
+ asJson,
1130
+ nonInteractive: parseFlag(args, "--non-interactive"),
1131
+ skipIngest: parseFlag(args, "--no-ingest"),
1132
+ dryRun,
1133
+ }, infrastructure);
1134
+ return;
1135
+ }
1136
+ }
1118
1137
  if (!asJson)
1119
1138
  console.log(`Scanning ${dir}…`);
1120
1139
  logProgress("scan.start", dir);
@@ -1,9 +1,93 @@
1
+ import { createInterface } from "readline";
1
2
  import { requireCredentials } from "../lib/credentials.js";
2
3
  import { detectClientsFromRegistry, writeClientConfig, nexarchServerBlockFromRegistry } from "../lib/clients.js";
3
4
  import { fetchAgentRegistryOrThrow } from "../lib/agent-registry.js";
4
5
  import { initAgent } from "./init-agent.js";
5
6
  import { installClaudeCodeSkill } from "../lib/skills.js";
6
7
  import { login } from "./login.js";
8
+ function argValue(args, flag) {
9
+ const idx = args.indexOf(flag);
10
+ if (idx === -1)
11
+ return null;
12
+ const value = args[idx + 1];
13
+ return !value || value.startsWith("--") ? null : value;
14
+ }
15
+ /** Drops a flag and the value that belongs to it, so neither is left orphaned. */
16
+ function withoutFlag(args, flag) {
17
+ const idx = args.indexOf(flag);
18
+ if (idx === -1)
19
+ return args;
20
+ const takesValue = args[idx + 1] && !args[idx + 1].startsWith("--");
21
+ return [...args.slice(0, idx), ...args.slice(idx + (takesValue ? 2 : 1))];
22
+ }
23
+ /**
24
+ * Builds the argument list for a login triggered from setup.
25
+ *
26
+ * `login` speaks only `--company`, so `--workspace` is translated rather than
27
+ * passed through to be silently dropped by the flag it does not recognise.
28
+ */
29
+ function loginArgs(args, company) {
30
+ const base = withoutFlag(withoutFlag(args, "--company"), "--workspace");
31
+ return company ? [...base, "--company", company] : base;
32
+ }
33
+ async function askYesNo(question) {
34
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
35
+ try {
36
+ const answer = await new Promise((resolve) => rl.question(`${question} [Y/n] `, resolve));
37
+ return !/^n(o)?$/i.test(answer.trim());
38
+ }
39
+ finally {
40
+ rl.close();
41
+ }
42
+ }
43
+ /**
44
+ * Settles which workspace setup is about to write to, out loud.
45
+ *
46
+ * Setup reuses a stored session when one exists, which is right — nobody wants
47
+ * to re-authenticate to reconfigure a client. But the workspace that session is
48
+ * scoped to was chosen at login, possibly weeks ago and in another repository,
49
+ * and setup never said which one it was. Running it in a new repo after
50
+ * creating a new workspace therefore succeeded loudly and wired everything to
51
+ * the old workspace: fourteen green ticks, not one of them naming the target.
52
+ *
53
+ * The browser consent screen already has a workspace picker for accounts with
54
+ * more than one membership, so switching is a matter of routing back through
55
+ * login rather than building a second picker here. The CLI deliberately does
56
+ * not enumerate workspaces itself: its token is scoped to one company, and a
57
+ * token for company A should not be able to list company B.
58
+ */
59
+ async function confirmWorkspace(args, credentials) {
60
+ const requested = argValue(args, "--company") ?? argValue(args, "--workspace");
61
+ const current = credentials.company || credentials.companyCode || credentials.companyId;
62
+ const matchesRequest = !requested ||
63
+ requested === credentials.companyId ||
64
+ requested.toLowerCase() === (credentials.companyCode ?? "").toLowerCase() ||
65
+ requested.toLowerCase() === (credentials.company ?? "").toLowerCase();
66
+ // An explicit --company that the stored session does not satisfy must never
67
+ // be quietly ignored: the caller stated a target, and setup writes files.
68
+ if (!matchesRequest) {
69
+ console.log(`Stored session is for workspace "${current}", but --company ${requested} was requested.`);
70
+ console.log("Re-authenticating to switch workspace…\n");
71
+ await login(loginArgs(args, requested));
72
+ return requireCredentials();
73
+ }
74
+ console.log(`\nWorkspace: ${current}`);
75
+ const nonInteractive = args.includes("--non-interactive") || args.includes("--yes") || !process.stdin.isTTY;
76
+ if (nonInteractive) {
77
+ console.log("Everything below is registered here. Pass --company <id> to target a different workspace.\n");
78
+ return credentials;
79
+ }
80
+ console.log("Everything below — MCP client config, agent registration, instruction files — is registered here.");
81
+ if (await askYesNo("Continue with this workspace?"))
82
+ return credentials;
83
+ // No company is forwarded: the browser consent screen lists every workspace
84
+ // the account belongs to, which is the point of coming back here.
85
+ console.log("\nSwitching workspace — choose one in the browser…\n");
86
+ await login(loginArgs(args, null));
87
+ const switched = requireCredentials();
88
+ console.log(`\nWorkspace: ${switched.company || switched.companyId}\n`);
89
+ return switched;
90
+ }
7
91
  function instructionRuntimeCodeForClient(code) {
8
92
  switch (code) {
9
93
  case "continue-dev":
@@ -27,7 +111,7 @@ export async function setup(args) {
27
111
  console.log("No active Nexarch session found. Starting login flow…\n");
28
112
  await login(args);
29
113
  }
30
- requireCredentials(); // ensure login succeeded before continuing
114
+ await confirmWorkspace(args, requireCredentials()); // ensure login succeeded, and name the target
31
115
  let registry;
32
116
  try {
33
117
  registry = await fetchAgentRegistryOrThrow();