nexarch 0.12.22 → 0.12.25
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/enroll.js +87 -0
- package/dist/commands/init-agent.js +93 -46
- package/dist/commands/init-project-data.js +181 -0
- package/dist/commands/init-project.js +133 -4
- package/dist/commands/mcp-proxy.js +81 -14
- package/dist/commands/verify-trust.js +32 -1
- package/dist/index.js +26 -0
- package/dist/lib/data-project-detect.js +204 -0
- package/dist/lib/mcp.js +17 -2
- package/dist/lib/trust.js +9 -2
- package/dist/lib/update-check.js +109 -0
- package/package.json +36 -35
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { hostname, platform, arch } from "node:os";
|
|
2
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
// Matches www.nexarch.ai — the default production host for the web app that
|
|
6
|
+
// owns /api/agent-enrollments/exchange. Not the same service as
|
|
7
|
+
// mcp.nexarch.ai (the MCP gateway); --host exists because staging and
|
|
8
|
+
// self-hosted deployments serve the exchange route from a different origin.
|
|
9
|
+
const DEFAULT_EXCHANGE_HOST = "https://www.nexarch.ai";
|
|
10
|
+
const ERROR_MESSAGES = {
|
|
11
|
+
enrollment_invalid: "The enrollment code is invalid, expired, or already used. Ask whoever created it to issue a new one.",
|
|
12
|
+
enrollment_client_mismatch: "This code was issued for a different client type than the one passed to --client. Check the client code against the one configured when the enrollment was created.",
|
|
13
|
+
server_not_configured: "The Nexarch server is missing its gateway URL configuration (MCP_GATEWAY_PUBLIC_URL). This is a server-side issue — ask whoever operates that deployment to set it, then retry with the same code.",
|
|
14
|
+
};
|
|
15
|
+
function parseFlag(args, flag) {
|
|
16
|
+
return args.includes(flag);
|
|
17
|
+
}
|
|
18
|
+
function parseOptionValue(args, option) {
|
|
19
|
+
const idx = args.indexOf(option);
|
|
20
|
+
if (idx === -1)
|
|
21
|
+
return null;
|
|
22
|
+
const value = args[idx + 1];
|
|
23
|
+
if (!value || value.startsWith("--"))
|
|
24
|
+
return null;
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
function redactedManifest(manifest) {
|
|
28
|
+
return { ...manifest, credential: { ...manifest.credential, token: "(redacted — see saved file, or pass --print-token)" } };
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Bootstraps a headless (browserless) MCP agent by redeeming a one-time
|
|
32
|
+
* enrollment code for a long-lived credential — the CLI equivalent of the
|
|
33
|
+
* curl snippet the workspace UI shows (headless-enrollment-form.tsx). Reuses
|
|
34
|
+
* the exact same unauthenticated exchange endpoint; the only thing this adds
|
|
35
|
+
* is host/platform auto-detection, an error message per failure mode instead
|
|
36
|
+
* of a raw JSON blob, and keeping the credential out of stdout/shell history
|
|
37
|
+
* by default.
|
|
38
|
+
*/
|
|
39
|
+
export async function enroll(args) {
|
|
40
|
+
const asJson = parseFlag(args, "--json");
|
|
41
|
+
const printToken = parseFlag(args, "--print-token");
|
|
42
|
+
const code = parseOptionValue(args, "--code");
|
|
43
|
+
const clientCode = parseOptionValue(args, "--client");
|
|
44
|
+
const clientVersion = parseOptionValue(args, "--client-version");
|
|
45
|
+
const host = parseOptionValue(args, "--host") ?? DEFAULT_EXCHANGE_HOST;
|
|
46
|
+
const outPath = parseOptionValue(args, "--out");
|
|
47
|
+
if (!code || !code.startsWith("nxe_")) {
|
|
48
|
+
throw new Error("Missing or invalid --code (expected a one-time code starting with 'nxe_' from the workspace UI).");
|
|
49
|
+
}
|
|
50
|
+
if (!clientCode) {
|
|
51
|
+
throw new Error("Missing --client (the client code configured for this enrollment, e.g. hermes-agent).");
|
|
52
|
+
}
|
|
53
|
+
const res = await fetch(`${host}/api/agent-enrollments/exchange`, {
|
|
54
|
+
method: "POST",
|
|
55
|
+
headers: { "Content-Type": "application/json" },
|
|
56
|
+
body: JSON.stringify({
|
|
57
|
+
code,
|
|
58
|
+
client: { code: clientCode, version: clientVersion ?? undefined },
|
|
59
|
+
host: { hostname: hostname(), platform: platform(), arch: arch() },
|
|
60
|
+
}),
|
|
61
|
+
});
|
|
62
|
+
const body = await res.json().catch(() => ({}));
|
|
63
|
+
if (!res.ok) {
|
|
64
|
+
const errorCode = typeof body.error === "string" ? body.error : null;
|
|
65
|
+
const message = (errorCode && ERROR_MESSAGES[errorCode]) ?? `Enrollment exchange failed (${errorCode ?? res.status}).`;
|
|
66
|
+
throw new Error(message);
|
|
67
|
+
}
|
|
68
|
+
const manifest = body;
|
|
69
|
+
const destination = outPath ?? join(homedir(), ".nexarch", "agent-credential.json");
|
|
70
|
+
mkdirSync(dirname(destination), { recursive: true });
|
|
71
|
+
writeFileSync(destination, JSON.stringify(manifest, null, 2), { mode: 0o600 });
|
|
72
|
+
if (asJson) {
|
|
73
|
+
const output = printToken ? manifest : redactedManifest(manifest);
|
|
74
|
+
process.stdout.write(`${JSON.stringify(output, null, 2)}\n`);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
console.log(`✓ Enrolled agent ${manifest.agent.ref} in workspace "${manifest.workspace.name}"`);
|
|
78
|
+
console.log(` Scopes : ${manifest.credential.scopes.join(", ")}`);
|
|
79
|
+
console.log(` Expires : ${manifest.credential.expiresAt ?? "never"}`);
|
|
80
|
+
console.log(` MCP server : ${manifest.mcp.url}`);
|
|
81
|
+
console.log(` Credential : saved to ${destination} (mode 600)`);
|
|
82
|
+
console.log(`\nExport it as ${manifest.mcp.auth.environmentVariable} in the agent runtime's own environment — read`);
|
|
83
|
+
console.log(`the token from that file rather than pasting it into a shell command or config file.`);
|
|
84
|
+
if (printToken) {
|
|
85
|
+
console.log(`\nToken: ${manifest.credential.token}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -2,6 +2,7 @@ import { arch, homedir, hostname, platform, release, type as osType, userInfo }
|
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "fs";
|
|
3
3
|
import { basename, join, resolve } from "path";
|
|
4
4
|
import * as readline from "node:readline/promises";
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
5
6
|
import process from "process";
|
|
6
7
|
import { requireCredentials } from "../lib/credentials.js";
|
|
7
8
|
import { fetchAgentRegistryOrThrow } from "../lib/agent-registry.js";
|
|
@@ -19,6 +20,10 @@ const CLI_VERSION = (() => {
|
|
|
19
20
|
})();
|
|
20
21
|
const AGENT_ENTITY_TYPE = "agent";
|
|
21
22
|
const TECH_COMPONENT_ENTITY_TYPE = "technology_component";
|
|
23
|
+
/** Hashes the exact managed-section text a trust attestation will be minted for — see ADR-0112. */
|
|
24
|
+
function sha256Hex(text) {
|
|
25
|
+
return createHash("sha256").update(text, "utf8").digest("hex");
|
|
26
|
+
}
|
|
22
27
|
function parseFlag(args, flag) {
|
|
23
28
|
return args.includes(flag);
|
|
24
29
|
}
|
|
@@ -337,7 +342,13 @@ function canonicalTargetKey(filePath) {
|
|
|
337
342
|
const abs = resolve(filePath);
|
|
338
343
|
return process.platform === "win32" || process.platform === "darwin" ? abs.toLowerCase() : abs;
|
|
339
344
|
}
|
|
340
|
-
|
|
345
|
+
/**
|
|
346
|
+
* `dryRun: true` computes what WOULD happen (status + the exact section body,
|
|
347
|
+
* so a caller can hash it) without touching any file. Callers must resolve
|
|
348
|
+
* consent from the dry-run result before calling again with `dryRun: false`
|
|
349
|
+
* to actually write — see the ADR-0112 note at the call site.
|
|
350
|
+
*/
|
|
351
|
+
function injectAgentConfigs(registry, runtimeCodes, dryRun) {
|
|
341
352
|
const templateByCode = new Map(registry.instructionTemplates.map((t) => [t.code, t]));
|
|
342
353
|
const sortedTargets = [...registry.instructionTargets]
|
|
343
354
|
.filter((target) => target.matchMode === "exact")
|
|
@@ -356,8 +367,9 @@ function injectAgentConfigs(registry, runtimeCodes) {
|
|
|
356
367
|
const sectionMarker = target.sectionMarker ?? sectionHeading;
|
|
357
368
|
const managedBody = wrapManagedSection("agent-registration", sectionBody);
|
|
358
369
|
if (!existsSync(filePath)) {
|
|
359
|
-
|
|
360
|
-
|
|
370
|
+
if (!dryRun)
|
|
371
|
+
writeFileSync(filePath, `${managedBody}\n`, "utf8");
|
|
372
|
+
return { path: filePath, status: "injected", sectionBody };
|
|
361
373
|
}
|
|
362
374
|
const existing = readFileSync(filePath, "utf8");
|
|
363
375
|
if (target.insertionMode === "replace_section") {
|
|
@@ -371,23 +383,26 @@ function injectAgentConfigs(registry, runtimeCodes) {
|
|
|
371
383
|
replaced = `${before}${managedBody}\n`;
|
|
372
384
|
}
|
|
373
385
|
if (replaced !== existing) {
|
|
374
|
-
|
|
375
|
-
|
|
386
|
+
if (!dryRun)
|
|
387
|
+
writeFileSync(filePath, replaced, "utf8");
|
|
388
|
+
return { path: filePath, status: "updated", sectionBody };
|
|
376
389
|
}
|
|
377
390
|
if (existing.includes(managedBody) || existing.includes(sectionBody)) {
|
|
378
|
-
return { path: filePath, status: "already_present" };
|
|
391
|
+
return { path: filePath, status: "already_present", sectionBody };
|
|
379
392
|
}
|
|
380
393
|
const separator = existing.endsWith("\n") ? "" : "\n";
|
|
381
|
-
|
|
382
|
-
|
|
394
|
+
if (!dryRun)
|
|
395
|
+
writeFileSync(filePath, existing + separator + managedBody + "\n", "utf8");
|
|
396
|
+
return { path: filePath, status: "injected", sectionBody };
|
|
383
397
|
}
|
|
384
398
|
if (existing.includes(managedBody) || existing.includes(sectionBody)) {
|
|
385
|
-
return { path: filePath, status: "already_present" };
|
|
399
|
+
return { path: filePath, status: "already_present", sectionBody };
|
|
386
400
|
}
|
|
387
401
|
const separator = existing.endsWith("\n") ? "" : "\n";
|
|
388
402
|
const next = existing + separator + managedBody + "\n";
|
|
389
|
-
|
|
390
|
-
|
|
403
|
+
if (!dryRun)
|
|
404
|
+
writeFileSync(filePath, next, "utf8");
|
|
405
|
+
return { path: filePath, status: "injected", sectionBody };
|
|
391
406
|
};
|
|
392
407
|
const seenTargets = new Set();
|
|
393
408
|
const existingMatches = [];
|
|
@@ -552,7 +567,8 @@ function injectInitProjectReportingContract(path) {
|
|
|
552
567
|
}
|
|
553
568
|
writeFileSync(path, replaced !== existing ? replaced : `${existing}${existing.endsWith("\n") ? "" : "\n"}${managed}\n`, "utf8");
|
|
554
569
|
}
|
|
555
|
-
|
|
570
|
+
/** Same dry-run contract as {@link injectAgentConfigs}. */
|
|
571
|
+
function injectGenericAgentConfig(registry, dryRun) {
|
|
556
572
|
const templateByCode = new Map(registry.instructionTemplates.map((t) => [t.code, t]));
|
|
557
573
|
const genericTargets = [...registry.instructionTargets]
|
|
558
574
|
.filter((t) => t.runtimeCode === "generic" && t.matchMode === "exact")
|
|
@@ -571,8 +587,9 @@ function injectGenericAgentConfig(registry) {
|
|
|
571
587
|
const sectionHeading = target.sectionHeading ?? "## Nexarch Agent Registration";
|
|
572
588
|
const managedBody = wrapManagedSection("agent-registration", sectionBody);
|
|
573
589
|
if (!existsSync(filePath)) {
|
|
574
|
-
|
|
575
|
-
|
|
590
|
+
if (!dryRun)
|
|
591
|
+
writeFileSync(filePath, `${managedBody}\n`, "utf8");
|
|
592
|
+
return [{ path: filePath, status: "injected", sectionBody }];
|
|
576
593
|
}
|
|
577
594
|
const existing = readFileSync(filePath, "utf8");
|
|
578
595
|
if (target.insertionMode === "replace_section") {
|
|
@@ -581,19 +598,21 @@ function injectGenericAgentConfig(registry) {
|
|
|
581
598
|
replaced = replaceInjectedSection(existing, sectionHeading, managedBody);
|
|
582
599
|
}
|
|
583
600
|
if (replaced !== existing) {
|
|
584
|
-
|
|
585
|
-
|
|
601
|
+
if (!dryRun)
|
|
602
|
+
writeFileSync(filePath, replaced, "utf8");
|
|
603
|
+
return [{ path: filePath, status: "updated", sectionBody }];
|
|
586
604
|
}
|
|
587
605
|
if (existing.includes(managedBody) || existing.includes(sectionBody)) {
|
|
588
|
-
return [{ path: filePath, status: "already_present" }];
|
|
606
|
+
return [{ path: filePath, status: "already_present", sectionBody }];
|
|
589
607
|
}
|
|
590
608
|
}
|
|
591
609
|
if (existing.includes(managedBody) || existing.includes(sectionBody)) {
|
|
592
|
-
return [{ path: filePath, status: "already_present" }];
|
|
610
|
+
return [{ path: filePath, status: "already_present", sectionBody }];
|
|
593
611
|
}
|
|
594
612
|
const separator = existing.endsWith("\n") ? "" : "\n";
|
|
595
|
-
|
|
596
|
-
|
|
613
|
+
if (!dryRun)
|
|
614
|
+
writeFileSync(filePath, existing + separator + managedBody + "\n", "utf8");
|
|
615
|
+
return [{ path: filePath, status: "injected", sectionBody }];
|
|
597
616
|
}
|
|
598
617
|
const fallbackTemplate = templateByCode.get("nexarch_agent_registration_v1") ?? registry.instructionTemplates[0];
|
|
599
618
|
if (!fallbackTemplate)
|
|
@@ -602,8 +621,9 @@ function injectGenericAgentConfig(registry) {
|
|
|
602
621
|
const sectionBody = fallbackTemplate.body.trim();
|
|
603
622
|
const managedBody = wrapManagedSection("agent-registration", sectionBody);
|
|
604
623
|
if (!existsSync(fallbackPath)) {
|
|
605
|
-
|
|
606
|
-
|
|
624
|
+
if (!dryRun)
|
|
625
|
+
writeFileSync(fallbackPath, `${managedBody}\n`, "utf8");
|
|
626
|
+
return [{ path: fallbackPath, status: "injected", sectionBody }];
|
|
607
627
|
}
|
|
608
628
|
const existing = readFileSync(fallbackPath, "utf8");
|
|
609
629
|
const sectionHeading = "## Nexarch Agent Registration";
|
|
@@ -612,15 +632,17 @@ function injectGenericAgentConfig(registry) {
|
|
|
612
632
|
replaced = replaceInjectedSection(existing, sectionHeading, managedBody);
|
|
613
633
|
}
|
|
614
634
|
if (replaced !== existing) {
|
|
615
|
-
|
|
616
|
-
|
|
635
|
+
if (!dryRun)
|
|
636
|
+
writeFileSync(fallbackPath, replaced, "utf8");
|
|
637
|
+
return [{ path: fallbackPath, status: "updated", sectionBody }];
|
|
617
638
|
}
|
|
618
639
|
if (existing.includes(managedBody) || existing.includes(sectionBody)) {
|
|
619
|
-
return [{ path: fallbackPath, status: "already_present" }];
|
|
640
|
+
return [{ path: fallbackPath, status: "already_present", sectionBody }];
|
|
620
641
|
}
|
|
621
642
|
const separator = existing.endsWith("\n") ? "" : "\n";
|
|
622
|
-
|
|
623
|
-
|
|
643
|
+
if (!dryRun)
|
|
644
|
+
writeFileSync(fallbackPath, existing + separator + managedBody + "\n", "utf8");
|
|
645
|
+
return [{ path: fallbackPath, status: "injected", sectionBody }];
|
|
624
646
|
}
|
|
625
647
|
export async function initAgent(args) {
|
|
626
648
|
const asJson = parseFlag(args, "--json");
|
|
@@ -1196,11 +1218,20 @@ export async function initAgent(args) {
|
|
|
1196
1218
|
catch {
|
|
1197
1219
|
// non-fatal
|
|
1198
1220
|
}
|
|
1199
|
-
|
|
1221
|
+
const runtimeTargetCodes = explicitInstructionRuntimeTargets.length > 0
|
|
1200
1222
|
? explicitInstructionRuntimeTargets
|
|
1201
|
-
: (selectedClient ? [selectedClient] : [])
|
|
1223
|
+
: (selectedClient ? [selectedClient] : []);
|
|
1224
|
+
// Dry run first: figure out what WOULD be written — and, critically,
|
|
1225
|
+
// whether the repo's files already match, which is how `alreadyConfigured`
|
|
1226
|
+
// below decides consent is even needed — without writing anything yet.
|
|
1227
|
+
// The write itself only happens after `instructionsWriteAllowed` is
|
|
1228
|
+
// resolved. (Previously `injectAgentConfigs`/`injectGenericAgentConfig`
|
|
1229
|
+
// wrote unconditionally here and the consent check only gated whether a
|
|
1230
|
+
// trust attestation got added afterward — so CLAUDE.md/AGENTS.md could be
|
|
1231
|
+
// modified before the human had said yes to anything.)
|
|
1232
|
+
let existingInstructionTargets = injectAgentConfigs(registry, runtimeTargetCodes, true);
|
|
1202
1233
|
if (existingInstructionTargets.length === 0) {
|
|
1203
|
-
existingInstructionTargets = injectGenericAgentConfig(registry);
|
|
1234
|
+
existingInstructionTargets = injectGenericAgentConfig(registry, true);
|
|
1204
1235
|
}
|
|
1205
1236
|
const alreadyConfigured = existingInstructionTargets.length > 0 && existingInstructionTargets.every((r) => r.status === "already_present");
|
|
1206
1237
|
if (denyInstructionWriteFlag) {
|
|
@@ -1216,33 +1247,49 @@ export async function initAgent(args) {
|
|
|
1216
1247
|
instructionsWriteAllowed = await confirmInstructionWrite();
|
|
1217
1248
|
}
|
|
1218
1249
|
if (instructionsWriteAllowed) {
|
|
1219
|
-
|
|
1250
|
+
// Consent granted (flag or interactive confirm) — write for real.
|
|
1251
|
+
agentConfigResults = injectAgentConfigs(registry, runtimeTargetCodes, false);
|
|
1252
|
+
if (agentConfigResults.length === 0) {
|
|
1253
|
+
agentConfigResults = injectGenericAgentConfig(registry, false);
|
|
1254
|
+
}
|
|
1220
1255
|
}
|
|
1221
1256
|
else if (alreadyConfigured) {
|
|
1257
|
+
// Nothing would change either way (every target already matches), so
|
|
1258
|
+
// the dry-run result is accurate and no second pass is needed.
|
|
1222
1259
|
agentConfigResults = existingInstructionTargets;
|
|
1223
1260
|
}
|
|
1224
|
-
//
|
|
1225
|
-
//
|
|
1226
|
-
//
|
|
1227
|
-
//
|
|
1261
|
+
// Attest only files actually written (injected/updated) just now. When
|
|
1262
|
+
// consent wasn't granted, nothing was written above — `existingInstructionTargets`
|
|
1263
|
+
// is dry-run data with no corresponding file change — so there is
|
|
1264
|
+
// nothing new to attest. An "already_present" target needs no fresh
|
|
1265
|
+
// attestation either: its content, and whatever attestation it already
|
|
1266
|
+
// carries from a prior run, hasn't changed.
|
|
1228
1267
|
const attestationTargets = instructionsWriteAllowed
|
|
1229
|
-
? agentConfigResults
|
|
1230
|
-
:
|
|
1268
|
+
? agentConfigResults.filter((r) => r.status === "injected" || r.status === "updated")
|
|
1269
|
+
: [];
|
|
1231
1270
|
if (attestationTargets.length > 0) {
|
|
1232
1271
|
trustAttestationAttempted = true;
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
}
|
|
1272
|
+
// Minted per target, not once for the batch: each file's managed
|
|
1273
|
+
// section can carry different text (different templateCode per
|
|
1274
|
+
// runtime), and the attestation has to bind to the exact bytes it
|
|
1275
|
+
// covers — see ADR-0112. `trustAttestation` keeps the first result for
|
|
1276
|
+
// the JSON summary field; every target still gets its own token.
|
|
1239
1277
|
for (const r of attestationTargets) {
|
|
1278
|
+
let targetAttestation;
|
|
1279
|
+
try {
|
|
1280
|
+
targetAttestation = await requestTrustAttestation(agentId, sha256Hex(r.sectionBody));
|
|
1281
|
+
}
|
|
1282
|
+
catch {
|
|
1283
|
+
targetAttestation = { ok: false, reason: "request failed" };
|
|
1284
|
+
}
|
|
1285
|
+
if (!trustAttestation)
|
|
1286
|
+
trustAttestation = targetAttestation;
|
|
1240
1287
|
try {
|
|
1241
|
-
if (
|
|
1242
|
-
injectTrustAttestationBlock(r.path,
|
|
1288
|
+
if (targetAttestation.ok) {
|
|
1289
|
+
injectTrustAttestationBlock(r.path, targetAttestation);
|
|
1243
1290
|
}
|
|
1244
1291
|
else {
|
|
1245
|
-
injectTrustAttestationUnavailableBlock(r.path,
|
|
1292
|
+
injectTrustAttestationUnavailableBlock(r.path, targetAttestation.reason ?? "unknown");
|
|
1246
1293
|
}
|
|
1247
1294
|
injectInitProjectReportingContract(r.path);
|
|
1248
1295
|
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { basename } from "path";
|
|
2
|
+
import { requireCredentials } from "../lib/credentials.js";
|
|
3
|
+
import { callMcpTool } from "../lib/mcp.js";
|
|
4
|
+
import { detectSourceRepository, resolveProjectIdentity } from "./init-project.js";
|
|
5
|
+
/**
|
|
6
|
+
* Onboarding for a data-pipeline repository: dbt, Airflow, Dagster, Dataform,
|
|
7
|
+
* ksqlDB, or Spark (data-architecture-plan.md 1.3, H-06).
|
|
8
|
+
*
|
|
9
|
+
* Mirrors init-project-infra.ts's shape deliberately: register first, report
|
|
10
|
+
* what is still outstanding, never claim completion the write did not earn.
|
|
11
|
+
* Unlike the infrastructure path there is no state to read afterwards — every
|
|
12
|
+
* component this scan finds is written in the one pass, so a successful,
|
|
13
|
+
* non-dry-run write is enrichmentCompleted: true outright.
|
|
14
|
+
*
|
|
15
|
+
* The pipeline registers as a `project` (ADR 8a: evidence, not architecture,
|
|
16
|
+
* same identity resolution as a regular repo — H-05) sourcing an
|
|
17
|
+
* `application` (subtype app_custom_built — no application subtype names
|
|
18
|
+
* "data pipeline" specifically, and this is a detection fix, not an ontology
|
|
19
|
+
* change). Its models/DAGs/assets become `application_component` children
|
|
20
|
+
* using subtypes that already exist and are already legal
|
|
21
|
+
* (app_comp_data_pipeline, orchestrator_component) — verified live,
|
|
22
|
+
* 2026-08-24.
|
|
23
|
+
*/
|
|
24
|
+
const RUNTIME_LABEL = {
|
|
25
|
+
dbt: "dbt model",
|
|
26
|
+
airflow: "Airflow DAG",
|
|
27
|
+
dagster: "Dagster asset",
|
|
28
|
+
dataform: "Dataform definition",
|
|
29
|
+
ksqldb: "ksqlDB script",
|
|
30
|
+
spark: "Spark job",
|
|
31
|
+
};
|
|
32
|
+
function readResult(raw) {
|
|
33
|
+
return JSON.parse(raw.content?.[0]?.text ?? "{}");
|
|
34
|
+
}
|
|
35
|
+
function slugify(value) {
|
|
36
|
+
return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 80);
|
|
37
|
+
}
|
|
38
|
+
function chunk(items, size) {
|
|
39
|
+
const out = [];
|
|
40
|
+
for (let i = 0; i < items.length; i += size)
|
|
41
|
+
out.push(items.slice(i, i + size));
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
export async function runDataProjectOnboarding(options, detection) {
|
|
45
|
+
const { dir, nameOverride, asJson, dryRun } = options;
|
|
46
|
+
const creds = requireCredentials();
|
|
47
|
+
const runtime = detection.runtime;
|
|
48
|
+
const displayName = nameOverride ?? basename(dir);
|
|
49
|
+
const projectDirSlug = slugify(displayName);
|
|
50
|
+
const detectedRepo = detectSourceRepository(dir);
|
|
51
|
+
const projectIdentity = dryRun
|
|
52
|
+
? { key: `project:${projectDirSlug}`, source: "directory_slug" }
|
|
53
|
+
: await resolveProjectIdentity(creds.companyId, detectedRepo, displayName, projectDirSlug);
|
|
54
|
+
const projectRef = projectIdentity.key;
|
|
55
|
+
// The application and its components derive their slug from the RESOLVED
|
|
56
|
+
// project identity, not the current checkout's directory name — otherwise
|
|
57
|
+
// tier 1/2 dedupe the project entity but every application_component
|
|
58
|
+
// still mints fresh under application:01_jaffle_shop beside
|
|
59
|
+
// application:jaffle_shop, which is the same H-05 bug one level down.
|
|
60
|
+
const resolvedSlug = projectIdentity.key.split(":").slice(1).join(":") || projectDirSlug;
|
|
61
|
+
const applicationRef = `application:${resolvedSlug}`;
|
|
62
|
+
if (!asJson) {
|
|
63
|
+
console.log(`\nDetected a ${runtime} data pipeline — ${detection.signals.join(", ")}.`);
|
|
64
|
+
if (projectIdentity.source !== "directory_slug") {
|
|
65
|
+
console.log(` Identity: matched existing project via ${projectIdentity.source.replace(/_/g, " ")} (${projectRef})`);
|
|
66
|
+
}
|
|
67
|
+
console.log(dryRun
|
|
68
|
+
? `Dry run — nothing below is written. ${displayName} would be registered as ${projectRef} sourcing ${applicationRef}.`
|
|
69
|
+
: `Registering ${displayName} as ${projectRef} sourcing ${applicationRef}.`);
|
|
70
|
+
}
|
|
71
|
+
const scanProvenance = {
|
|
72
|
+
source: "nexarch_cli_init_project",
|
|
73
|
+
source_dir: dir,
|
|
74
|
+
scanned_at: new Date().toISOString(),
|
|
75
|
+
data_runtime: runtime,
|
|
76
|
+
...(detectedRepo?.url ? { repository_url: detectedRepo.url, source_repository_url: detectedRepo.url } : {}),
|
|
77
|
+
};
|
|
78
|
+
const projectEntity = {
|
|
79
|
+
entityRef: projectRef,
|
|
80
|
+
entityTypeCode: "project",
|
|
81
|
+
entitySubtypeCode: "project_repository",
|
|
82
|
+
name: displayName,
|
|
83
|
+
confidence: 1,
|
|
84
|
+
attributes: scanProvenance,
|
|
85
|
+
};
|
|
86
|
+
const applicationEntity = {
|
|
87
|
+
entityRef: applicationRef,
|
|
88
|
+
entityTypeCode: "application",
|
|
89
|
+
entitySubtypeCode: "app_custom_built",
|
|
90
|
+
name: displayName,
|
|
91
|
+
description: `${runtime} data pipeline (${detection.signals.join(", ")}).`,
|
|
92
|
+
confidence: 1,
|
|
93
|
+
attributes: scanProvenance,
|
|
94
|
+
};
|
|
95
|
+
const componentEntities = detection.components.map((c) => ({
|
|
96
|
+
entityRef: `application_component:${resolvedSlug}_${c.slug}`,
|
|
97
|
+
entityTypeCode: "application_component",
|
|
98
|
+
entitySubtypeCode: c.subtypeCode,
|
|
99
|
+
name: c.name,
|
|
100
|
+
description: `${RUNTIME_LABEL[runtime]} at ${c.relativePath}`,
|
|
101
|
+
confidence: 0.9,
|
|
102
|
+
attributes: { source: "nexarch_cli_init_project", data_runtime: runtime, source_path: c.relativePath },
|
|
103
|
+
}));
|
|
104
|
+
const entities = [projectEntity, applicationEntity, ...componentEntities];
|
|
105
|
+
const relationships = [
|
|
106
|
+
{ fromEntityRef: applicationRef, toEntityRef: projectRef, relationshipTypeCode: "sourced_from", confidence: 1 },
|
|
107
|
+
...componentEntities.map((c) => ({
|
|
108
|
+
fromEntityRef: c.entityRef,
|
|
109
|
+
toEntityRef: applicationRef,
|
|
110
|
+
relationshipTypeCode: "part_of",
|
|
111
|
+
confidence: 0.9,
|
|
112
|
+
})),
|
|
113
|
+
];
|
|
114
|
+
if (dryRun) {
|
|
115
|
+
if (asJson) {
|
|
116
|
+
process.stdout.write(`${JSON.stringify({ ok: true, dryRun: true, wrote: false, projectRef, applicationRef, dataRuntime: runtime, registrationStatus: "skeleton_only", enrichmentCompleted: false, detection, plan: { entities, relationships } }, null, 2)}\n`);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
console.log(`\nWould write ${entities.length} entit${entities.length === 1 ? "y" : "ies"} (${componentEntities.length} pipeline component(s)):`);
|
|
120
|
+
for (const e of entities.slice(0, 20))
|
|
121
|
+
console.log(` ${e.entityRef} (${e.entityTypeCode} / ${e.entitySubtypeCode})`);
|
|
122
|
+
if (entities.length > 20)
|
|
123
|
+
console.log(` … and ${entities.length - 20} more`);
|
|
124
|
+
console.log(`\nWould write ${relationships.length} relationship(s).`);
|
|
125
|
+
console.log(`\nNothing was written. Re-run without --dry-run to register.`);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const policiesRaw = await callMcpTool("nexarch_get_applied_policies", {});
|
|
129
|
+
const policyBundleHash = readResult(policiesRaw).policyBundleHash ?? null;
|
|
130
|
+
if (!policyBundleHash) {
|
|
131
|
+
throw new Error("Policy bootstrap is missing for this workspace. Complete workspace setup before registering a project.");
|
|
132
|
+
}
|
|
133
|
+
const agentContext = {
|
|
134
|
+
agentId: "nexarch-cli:init-project",
|
|
135
|
+
agentRunId: `init-project-data-${Date.now()}`,
|
|
136
|
+
repoRef: dir,
|
|
137
|
+
repoPath: dir,
|
|
138
|
+
observedAt: new Date().toISOString(),
|
|
139
|
+
source: "nexarch-cli",
|
|
140
|
+
model: "n/a",
|
|
141
|
+
provider: "n/a",
|
|
142
|
+
};
|
|
143
|
+
const policyContext = { policyBundleHash, alignmentSummary: { score: 1, violations: [], waivers: [] } };
|
|
144
|
+
let succeeded = 0;
|
|
145
|
+
let failed = 0;
|
|
146
|
+
for (const batch of chunk(entities, 400)) {
|
|
147
|
+
const raw = await callMcpTool("nexarch_upsert_entities", { entities: batch, agentContext, policyContext, companyId: creds.companyId }, { companyId: creds.companyId });
|
|
148
|
+
const result = readResult(raw);
|
|
149
|
+
succeeded += Number(result.summary?.succeeded ?? 0);
|
|
150
|
+
failed += Number(result.summary?.failed ?? 0);
|
|
151
|
+
}
|
|
152
|
+
for (const batch of chunk(relationships, 400)) {
|
|
153
|
+
await callMcpTool("nexarch_upsert_relationships", { relationships: batch, agentContext, policyContext, companyId: creds.companyId }, { companyId: creds.companyId });
|
|
154
|
+
}
|
|
155
|
+
if (projectIdentity.source === "directory_slug") {
|
|
156
|
+
try {
|
|
157
|
+
await callMcpTool("nexarch_register_alias", { alias: displayName, canonicalExternalKey: projectRef, canonicalName: displayName, entityTypeCode: "project", companyId: creds.companyId }, { companyId: creds.companyId });
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
// Non-fatal — see resolveProjectIdentity's tier 2.
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (!asJson) {
|
|
164
|
+
console.log(` ✓ Registered ${projectRef} sourcing ${applicationRef}`);
|
|
165
|
+
console.log(` ✓ Registered ${componentEntities.length} ${RUNTIME_LABEL[runtime]}(s) as application_component, part_of ${applicationRef}`);
|
|
166
|
+
if (failed > 0)
|
|
167
|
+
console.log(` ! ${failed} entit${failed === 1 ? "y" : "ies"} failed to write — see JSON output (--json) for details.`);
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
process.stdout.write(`${JSON.stringify({
|
|
171
|
+
ok: failed === 0,
|
|
172
|
+
projectRef,
|
|
173
|
+
applicationRef,
|
|
174
|
+
dataRuntime: runtime,
|
|
175
|
+
registrationStatus: "enriched",
|
|
176
|
+
enrichmentCompleted: true,
|
|
177
|
+
detection,
|
|
178
|
+
summary: { entitiesRequested: entities.length, entitiesSucceeded: succeeded, entitiesFailed: failed, relationshipsWritten: relationships.length },
|
|
179
|
+
}, null, 2)}\n`);
|
|
180
|
+
}
|
|
181
|
+
}
|