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.
- package/dist/commands/ingest-infra.js +319 -0
- package/dist/commands/init-project-infra.js +170 -0
- package/dist/commands/init-project.js +19 -0
- package/dist/commands/setup.js +85 -1
- package/dist/index.js +202 -192
- 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 };
|
|
@@ -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);
|
package/dist/commands/setup.js
CHANGED
|
@@ -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
|
|
114
|
+
await confirmWorkspace(args, requireCredentials()); // ensure login succeeded, and name the target
|
|
31
115
|
let registry;
|
|
32
116
|
try {
|
|
33
117
|
registry = await fetchAgentRegistryOrThrow();
|