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.
@@ -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
- function injectAgentConfigs(registry, runtimeCodes) {
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
- writeFileSync(filePath, `${managedBody}\n`, "utf8");
360
- return { path: filePath, status: "injected" };
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
- writeFileSync(filePath, replaced, "utf8");
375
- return { path: filePath, status: "updated" };
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
- writeFileSync(filePath, existing + separator + managedBody + "\n", "utf8");
382
- return { path: filePath, status: "injected" };
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
- writeFileSync(filePath, next, "utf8");
390
- return { path: filePath, status: "injected" };
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
- function injectGenericAgentConfig(registry) {
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
- writeFileSync(filePath, `${managedBody}\n`, "utf8");
575
- return [{ path: filePath, status: "injected" }];
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
- writeFileSync(filePath, replaced, "utf8");
585
- return [{ path: filePath, status: "updated" }];
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
- writeFileSync(filePath, existing + separator + managedBody + "\n", "utf8");
596
- return [{ path: filePath, status: "injected" }];
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
- writeFileSync(fallbackPath, `${managedBody}\n`, "utf8");
606
- return [{ path: fallbackPath, status: "injected" }];
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
- writeFileSync(fallbackPath, replaced, "utf8");
616
- return [{ path: fallbackPath, status: "updated" }];
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
- writeFileSync(fallbackPath, existing + separator + managedBody + "\n", "utf8");
623
- return [{ path: fallbackPath, status: "injected" }];
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
- let existingInstructionTargets = injectAgentConfigs(registry, explicitInstructionRuntimeTargets.length > 0
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
- agentConfigResults = existingInstructionTargets;
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
- // Inject trust attestation for any file actually written (injected/updated),
1225
- // plus all targets when instructionsWriteAllowed forces a refresh.
1226
- // Runs regardless of instructionsWriteAllowed so writes that happened before
1227
- // the consent check always get a trust block.
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
- : existingInstructionTargets.filter((r) => r.status === "injected" || r.status === "updated");
1268
+ ? agentConfigResults.filter((r) => r.status === "injected" || r.status === "updated")
1269
+ : [];
1231
1270
  if (attestationTargets.length > 0) {
1232
1271
  trustAttestationAttempted = true;
1233
- try {
1234
- trustAttestation = await requestTrustAttestation(agentId);
1235
- }
1236
- catch {
1237
- trustAttestation = { ok: false, reason: "request failed" };
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 (trustAttestation.ok) {
1242
- injectTrustAttestationBlock(r.path, trustAttestation);
1288
+ if (targetAttestation.ok) {
1289
+ injectTrustAttestationBlock(r.path, targetAttestation);
1243
1290
  }
1244
1291
  else {
1245
- injectTrustAttestationUnavailableBlock(r.path, trustAttestation.reason ?? "unknown");
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
+ }