nexarch 0.12.5 → 0.12.7

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 };
@@ -1,11 +1,12 @@
1
1
  import process from "process";
2
- import * as readline from "node:readline/promises";
3
2
  import { execFileSync } from "node:child_process";
4
3
  import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
5
4
  import { basename, join, relative, resolve as resolvePath } from "node:path";
6
5
  import { requireCredentials } from "../lib/credentials.js";
7
6
  import { callMcpTool } from "../lib/mcp.js";
8
7
  import { buildVersionAttributes } from "../lib/version-normalization.js";
8
+ import { detectInfrastructureProject } from "../lib/terraform-detect.js";
9
+ import { runInfrastructureOnboarding } from "./init-project-infra.js";
9
10
  // ─── Helpers ─────────────────────────────────────────────────────────────────
10
11
  function parseFlag(args, flag) {
11
12
  return args.includes(flag);
@@ -1069,43 +1070,6 @@ export function scoreApplicationCandidate(app, projectName, repoUrl) {
1069
1070
  return null;
1070
1071
  return { entityRef, name: app.name, score: Math.min(1, score), reasons };
1071
1072
  }
1072
- async function promptApplicationChoice(matches, allApps, suggested) {
1073
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1074
- try {
1075
- console.log("\nExisting application entities found.");
1076
- if (suggested) {
1077
- console.log(`Suggested match: ${suggested.name} (${suggested.entityRef}) score=${suggested.score.toFixed(2)} [${suggested.reasons.join(", ")}]`);
1078
- }
1079
- console.log("\nChoose target application:");
1080
- const options = [];
1081
- let index = 1;
1082
- for (const m of matches.slice(0, 5)) {
1083
- options.push({
1084
- key: String(index++),
1085
- label: `${m.name} (${m.entityRef}) score=${m.score.toFixed(2)}`,
1086
- value: m.entityRef,
1087
- });
1088
- }
1089
- options.push({ key: String(index++), label: "Show all applications", value: "__show_all__" });
1090
- options.push({ key: String(index), label: "Create a new application", value: "__create__" });
1091
- for (const o of options)
1092
- console.log(` ${o.key}) ${o.label}`);
1093
- const answer = (await rl.question("Select option: ")).trim();
1094
- const chosen = options.find((o) => o.key === answer)?.value;
1095
- if (chosen === "__show_all__") {
1096
- console.log("\nAll applications:");
1097
- allApps.forEach((a, i) => console.log(` ${i + 1}) ${a.name} (${a.entityRef ?? a.externalKey ?? "n/a"})`));
1098
- const pick = Number((await rl.question("Pick application number or 0 to create new: ")).trim());
1099
- if (!Number.isFinite(pick) || pick <= 0 || pick > allApps.length)
1100
- return "__create__";
1101
- return allApps[pick - 1].entityRef ?? allApps[pick - 1].externalKey ?? "__create__";
1102
- }
1103
- return chosen && chosen !== "__show_all__" ? chosen : "__create__";
1104
- }
1105
- finally {
1106
- rl.close();
1107
- }
1108
- }
1109
1073
  // ─── Main command ─────────────────────────────────────────────────────────────
1110
1074
  export async function initProject(args) {
1111
1075
  const asJson = parseFlag(args, "--json");
@@ -1153,6 +1117,23 @@ export async function initProject(args) {
1153
1117
  const refreshMode = parseFlag(args, "--refresh");
1154
1118
  const creds = requireCredentials();
1155
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
+ }
1156
1137
  if (!asJson)
1157
1138
  console.log(`Scanning ${dir}…`);
1158
1139
  logProgress("scan.start", dir);
@@ -1293,6 +1274,27 @@ export async function initProject(args) {
1293
1274
  console.log(`\nUsing --application-ref target: ${projectExternalKey}`);
1294
1275
  }
1295
1276
  else {
1277
+ // Seed S2 auto-map: when a reviewer previously declined a proposal with
1278
+ // this name as a duplicate, a company alias maps it to the surviving
1279
+ // application. Resolving the name first means the human's "no" is
1280
+ // honoured automatically — the scan maps instead of re-proposing.
1281
+ try {
1282
+ const aliasRaw = await callMcpProfiled("nexarch_resolve_reference", { names: [displayName], companyId: creds.companyId }, { batchSize: 1 });
1283
+ const aliasData = parseToolText(aliasRaw);
1284
+ const aliasHit = (aliasData.results ?? []).find((r) => r.resolved && r.entityTypeCode === "application" && r.canonicalExternalRef?.startsWith("application:"));
1285
+ if (aliasHit?.canonicalExternalRef) {
1286
+ projectExternalKey = aliasHit.canonicalExternalRef;
1287
+ if (!asJson) {
1288
+ console.log(`\nAuto-mapped via workspace alias: "${displayName}" → ${projectExternalKey} (${aliasHit.canonicalName ?? "existing application"})`);
1289
+ console.log(" A reviewer previously mapped this name to an existing application. To create a separate application anyway, re-run with --create-application.");
1290
+ }
1291
+ }
1292
+ }
1293
+ catch {
1294
+ // Alias resolution is best-effort; fall through to candidate scoring.
1295
+ }
1296
+ }
1297
+ if (!applicationRefOverride && projectExternalKey === `${entityTypeOverride}:${projectSlug}`) {
1296
1298
  const appsRaw = await callMcpProfiled("nexarch_list_entities", { entityTypeCode: "application", status: "active", limit: 500, companyId: creds.companyId }, { entityTypeCode: "application", limit: 500 });
1297
1299
  const appsData = parseToolText(appsRaw);
1298
1300
  const apps = (appsData.entities ?? []).filter((e) => (e.entityRef ?? e.externalKey));
@@ -1303,58 +1305,25 @@ export async function initProject(args) {
1303
1305
  .sort((a, b) => b.score - a.score);
1304
1306
  const suggested = matches.length > 0 ? matches[0] : null;
1305
1307
  const highConfidence = suggested && suggested.score >= 0.85;
1306
- const interactiveAllowed = !nonInteractive && process.stdin.isTTY;
1308
+ // ADR 8d: the interactive "Choose target application" prompt is gone.
1309
+ // It demanded an architectural judgement from whoever happened to run
1310
+ // the command, in a terminal, sixty seconds into using the product —
1311
+ // and blocked non-interactive runs entirely. The default is now to
1312
+ // create a new application, which arrives as PROPOSED (8b): the
1313
+ // mapping judgement moves to the activation card, where similarity
1314
+ // evidence renders for the human reviewing it (8c). Explicit mapping
1315
+ // remains available via --application-ref and --auto-map-application.
1307
1316
  if (autoMapApplication && highConfidence) {
1308
1317
  projectExternalKey = suggested.entityRef;
1309
1318
  if (!asJson)
1310
1319
  console.log(`\nAuto-mapped to existing application: ${suggested.name} (${projectExternalKey})`);
1311
1320
  }
1312
- else if (!interactiveAllowed) {
1313
- const message = "Application mapping requires explicit choice in non-interactive mode. Pass --application-ref <entityRef> or --create-application (or run interactively).";
1314
- const existingApplications = apps.slice(0, 25).map((a) => ({
1315
- entityRef: a.entityRef ?? a.externalKey,
1316
- name: a.name,
1317
- entityTypeCode: a.entityTypeCode,
1318
- }));
1319
- if (asJson) {
1320
- process.stdout.write(`${JSON.stringify({
1321
- ok: true,
1322
- status: "input_required",
1323
- code: "APPLICATION_MAPPING_REQUIRED",
1324
- message,
1325
- suggested: suggested ?? null,
1326
- candidates: matches.slice(0, 10),
1327
- existingApplications,
1328
- requiredInput: {
1329
- applicationRefOption: "--application-ref <entityRef>",
1330
- createOption: "--create-application",
1331
- },
1332
- }, null, 2)}\n`);
1333
- return;
1334
- }
1335
- console.log("\nInput required before continuing:");
1336
- console.log(` ${message}`);
1337
- if (suggested) {
1338
- console.log(` Suggested: ${suggested.name} (${suggested.entityRef}) score=${suggested.score.toFixed(2)}`);
1339
- }
1340
- if (existingApplications.length > 0) {
1341
- console.log(" Existing applications:");
1342
- for (const app of existingApplications) {
1343
- console.log(` - ${app.name} (${app.entityRef})`);
1344
- }
1345
- }
1346
- return;
1347
- }
1348
- else {
1349
- const chosen = await promptApplicationChoice(matches, apps, highConfidence ? suggested : null);
1350
- if (chosen !== "__create__") {
1351
- projectExternalKey = chosen;
1352
- if (!asJson)
1353
- console.log(`Mapped to existing application: ${projectExternalKey}`);
1354
- }
1355
- else if (!asJson) {
1356
- console.log("Creating a new application entity for this project.");
1321
+ else if (matches.length > 0 && !asJson) {
1322
+ console.log("\nSimilar existing applications found (registering a new proposed application anyway the reviewer sees these again at activation):");
1323
+ for (const m of matches.slice(0, 5)) {
1324
+ console.log(` - ${m.name} (${m.entityRef}) score=${m.score.toFixed(2)} [${m.reasons.join(", ")}]`);
1357
1325
  }
1326
+ console.log(" To map to one instead, re-run with: --application-ref <entityRef>");
1358
1327
  }
1359
1328
  }
1360
1329
  }