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.
- package/dist/commands/ingest-infra.js +319 -0
- package/dist/commands/init-project-infra.js +170 -0
- package/dist/commands/init-project.js +53 -84
- package/dist/index.js +12 -3
- package/dist/lib/credentials.js +33 -1
- package/dist/lib/terraform-catalogue-seed.js +206 -0
- package/dist/lib/terraform-detect.js +338 -0
- package/dist/lib/terraform-projection.js +231 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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 (!
|
|
1313
|
-
|
|
1314
|
-
const
|
|
1315
|
-
|
|
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
|
}
|