nexarch 0.12.23 → 0.12.26

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,128 @@
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
+ import { expandHomePath, supportsAutoWrite, writeClientConfig } from "../lib/client-config-writers.js";
6
+ // Matches www.nexarch.ai — the default production host for the web app that
7
+ // owns /api/agent-enrollments/exchange. Not the same service as
8
+ // mcp.nexarch.ai (the MCP gateway); --host exists because staging and
9
+ // self-hosted deployments serve the exchange route from a different origin.
10
+ const DEFAULT_EXCHANGE_HOST = "https://www.nexarch.ai";
11
+ function renderInstructions(template, manifest, token, mcpEndpoint) {
12
+ return template
13
+ .replaceAll("{{envVarBraced}}", `\${${manifest.mcp.auth.environmentVariable}}`)
14
+ .replaceAll("{{envVar}}", manifest.mcp.auth.environmentVariable)
15
+ .replaceAll("{{mcpUrl}}", mcpEndpoint)
16
+ .replaceAll("{{serverKey}}", manifest.mcp.serverName)
17
+ .replaceAll("{{token}}", token);
18
+ }
19
+ const ERROR_MESSAGES = {
20
+ enrollment_invalid: "The enrollment code is invalid, expired, or already used. Ask whoever created it to issue a new one.",
21
+ 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.",
22
+ 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.",
23
+ };
24
+ function parseFlag(args, flag) {
25
+ return args.includes(flag);
26
+ }
27
+ function parseOptionValue(args, option) {
28
+ const idx = args.indexOf(option);
29
+ if (idx === -1)
30
+ return null;
31
+ const value = args[idx + 1];
32
+ if (!value || value.startsWith("--"))
33
+ return null;
34
+ return value;
35
+ }
36
+ function redactedManifest(manifest) {
37
+ return { ...manifest, credential: { ...manifest.credential, token: "(redacted — see saved file, or pass --print-token)" } };
38
+ }
39
+ /**
40
+ * Bootstraps a headless (browserless) MCP agent by redeeming a one-time
41
+ * enrollment code for a long-lived credential — the CLI equivalent of the
42
+ * curl snippet the workspace UI shows (headless-enrollment-form.tsx). Reuses
43
+ * the exact same unauthenticated exchange endpoint; the only thing this adds
44
+ * is host/platform auto-detection, an error message per failure mode instead
45
+ * of a raw JSON blob, and keeping the credential out of stdout/shell history
46
+ * by default.
47
+ */
48
+ export async function enroll(args) {
49
+ const asJson = parseFlag(args, "--json");
50
+ const printToken = parseFlag(args, "--print-token");
51
+ const skipClientConfig = parseFlag(args, "--skip-client-config");
52
+ const code = parseOptionValue(args, "--code");
53
+ const clientCode = parseOptionValue(args, "--client");
54
+ const clientVersion = parseOptionValue(args, "--client-version");
55
+ const host = parseOptionValue(args, "--host") ?? DEFAULT_EXCHANGE_HOST;
56
+ const outPath = parseOptionValue(args, "--out");
57
+ if (!code || !code.startsWith("nxe_")) {
58
+ throw new Error("Missing or invalid --code (expected a one-time code starting with 'nxe_' from the workspace UI).");
59
+ }
60
+ if (!clientCode) {
61
+ throw new Error("Missing --client (the client code configured for this enrollment, e.g. hermes-agent).");
62
+ }
63
+ const res = await fetch(`${host}/api/agent-enrollments/exchange`, {
64
+ method: "POST",
65
+ headers: { "Content-Type": "application/json" },
66
+ body: JSON.stringify({
67
+ code,
68
+ client: { code: clientCode, version: clientVersion ?? undefined },
69
+ host: { hostname: hostname(), platform: platform(), arch: arch() },
70
+ }),
71
+ });
72
+ const body = await res.json().catch(() => ({}));
73
+ if (!res.ok) {
74
+ const errorCode = typeof body.error === "string" ? body.error : null;
75
+ const message = (errorCode && ERROR_MESSAGES[errorCode]) ?? `Enrollment exchange failed (${errorCode ?? res.status}).`;
76
+ throw new Error(message);
77
+ }
78
+ const manifest = body;
79
+ const destination = outPath ?? join(homedir(), ".nexarch", "agent-credential.json");
80
+ mkdirSync(dirname(destination), { recursive: true });
81
+ writeFileSync(destination, JSON.stringify(manifest, null, 2), { mode: 0o600 });
82
+ // The gateway's JSON-RPC endpoint is always {mcp.url}/mcp — mcp.url on the
83
+ // manifest is the bare origin (e.g. https://mcp.nexarch.ai), matching the
84
+ // convention nexarch mcp-config already uses for the same gateway.
85
+ const mcpEndpoint = `${manifest.mcp.url.replace(/\/$/, "")}/mcp`;
86
+ let wroteClientConfig = false;
87
+ if (!skipClientConfig && manifest.envFilePath && manifest.mcpConfigPath && supportsAutoWrite(manifest.mcpConfigMergeStrategy)) {
88
+ writeClientConfig(manifest.mcpConfigMergeStrategy, {
89
+ envFilePath: expandHomePath(manifest.envFilePath),
90
+ mcpConfigPath: expandHomePath(manifest.mcpConfigPath),
91
+ serverKey: manifest.mcp.serverName,
92
+ envVar: manifest.mcp.auth.environmentVariable,
93
+ mcpUrl: mcpEndpoint,
94
+ token: manifest.credential.token,
95
+ });
96
+ wroteClientConfig = true;
97
+ }
98
+ if (asJson) {
99
+ const output = printToken ? manifest : redactedManifest(manifest);
100
+ process.stdout.write(`${JSON.stringify({ ...output, wroteClientConfig }, null, 2)}\n`);
101
+ return;
102
+ }
103
+ console.log(`✓ Enrolled agent ${manifest.agent.ref} in workspace "${manifest.workspace.name}"`);
104
+ console.log(` Scopes : ${manifest.credential.scopes.join(", ")}`);
105
+ console.log(` Expires : ${manifest.credential.expiresAt ?? "never"}`);
106
+ console.log(` MCP server : ${mcpEndpoint}`);
107
+ console.log(` Credential : saved to ${destination} (mode 600)`);
108
+ if (wroteClientConfig) {
109
+ console.log(`\n✓ Wired the connection into ${manifest.envFilePath} and ${manifest.mcpConfigPath}`);
110
+ }
111
+ else if (skipClientConfig) {
112
+ console.log(`\nSkipped client config (--skip-client-config passed).`);
113
+ }
114
+ else {
115
+ console.log(`\nNo known auto-write target for client "${clientCode}" — connect it manually:`);
116
+ }
117
+ const shownToken = printToken ? manifest.credential.token : `<see ${destination}>`;
118
+ if (manifest.connectInstructionsTemplate) {
119
+ console.log(renderInstructions(manifest.connectInstructionsTemplate, manifest, shownToken, mcpEndpoint));
120
+ }
121
+ else if (!wroteClientConfig) {
122
+ console.log(`Export ${manifest.mcp.auth.environmentVariable}=${shownToken} in the agent runtime's own environment, then`);
123
+ console.log(`point its MCP client at ${mcpEndpoint} (streamable-http, Authorization: Bearer \${${manifest.mcp.auth.environmentVariable}}).`);
124
+ }
125
+ if (printToken) {
126
+ console.log(`\nToken: ${manifest.credential.token}`);
127
+ }
128
+ }
@@ -8,7 +8,7 @@ import { requireCredentials } from "../lib/credentials.js";
8
8
  import { fetchAgentRegistryOrThrow } from "../lib/agent-registry.js";
9
9
  import { callMcpTool, mcpInitialize, mcpListTools } from "../lib/mcp.js";
10
10
  import { buildVersionAttributes } from "../lib/version-normalization.js";
11
- import { requestTrustAttestation } from "../lib/trust.js";
11
+ import { requestTrustAttestation, TRUST_ATTESTATION_SCOPE } from "../lib/trust.js";
12
12
  const CLI_VERSION = (() => {
13
13
  try {
14
14
  const pkg = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8"));
@@ -443,6 +443,30 @@ function injectAgentConfigs(registry, runtimeCodes, dryRun) {
443
443
  }
444
444
  return [];
445
445
  }
446
+ /**
447
+ * Whether the trust attestation already sitting in `path` still holds up: present, unexpired,
448
+ * and minted under the scope this build of the CLI currently mints under. A registration
449
+ * section can be byte-identical to the current template (so `injectAgentConfigs` reports
450
+ * `already_present` and writes nothing) while its neighbouring attestation was minted under an
451
+ * old scope value or has since expired — that staleness is independent of whether the
452
+ * registration prose changed, so it needs its own check rather than riding on
453
+ * `already_present` vs `updated`. See ADR-0112.
454
+ */
455
+ function isTrustAttestationStale(path) {
456
+ if (!existsSync(path))
457
+ return true;
458
+ const content = readFileSync(path, "utf8");
459
+ const match = content.match(/<!-- nexarch:trust-attestation:start -->\n([\s\S]*?)\n<!-- nexarch:trust-attestation:end -->/);
460
+ const body = match ? match[1] : content;
461
+ const scope = body.match(/^scope:\s*(\S+)\s*$/m)?.[1];
462
+ const expiresAt = body.match(/^expires_at:\s*(\S+)\s*$/m)?.[1];
463
+ if (!scope || !expiresAt)
464
+ return true;
465
+ if (scope !== TRUST_ATTESTATION_SCOPE)
466
+ return true;
467
+ const expiry = Date.parse(expiresAt);
468
+ return Number.isNaN(expiry) || expiry <= Date.now();
469
+ }
446
470
  function injectTrustAttestationBlock(path, attestation) {
447
471
  // The fallbacks read this very file, so they have to name it: the block is
448
472
  // injected into AGENTS.md and .cursorrules too, where a hardcoded CLAUDE.md
@@ -1258,14 +1282,17 @@ export async function initAgent(args) {
1258
1282
  // the dry-run result is accurate and no second pass is needed.
1259
1283
  agentConfigResults = existingInstructionTargets;
1260
1284
  }
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 changeso 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.
1285
+ // Attest files actually written (injected/updated) just now, plus any
1286
+ // "already_present" target whose existing attestation is itself stale
1287
+ // (missing, expired, or minted under an old scope)the registration
1288
+ // prose matching the template says nothing about whether the neighbouring
1289
+ // attestation is still current, so that's checked independently rather
1290
+ // than assumed from the write status. See ADR-0112 / isTrustAttestationStale.
1291
+ // When consent wasn't granted, nothing was written above —
1292
+ // `existingInstructionTargets` is dry-run data with no corresponding file
1293
+ // change — so there is nothing to attest.
1267
1294
  const attestationTargets = instructionsWriteAllowed
1268
- ? agentConfigResults.filter((r) => r.status === "injected" || r.status === "updated")
1295
+ ? agentConfigResults.filter((r) => r.status === "injected" || r.status === "updated" || isTrustAttestationStale(r.path))
1269
1296
  : [];
1270
1297
  if (attestationTargets.length > 0) {
1271
1298
  trustAttestationAttempted = true;
@@ -1338,7 +1365,9 @@ export async function initAgent(args) {
1338
1365
  : !instructionsWriteAllowed
1339
1366
  ? "skipped (consent not granted)"
1340
1367
  : !trustAttestationAttempted
1341
- ? "skipped (no instruction target written)"
1368
+ ? agentConfigResults.length > 0
1369
+ ? "already current (no refresh needed)"
1370
+ : "skipped (no instruction target written)"
1342
1371
  : trustAttestation?.ok
1343
1372
  ? "minted and injected into instruction file(s)"
1344
1373
  : `unavailable (${trustAttestation?.reason ?? "unknown"})`,
@@ -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
+ }
@@ -7,6 +7,8 @@ import { callMcpTool } from "../lib/mcp.js";
7
7
  import { buildVersionAttributes } from "../lib/version-normalization.js";
8
8
  import { detectInfrastructureProject } from "../lib/terraform-detect.js";
9
9
  import { runInfrastructureOnboarding } from "./init-project-infra.js";
10
+ import { detectDataProject } from "../lib/data-project-detect.js";
11
+ import { runDataProjectOnboarding } from "./init-project-data.js";
10
12
  // ─── Helpers ─────────────────────────────────────────────────────────────────
11
13
  function parseFlag(args, flag) {
12
14
  return args.includes(flag);
@@ -261,6 +263,55 @@ export function detectSourceRepository(dir) {
261
263
  canonicalRepoRef: providerCanonicalRepoRef(provider),
262
264
  };
263
265
  }
266
+ /**
267
+ * Project identity must survive re-ingestion from a differently-named
268
+ * checkout (H-05, docs/plans/data-architecture-plan.md 1.2): ten repositories
269
+ * became twenty projects because the only identity signal was the local
270
+ * folder name — `project:01_jaffle_shop` beside `project:jaffle_shop` for the
271
+ * same remote. Three tiers, tried in order, each a stronger signal than the
272
+ * one it falls back to:
273
+ *
274
+ * 1. git remote URL — survives a clone under any directory name. Matches
275
+ * against attributes.source_repository_url already recorded on this
276
+ * company's existing `project` entities.
277
+ * 2. registered entity_ref — a company-scoped alias from a prior run's
278
+ * resolveProjectIdentity call (registered by the caller after this
279
+ * returns), for repositories tier 1 cannot see: no hosted git remote
280
+ * (vendored archives, local-only git, a VCS with no stable URL).
281
+ * 3. directory slug — today's only mechanism. First contact with this
282
+ * repository under either signal.
283
+ */
284
+ export async function resolveProjectIdentity(companyId, detectedRepo, projectDirName, projectDirSlug) {
285
+ const fallbackKey = `project:${projectDirSlug}`;
286
+ if (detectedRepo?.url) {
287
+ try {
288
+ const raw = await callMcpTool("nexarch_list_entities", { entityTypeCode: "project", limit: 500, companyId }, { companyId });
289
+ const parsed = parseToolText(raw);
290
+ const match = (parsed.entities ?? []).find((e) => {
291
+ const attrs = e.attributes ?? {};
292
+ return attrs.source_repository_url === detectedRepo.url || attrs.repository_url === detectedRepo.url;
293
+ });
294
+ const matchedRef = match?.entityRef ?? match?.externalKey;
295
+ if (matchedRef)
296
+ return { key: matchedRef, source: "git_remote_url" };
297
+ }
298
+ catch {
299
+ // Best effort — an ontology or network hiccup here should not block
300
+ // the scan; fall through to the next tier.
301
+ }
302
+ }
303
+ try {
304
+ const raw = await callMcpTool("nexarch_resolve_reference", { names: [projectDirName], companyId }, { companyId });
305
+ const parsed = parseToolText(raw);
306
+ const hit = (parsed.results ?? []).find((r) => r.resolved && r.entityTypeCode === "project" && r.canonicalExternalRef);
307
+ if (hit?.canonicalExternalRef)
308
+ return { key: hit.canonicalExternalRef, source: "registered_entity_ref" };
309
+ }
310
+ catch {
311
+ // Best effort.
312
+ }
313
+ return { key: fallbackKey, source: "directory_slug" };
314
+ }
264
315
  // ─── Project scanning ─────────────────────────────────────────────────────────
265
316
  // Noise patterns for env var keys that are internal config, not external service references
266
317
  const ENV_KEY_NOISE = /^(NODE_ENV|PORT|HOST|DEBUG|LOG_LEVEL|TZ|LANG|PATH|HOME|USER|SHELL|TERM)$|(_LOG_LEVEL|_MAX_|_MIN_|_DEFAULT_|_TIMEOUT|_DELAY|_JOBS|_INTERVAL|_LIMIT|_RETRIES|_CONCURREN|_WORKERS)$|(_URL|_SECRET|_TOKEN|_KEY|_PASSWORD|_CREDENTIAL|_DSN|_URI)$/;
@@ -1023,8 +1074,15 @@ export async function loadAllowedLinks(companyId) {
1023
1074
  allowedLinks = new Set(links.map((l) => linkKey(l.fromEntityTypeCode, l.relationshipTypeCode, l.toEntityTypeCode)));
1024
1075
  return allowedLinks.size;
1025
1076
  }
1026
- catch {
1077
+ catch (error) {
1027
1078
  allowedLinks = new Set();
1079
+ // Still non-fatal — a scan that records slightly wrong relationships is
1080
+ // worth more than a scan that refuses to run because this one read
1081
+ // failed. But silent degradation is what let a scoped-token companyId
1082
+ // bug (M-05) go unnoticed: the relationship conformer just quietly
1083
+ // stopped narrowing anything, in exactly the CI context it exists for.
1084
+ const message = error instanceof Error ? error.message : String(error);
1085
+ console.error(`Warning: could not load the ingest contract — relationship conformance is disabled for this run (edges may be sent that the ontology refuses). Reason: ${message}`);
1028
1086
  return 0;
1029
1087
  }
1030
1088
  }
@@ -1042,7 +1100,15 @@ export async function loadAllowedLinks(companyId) {
1042
1100
  * invalidate a shipped CLI: the contract is read at run time, so the constraint
1043
1101
  * the gateway will apply is the constraint used to choose.
1044
1102
  */
1103
+ // project has zero legal outbound relationship types (verified live,
1104
+ // 2026-08-24 — ADR 8a models it as evidence, not architecture: nothing a
1105
+ // project scan discovers depends_on/runs_on/etc. from the project itself).
1106
+ // That is a permanent ontology fact, not a transient contract gap, so
1107
+ // there is nothing for a refusal to usefully explain (data-architecture-plan.md 1.2).
1108
+ const KNOWN_SOURCELESS_ENTITY_TYPES = new Set(["project"]);
1045
1109
  function conformRelationshipType(preferred, fromEntityTypeCode, toEntityTypeCode) {
1110
+ if (KNOWN_SOURCELESS_ENTITY_TYPES.has(fromEntityTypeCode))
1111
+ return null;
1046
1112
  if (allowedLinks.size === 0)
1047
1113
  return preferred;
1048
1114
  if (allowedLinks.has(linkKey(fromEntityTypeCode, preferred, toEntityTypeCode)))
@@ -1173,6 +1239,33 @@ export async function initProject(args) {
1173
1239
  const refreshMode = parseFlag(args, "--refresh");
1174
1240
  const creds = requireCredentials();
1175
1241
  const mcpOpts = { companyId: creds.companyId };
1242
+ // A data-pipeline repository (dbt, Airflow, Dagster, Dataform, ksqlDB,
1243
+ // Spark) is onboarded differently too: the npm/manifest dependency scan
1244
+ // below finds nothing meaningful in it (H-06, data-architecture-plan.md
1245
+ // 1.3). Checked ahead of infrastructure, not instead of it — a repo that
1246
+ // both runs a pipeline and provisions its own Terraform gets both facets
1247
+ // registered, because the first signal found deciding the whole
1248
+ // repository was exactly the module-library bug this mirrors.
1249
+ if (!parseFlag(args, "--as-application")) {
1250
+ const dataProject = detectDataProject(dir);
1251
+ if (dataProject.isDataProject) {
1252
+ await runDataProjectOnboarding({ dir, nameOverride, asJson, dryRun }, dataProject);
1253
+ const infrastructure = detectInfrastructureProject(dir);
1254
+ if (infrastructure.isInfrastructure) {
1255
+ if (!asJson)
1256
+ console.log(`\nAlso detected infrastructure in the same repository — ${infrastructure.signals.join(", ")}.`);
1257
+ await runInfrastructureOnboarding({
1258
+ dir,
1259
+ nameOverride,
1260
+ asJson,
1261
+ nonInteractive: parseFlag(args, "--non-interactive"),
1262
+ skipIngest: parseFlag(args, "--no-ingest"),
1263
+ dryRun,
1264
+ }, infrastructure);
1265
+ }
1266
+ return;
1267
+ }
1268
+ }
1176
1269
  // An infrastructure repository is onboarded differently: its content lives in
1177
1270
  // Terraform state, not in a manifest, so the dependency scan below would
1178
1271
  // describe the wrong thing entirely (ADR: infrastructure-as-code ingestion).
@@ -1213,7 +1306,18 @@ export async function initProject(args) {
1213
1306
  const isMonorepo = subPackages.length > 0;
1214
1307
  const projectDirName = nameOverride ?? basename(dir);
1215
1308
  const projectDirSlug = slugify(projectDirName);
1216
- const projectEntityKey = `project:${projectDirSlug}`;
1309
+ // H-05 (data-architecture-plan.md 1.2): resolve identity before minting a
1310
+ // key from the directory name, so re-ingestion from a differently-named
1311
+ // checkout reuses the project this company already has.
1312
+ const projectIdentity = projectConstruct
1313
+ ? await resolveProjectIdentity(creds.companyId, detectedRepo, projectDirName, projectDirSlug)
1314
+ : { key: `project:${projectDirSlug}`, source: "directory_slug" };
1315
+ const projectEntityKey = projectIdentity.key;
1316
+ if (projectConstruct && projectIdentity.source !== "directory_slug") {
1317
+ logProgress("project.identity", `${projectIdentity.source} -> ${projectEntityKey}`);
1318
+ if (!asJson)
1319
+ console.log(` Identity: matched existing project via ${projectIdentity.source.replace(/_/g, " ")} (${projectEntityKey})`);
1320
+ }
1217
1321
  // Compute sub-package external keys now that projectSlug is known.
1218
1322
  // Unscoped names (no "/") get prefixed with the project slug to avoid ambiguous keys
1219
1323
  // like "application:crawler" — they become "application:whatsontap-crawler" instead,
@@ -1571,8 +1675,11 @@ export async function initProject(args) {
1571
1675
  continue;
1572
1676
  if (!rootDepNames.has(r.input) && !rootDepNames.has(r.normalised))
1573
1677
  continue;
1678
+ const relType = conformRelationshipType(pickRelationshipType(r.entityTypeCode, r.entitySubtypeCode), entityTypeOverride, r.entityTypeCode);
1679
+ if (!relType)
1680
+ continue;
1574
1681
  const depSpec = rootDepVersions.get(r.input) ?? rootDepVersions.get(r.normalised);
1575
- addRel(conformRelationshipType(pickRelationshipType(r.entityTypeCode, r.entitySubtypeCode), entityTypeOverride, r.entityTypeCode), projectExternalKey, r.canonicalExternalRef, 0.9, {
1682
+ addRel(relType, projectExternalKey, r.canonicalExternalRef, 0.9, {
1576
1683
  source: depSpec?.source ?? "manifest_scan",
1577
1684
  detected_at: nowIso,
1578
1685
  ...buildVersionAttributes(depSpec?.versionRaw ?? null, depSpec?.source ?? "manifest_scan"),
@@ -1602,7 +1709,10 @@ export async function initProject(args) {
1602
1709
  for (const dep of sp.depSpecs) {
1603
1710
  const r = resolvedByInput.get(dep.name);
1604
1711
  if (r?.canonicalExternalRef && r.entityTypeCode) {
1605
- addRel(conformRelationshipType(pickRelationshipType(r.entityTypeCode, r.entitySubtypeCode, sp.entityType), sp.entityType, r.entityTypeCode), sp.externalKey, r.canonicalExternalRef, 0.9, {
1712
+ const relType = conformRelationshipType(pickRelationshipType(r.entityTypeCode, r.entitySubtypeCode, sp.entityType), sp.entityType, r.entityTypeCode);
1713
+ if (!relType)
1714
+ continue;
1715
+ addRel(relType, sp.externalKey, r.canonicalExternalRef, 0.9, {
1606
1716
  source: dep.source,
1607
1717
  detected_at: nowIso,
1608
1718
  ...buildVersionAttributes(dep.versionRaw, dep.source),
@@ -1691,6 +1801,25 @@ export async function initProject(args) {
1691
1801
  logProgress("upsert.entities.batch.done", `${i + 1}/${entityChunks.length} succeeded=${chunkResult.summary?.succeeded ?? 0}, failed=${chunkResult.summary?.failed ?? 0}`);
1692
1802
  }
1693
1803
  logProgress("upsert.entities.done", `succeeded=${entitiesResult.summary?.succeeded ?? 0}, failed=${entitiesResult.summary?.failed ?? 0}`);
1804
+ // Register this directory name as an alias for the resolved project key,
1805
+ // so a future run from a renamed checkout with no hosted git remote can
1806
+ // still find this project via resolveProjectIdentity's tier 2 (H-05).
1807
+ // Best effort: a failure here should not fail an otherwise-successful scan.
1808
+ if (projectConstruct && (entitiesResult.summary?.succeeded ?? 0) > 0) {
1809
+ try {
1810
+ await callMcpTool("nexarch_register_alias", {
1811
+ alias: projectDirName,
1812
+ canonicalExternalKey: projectEntityKey,
1813
+ canonicalName: projectDirName,
1814
+ entityTypeCode: "project",
1815
+ companyId: creds.companyId,
1816
+ }, { companyId: creds.companyId });
1817
+ }
1818
+ catch {
1819
+ // Non-fatal — this only affects a future differently-named checkout's
1820
+ // ability to resolve via tier 2; the scan itself already succeeded.
1821
+ }
1822
+ }
1694
1823
  // Upsert relationships (chunked)
1695
1824
  let relsResult = null;
1696
1825
  if (relationships.length > 0) {
@@ -9,6 +9,86 @@ import { forwardToGateway } from "../lib/mcp.js";
9
9
  * Each line from stdin is a complete JSON-RPC message.
10
10
  * Responses are written to stdout as single-line JSON.
11
11
  */
12
+ const MAX_ERROR_DATA_JSON_LENGTH = 8000;
13
+ const FORBIDDEN_KEY_PATTERN = /token|cookie|password|secret|authorization|bearer|stack/i;
14
+ function containsForbiddenKey(value) {
15
+ if (value === null || typeof value !== "object")
16
+ return false;
17
+ if (Array.isArray(value))
18
+ return value.some(containsForbiddenKey);
19
+ return Object.entries(value).some(([key, val]) => FORBIDDEN_KEY_PATTERN.test(key) || containsForbiddenKey(val));
20
+ }
21
+ function sanitizeErrorData(data) {
22
+ if (data === undefined)
23
+ return undefined;
24
+ if (containsForbiddenKey(data))
25
+ return { redacted: true, reason: "error detail withheld" };
26
+ const serialized = JSON.stringify(data);
27
+ if (serialized !== undefined && serialized.length > MAX_ERROR_DATA_JSON_LENGTH) {
28
+ return { truncated: true, reason: "error detail exceeded size limit" };
29
+ }
30
+ return data;
31
+ }
32
+ function isSafeJsonRpcErrorBody(parsed) {
33
+ if (!parsed || typeof parsed !== "object")
34
+ return false;
35
+ const err = parsed.error;
36
+ if (!err || typeof err !== "object")
37
+ return false;
38
+ const errObj = err;
39
+ return typeof errObj.code === "number" && typeof errObj.message === "string";
40
+ }
41
+ /**
42
+ * Builds the JSON-RPC error response written to stdout for a non-200
43
+ * gateway response. The gateway already returns detailed JSON-RPC error
44
+ * bodies (field paths, error codes, domain-specific detail) on every
45
+ * non-200 status — this forwards that body verbatim (sanitized and
46
+ * size-bounded) instead of collapsing it into "Gateway error (HTTP N)".
47
+ * A generic fallback is used only when the body is absent, unparseable,
48
+ * or doesn't look like a safe JSON-RPC error shape.
49
+ */
50
+ export function buildProxyErrorResponse(requestId, status, rawBody) {
51
+ let parsed = null;
52
+ if (rawBody && rawBody.trim()) {
53
+ try {
54
+ parsed = JSON.parse(rawBody);
55
+ }
56
+ catch {
57
+ parsed = null;
58
+ }
59
+ }
60
+ if (isSafeJsonRpcErrorBody(parsed)) {
61
+ const data = sanitizeErrorData(parsed.error.data);
62
+ if (status === 401) {
63
+ return JSON.stringify({
64
+ jsonrpc: "2.0",
65
+ id: requestId ?? null,
66
+ error: {
67
+ code: -32001,
68
+ message: `Unauthorized — run \`nexarch login\` to re-authenticate. (${parsed.error.message})`,
69
+ ...(data !== undefined ? { data } : {}),
70
+ },
71
+ });
72
+ }
73
+ return JSON.stringify({
74
+ jsonrpc: "2.0",
75
+ id: requestId ?? null,
76
+ error: { code: parsed.error.code, message: parsed.error.message, ...(data !== undefined ? { data } : {}) },
77
+ });
78
+ }
79
+ if (status === 401) {
80
+ return JSON.stringify({
81
+ jsonrpc: "2.0",
82
+ id: requestId ?? null,
83
+ error: { code: -32001, message: "Unauthorized — run `nexarch login` to re-authenticate." },
84
+ });
85
+ }
86
+ return JSON.stringify({
87
+ jsonrpc: "2.0",
88
+ id: requestId ?? null,
89
+ error: { code: -32000, message: `Gateway error (HTTP ${status})` },
90
+ });
91
+ }
12
92
  export async function mcpProxy(_args) {
13
93
  const creds = requireCredentials();
14
94
  const rl = readline.createInterface({
@@ -45,21 +125,8 @@ export async function mcpProxy(_args) {
45
125
  // Write response as a single line.
46
126
  process.stdout.write(body.replace(/\n/g, " ") + "\n");
47
127
  }
48
- else if (status === 401) {
49
- const errorResponse = JSON.stringify({
50
- jsonrpc: "2.0",
51
- id: message.id ?? null,
52
- error: { code: -32001, message: "Unauthorized — run `nexarch login` to re-authenticate." },
53
- });
54
- process.stdout.write(errorResponse + "\n");
55
- }
56
128
  else {
57
- const errorResponse = JSON.stringify({
58
- jsonrpc: "2.0",
59
- id: message.id ?? null,
60
- error: { code: -32000, message: `Gateway error (HTTP ${status})` },
61
- });
62
- process.stdout.write(errorResponse + "\n");
129
+ process.stdout.write(buildProxyErrorResponse(message.id, status, body) + "\n");
63
130
  }
64
131
  }
65
132
  catch (err) {
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { login } from "./commands/login.js";
3
+ import { enroll } from "./commands/enroll.js";
3
4
  import { logout } from "./commands/logout.js";
4
5
  import { status } from "./commands/status.js";
5
6
  import { setup } from "./commands/setup.js";
@@ -29,9 +30,17 @@ import { registerRuntime } from "./commands/register-runtime.js";
29
30
  import { ingestInfra } from "./commands/ingest-infra.js";
30
31
  import { verifyTrust } from "./commands/verify-trust.js";
31
32
  import { cliBuild, formatBuild } from "./lib/version.js";
33
+ import { checkForUpdate, printUpdateNoticeIfReady } from "./lib/update-check.js";
32
34
  const [, , command, ...args] = process.argv;
35
+ // Kicked off before anything else so it rides alongside whatever network
36
+ // calls the command itself makes, rather than adding its own delay — see
37
+ // update-check.ts. Skipped for mcp-proxy: that command's stdout/stderr is a
38
+ // live JSON-RPC stream to an MCP client, and even a stray stderr line has no
39
+ // safe place to land there.
40
+ const updateCheck = command === "mcp-proxy" ? null : checkForUpdate();
33
41
  const commands = {
34
42
  login,
43
+ enroll,
35
44
  logout,
36
45
  status,
37
46
  setup,
@@ -96,6 +105,21 @@ Usage:
96
105
  nexarch login Authenticate in browser and store company-scoped credentials
97
106
  Option: --company <id>
98
107
  nexarch --version Print the version and the path this copy runs from
108
+ nexarch enroll Bootstrap a headless (browserless) agent with a one-time
109
+ enrollment code — no login required. Writes the resulting
110
+ credential to ~/.nexarch/agent-credential.json (mode 600)
111
+ rather than printing it. For a client with a known config
112
+ format (e.g. hermes-agent), also writes the connection
113
+ directly into that client's own env/config files; for
114
+ anything else, prints instructions instead.
115
+ Options: --code <code> (required, starts with nxe_)
116
+ --client <code> (required, e.g. hermes-agent)
117
+ --client-version <v>
118
+ --host <baseUrl> (default: https://www.nexarch.ai)
119
+ --out <path> write credential here instead
120
+ --skip-client-config don't touch the client's own files
121
+ --print-token also print the token to stdout
122
+ --json
99
123
  nexarch logout Remove stored credentials
100
124
  nexarch status Check connection and show architecture summary
101
125
  nexarch verify-trust Verify this repo's Nexarch trust attestation. Reads the token
@@ -295,14 +319,20 @@ Usage:
295
319
  audit rollup (latest run status, pass/partial/fail counts).
296
320
  Options: --json
297
321
  `);
322
+ if (updateCheck)
323
+ await printUpdateNoticeIfReady(updateCheck);
298
324
  process.exit(command ? 1 : 0);
299
325
  }
300
326
  try {
301
327
  await handler(args);
328
+ if (updateCheck)
329
+ await printUpdateNoticeIfReady(updateCheck);
302
330
  }
303
331
  catch (err) {
304
332
  const message = err instanceof Error ? err.message : String(err);
305
333
  console.error(`error: ${message}`);
334
+ if (updateCheck)
335
+ await printUpdateNoticeIfReady(updateCheck);
306
336
  process.exit(1);
307
337
  }
308
338
  }
@@ -0,0 +1,61 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import { parseDocument } from "yaml";
5
+ export function expandHomePath(path) {
6
+ if (path.startsWith("~/") || path === "~") {
7
+ return join(homedir(), path.slice(1));
8
+ }
9
+ return path;
10
+ }
11
+ /**
12
+ * Replaces the line for `key=` if present, otherwise appends it — never
13
+ * touches any other line, so unrelated vars in an existing .env survive.
14
+ */
15
+ function upsertEnvVar(filePath, key, value) {
16
+ mkdirSync(dirname(filePath), { recursive: true });
17
+ const existing = existsSync(filePath) ? readFileSync(filePath, "utf8") : "";
18
+ const lines = existing.length > 0 ? existing.split(/\r?\n/) : [];
19
+ const lineIndex = lines.findIndex((line) => line.startsWith(`${key}=`));
20
+ const newLine = `${key}=${value}`;
21
+ if (lineIndex >= 0) {
22
+ lines[lineIndex] = newLine;
23
+ }
24
+ else {
25
+ if (lines.length > 0 && lines[lines.length - 1].trim() !== "")
26
+ lines.push("");
27
+ lines.push(newLine);
28
+ }
29
+ const out = lines.join("\n").replace(/\n*$/, "\n");
30
+ writeFileSync(filePath, out, { mode: 0o600 });
31
+ }
32
+ /**
33
+ * Merges one `mcp_servers.<serverKey>` entry into a YAML document, preserving
34
+ * every other key, comment, and formatting choice already in the file — this
35
+ * is someone else's live config, not a file this CLI owns. `${VAR}` in the
36
+ * Authorization header is left as a literal template string; the client
37
+ * itself (Hermes) resolves it from its own env at connect time, so the raw
38
+ * token is never written into this file.
39
+ */
40
+ function yamlUrlBearerStyle(configPath, serverKey, envVar, mcpUrl) {
41
+ mkdirSync(dirname(configPath), { recursive: true });
42
+ const existing = existsSync(configPath) ? readFileSync(configPath, "utf8") : "";
43
+ const doc = parseDocument(existing);
44
+ doc.setIn(["mcp_servers", serverKey, "url"], mcpUrl);
45
+ doc.setIn(["mcp_servers", serverKey, "headers", "Authorization"], `Bearer \${${envVar}}`);
46
+ writeFileSync(configPath, doc.toString(), { mode: 0o600 });
47
+ }
48
+ const STRATEGIES = {
49
+ yaml_url_bearer_style: (t) => yamlUrlBearerStyle(t.mcpConfigPath, t.serverKey, t.envVar, t.mcpUrl),
50
+ };
51
+ export function supportsAutoWrite(mergeStrategy) {
52
+ return Boolean(mergeStrategy && STRATEGIES[mergeStrategy]);
53
+ }
54
+ /** Throws if mergeStrategy isn't recognized — check supportsAutoWrite first. */
55
+ export function writeClientConfig(mergeStrategy, targets) {
56
+ const strategy = STRATEGIES[mergeStrategy];
57
+ if (!strategy)
58
+ throw new Error(`Unknown mcp_config_merge_strategy: ${mergeStrategy}`);
59
+ upsertEnvVar(targets.envFilePath, targets.envVar, targets.token);
60
+ strategy(targets);
61
+ }
@@ -0,0 +1,204 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from "fs";
2
+ import { join } from "path";
3
+ const IGNORED_DIRS = new Set(["node_modules", ".git", ".venv", "venv", "__pycache__", "dist", "build", ".next", "target"]);
4
+ const SCAN_DEPTH = 4;
5
+ function slugify(name) {
6
+ return name.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
7
+ }
8
+ function safeReaddir(dir) {
9
+ try {
10
+ return readdirSync(dir);
11
+ }
12
+ catch {
13
+ return [];
14
+ }
15
+ }
16
+ function safeReadFile(path) {
17
+ try {
18
+ return readFileSync(path, "utf8");
19
+ }
20
+ catch {
21
+ return "";
22
+ }
23
+ }
24
+ function isDir(path) {
25
+ try {
26
+ return statSync(path).isDirectory();
27
+ }
28
+ catch {
29
+ return false;
30
+ }
31
+ }
32
+ /** Manifest text worth grep-checking for a runtime dependency: requirements.txt, pyproject.toml, setup.py, build.sbt, pom.xml. */
33
+ function readPythonManifests(dir) {
34
+ return ["requirements.txt", "requirements-dev.txt", "pyproject.toml", "setup.py", "Pipfile"]
35
+ .map((f) => safeReadFile(join(dir, f)))
36
+ .join("\n");
37
+ }
38
+ /** Finds a directory by name, searched breadth-first up to SCAN_DEPTH so a nested layout (e.g. `orchestration/dags/`) is still found. */
39
+ function findDirectory(root, name, depth = 0) {
40
+ if (depth > SCAN_DEPTH)
41
+ return null;
42
+ const entries = safeReaddir(root);
43
+ if (entries.includes(name) && isDir(join(root, name)))
44
+ return join(root, name);
45
+ for (const entry of entries) {
46
+ if (IGNORED_DIRS.has(entry))
47
+ continue;
48
+ const full = join(root, entry);
49
+ if (isDir(full)) {
50
+ const found = findDirectory(full, name, depth + 1);
51
+ if (found)
52
+ return found;
53
+ }
54
+ }
55
+ return null;
56
+ }
57
+ /** Finds a file by exact name, searched breadth-first up to SCAN_DEPTH. */
58
+ function findFile(root, name, depth = 0) {
59
+ if (depth > SCAN_DEPTH)
60
+ return null;
61
+ const entries = safeReaddir(root);
62
+ if (entries.includes(name) && !isDir(join(root, name)))
63
+ return join(root, name);
64
+ for (const entry of entries) {
65
+ if (IGNORED_DIRS.has(entry))
66
+ continue;
67
+ const full = join(root, entry);
68
+ if (isDir(full)) {
69
+ const found = findFile(full, name, depth + 1);
70
+ if (found)
71
+ return found;
72
+ }
73
+ }
74
+ return null;
75
+ }
76
+ function collectFilesByExtension(dir, extensions, repoRoot, depth = 0, out = [], subtypeCode = "app_comp_data_pipeline") {
77
+ if (depth > SCAN_DEPTH)
78
+ return out;
79
+ for (const entry of safeReaddir(dir)) {
80
+ if (IGNORED_DIRS.has(entry))
81
+ continue;
82
+ const full = join(dir, entry);
83
+ if (isDir(full)) {
84
+ collectFilesByExtension(full, extensions, repoRoot, depth + 1, out, subtypeCode);
85
+ continue;
86
+ }
87
+ if (!extensions.some((ext) => entry.endsWith(ext)))
88
+ continue;
89
+ if (entry === "schema.yml" || entry === "sources.yml" || entry === "__init__.py")
90
+ continue;
91
+ const relativePath = full.slice(repoRoot.length + 1).split("\\").join("/");
92
+ const baseName = entry.replace(/\.[^.]+$/, "");
93
+ out.push({ slug: slugify(baseName), name: baseName, relativePath, subtypeCode });
94
+ }
95
+ return out;
96
+ }
97
+ function detectDbt(dir) {
98
+ const manifest = findFile(dir, "dbt_project.yml");
99
+ if (!manifest)
100
+ return null;
101
+ const root = join(manifest, "..");
102
+ const modelsDir = findDirectory(root, "models") ?? join(root, "models");
103
+ const signals = [`dbt_project.yml at ${manifest.slice(dir.length + 1)}`];
104
+ const components = existsSync(modelsDir) ? collectFilesByExtension(modelsDir, [".sql"], dir) : [];
105
+ if (existsSync(modelsDir))
106
+ signals.push(`${components.length} model(s) under ${modelsDir.slice(dir.length + 1)}`);
107
+ return { isDataProject: true, runtime: "dbt", signals, components };
108
+ }
109
+ function detectAirflow(dir) {
110
+ const dagsDir = findDirectory(dir, "dags");
111
+ const hasAirflowCfg = Boolean(findFile(dir, "airflow.cfg"));
112
+ const hasAirflowDep = /apache-airflow/i.test(readPythonManifests(dir));
113
+ if (!dagsDir && !hasAirflowCfg)
114
+ return null;
115
+ if (dagsDir && !hasAirflowDep && !hasAirflowCfg)
116
+ return null; // a bare "dags" dir with no corroboration is too weak to claim
117
+ const signals = [];
118
+ if (dagsDir)
119
+ signals.push(`dags/ at ${dagsDir.slice(dir.length + 1)}`);
120
+ if (hasAirflowCfg)
121
+ signals.push("airflow.cfg present");
122
+ if (hasAirflowDep)
123
+ signals.push("apache-airflow in manifest");
124
+ const components = dagsDir ? collectFilesByExtension(dagsDir, [".py"], dir, 0, [], "orchestrator_component") : [];
125
+ if (dagsDir)
126
+ signals.push(`${components.length} DAG file(s)`);
127
+ return { isDataProject: true, runtime: "airflow", signals, components };
128
+ }
129
+ function detectDagster(dir) {
130
+ const workspaceYaml = findFile(dir, "workspace.yaml");
131
+ const hasDagsterDep = /\bdagster\b/i.test(readPythonManifests(dir));
132
+ const definitionsFile = findFile(dir, "definitions.py") ?? findFile(dir, "repository.py");
133
+ if (!workspaceYaml && !(hasDagsterDep && definitionsFile))
134
+ return null;
135
+ const signals = [];
136
+ if (workspaceYaml)
137
+ signals.push(`workspace.yaml at ${workspaceYaml.slice(dir.length + 1)}`);
138
+ if (hasDagsterDep)
139
+ signals.push("dagster in manifest");
140
+ if (definitionsFile)
141
+ signals.push(`definitions at ${definitionsFile.slice(dir.length + 1)}`);
142
+ const assetsDir = findDirectory(dir, "assets");
143
+ const components = assetsDir ? collectFilesByExtension(assetsDir, [".py"], dir) : [];
144
+ if (assetsDir)
145
+ signals.push(`${components.length} asset file(s) under ${assetsDir.slice(dir.length + 1)}`);
146
+ return { isDataProject: true, runtime: "dagster", signals, components };
147
+ }
148
+ function detectDataform(dir) {
149
+ const configFile = findFile(dir, "workflow_settings.yaml") ?? findFile(dir, "dataform.json");
150
+ if (!configFile)
151
+ return null;
152
+ const definitionsDir = findDirectory(dir, "definitions");
153
+ const signals = [`${configFile.slice(dir.length + 1)} present`];
154
+ const components = definitionsDir ? collectFilesByExtension(definitionsDir, [".sqlx", ".sql", ".js"], dir) : [];
155
+ if (definitionsDir)
156
+ signals.push(`${components.length} definition(s) under ${definitionsDir.slice(dir.length + 1)}`);
157
+ return { isDataProject: true, runtime: "dataform", signals, components };
158
+ }
159
+ function detectKsqlDb(dir) {
160
+ const propsFile = findFile(dir, "ksql-server.properties") ?? findFile(dir, "ksqldb-server.properties");
161
+ const ksqlFiles = collectFilesByExtension(dir, [".ksql"], dir);
162
+ if (!propsFile && ksqlFiles.length === 0)
163
+ return null;
164
+ const signals = [];
165
+ if (propsFile)
166
+ signals.push(`${propsFile.slice(dir.length + 1)} present`);
167
+ if (ksqlFiles.length > 0)
168
+ signals.push(`${ksqlFiles.length} .ksql file(s)`);
169
+ return { isDataProject: true, runtime: "ksqldb", signals, components: ksqlFiles };
170
+ }
171
+ function detectSpark(dir) {
172
+ const buildSbt = safeReadFile(join(dir, "build.sbt"));
173
+ const pom = safeReadFile(join(dir, "pom.xml"));
174
+ const pyManifest = readPythonManifests(dir);
175
+ const hasSparkDep = /org\.apache\.spark/i.test(buildSbt) || /org\.apache\.spark/i.test(pom) || /\bpyspark\b/i.test(pyManifest);
176
+ if (!hasSparkDep)
177
+ return null;
178
+ // Spark is a general-purpose engine used inside plenty of non-pipeline
179
+ // applications; requiring a conventional jobs directory too keeps a
180
+ // service that merely calls into Spark from being reclassified.
181
+ const jobsDir = findDirectory(dir, "jobs") ?? findDirectory(dir, "spark_jobs");
182
+ if (!jobsDir)
183
+ return null;
184
+ const signals = ["Spark dependency declared", `jobs directory at ${jobsDir.slice(dir.length + 1)}`];
185
+ const components = collectFilesByExtension(jobsDir, [".py", ".scala"], dir);
186
+ signals.push(`${components.length} job file(s)`);
187
+ return { isDataProject: true, runtime: "spark", signals, components };
188
+ }
189
+ const DETECTORS = [detectDbt, detectAirflow, detectDagster, detectDataform, detectKsqlDb, detectSpark];
190
+ /**
191
+ * Detects the first matching data runtime. Order matters only for the rare
192
+ * repository combining two markers (e.g. a Dagster project orchestrating dbt
193
+ * models) — dbt is checked first because `dbt_project.yml` is the least
194
+ * ambiguous marker of the six, and the dbt models are what actually holds
195
+ * the domain content worth graphing.
196
+ */
197
+ export function detectDataProject(dir) {
198
+ for (const detector of DETECTORS) {
199
+ const result = detector(dir);
200
+ if (result)
201
+ return result;
202
+ }
203
+ return { isDataProject: false, runtime: null, signals: [], components: [] };
204
+ }
package/dist/lib/mcp.js CHANGED
@@ -33,7 +33,13 @@ async function requestOnce(method, params, options) {
33
33
  headers: {
34
34
  "Content-Type": "application/json",
35
35
  Authorization: `Bearer ${creds.token}`,
36
- "x-company-id": companyId,
36
+ // Scoped tokens carry their company_id server-side (see
37
+ // auth.ts's resolveCompanyId) and never populate creds.companyId
38
+ // locally, so an empty header here is the expected normal case —
39
+ // sending it as a literal empty string, rather than omitting it,
40
+ // used to matter for legacy static tokens where the header is the
41
+ // only source of truth. Omit rather than send "".
42
+ ...(companyId ? { "x-company-id": companyId } : {}),
37
43
  "Content-Length": Buffer.byteLength(body),
38
44
  },
39
45
  timeout: REQUEST_TIMEOUT_MS,
@@ -93,7 +99,16 @@ async function callMcpRpc(method, params = {}, options = {}) {
93
99
  throw lastError instanceof Error ? lastError : new Error(String(lastError ?? "MCP call failed"));
94
100
  }
95
101
  export async function callMcpTool(toolName, toolArgs = {}, options = {}) {
96
- return callMcpRpc("tools/call", { name: toolName, arguments: toolArgs }, options);
102
+ // Every gateway tool schema declares companyId as z.string().uuid().optional() —
103
+ // "optional" means absent, not empty. Call sites pass creds.companyId
104
+ // unconditionally, which is "" for a scoped token (see credentials.ts);
105
+ // sent as a literal "", that fails .uuid() and the tool call is refused
106
+ // outright instead of falling back to the header/token-derived company.
107
+ // Single choke point: every call site funnels through here.
108
+ const toolArguments = { ...toolArgs };
109
+ if (toolArguments.companyId === "")
110
+ delete toolArguments.companyId;
111
+ return callMcpRpc("tools/call", { name: toolName, arguments: toolArguments }, options);
97
112
  }
98
113
  export async function mcpInitialize(options = {}) {
99
114
  return callMcpRpc("initialize", {
package/dist/lib/trust.js CHANGED
@@ -1,6 +1,9 @@
1
1
  import https from "https";
2
2
  import { requireCredentials } from "./credentials.js";
3
3
  const MCP_GATEWAY_URL = "https://mcp.nexarch.ai";
4
+ /** The scope every attestation is minted under — see ADR-0112. Exported so callers can tell a
5
+ * stored attestation's scope apart from what would be minted today, without duplicating the literal. */
6
+ export const TRUST_ATTESTATION_SCOPE = "agent_config_write";
4
7
  /**
5
8
  * `contentHash` (sha256 hex of the exact managed-section text being written)
6
9
  * binds the signature to the instructions themselves, not just to the claim
@@ -11,7 +14,7 @@ export async function requestTrustAttestation(agentId, contentHash) {
11
14
  const creds = requireCredentials();
12
15
  const body = JSON.stringify({
13
16
  agentId,
14
- scope: "agent_config_write",
17
+ scope: TRUST_ATTESTATION_SCOPE,
15
18
  ...(contentHash ? { contentHash } : {}),
16
19
  });
17
20
  return new Promise((resolve) => {
@@ -0,0 +1,109 @@
1
+ import { homedir } from "os";
2
+ import { join } from "path";
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
4
+ import { cliVersion } from "./version.js";
5
+ /**
6
+ * Best-effort "a newer version exists" advisory (feature request #1).
7
+ *
8
+ * Checked once per day per machine, not on every invocation — a network call
9
+ * on every single command would make the CLI feel like it always has a
10
+ * network dependency, when almost nothing else about a status check does.
11
+ * The result is cached in the same ~/.nexarch directory as credentials.json,
12
+ * so most invocations skip the network entirely and this costs nothing.
13
+ *
14
+ * Kicked off at the very start of the process and only awaited (with a short
15
+ * cap) right before the CLI would otherwise exit, so it rides alongside
16
+ * whatever network calls the command itself is already making rather than
17
+ * adding its own visible delay.
18
+ */
19
+ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
20
+ const FETCH_TIMEOUT_MS = 1500;
21
+ /** How long we're willing to wait for an in-flight check before giving up on printing it this run. */
22
+ const PRINT_GRACE_MS = 200;
23
+ function cacheDir() {
24
+ return join(homedir(), ".nexarch");
25
+ }
26
+ function cachePath() {
27
+ return join(cacheDir(), "update-check.json");
28
+ }
29
+ function readCache() {
30
+ try {
31
+ if (!existsSync(cachePath()))
32
+ return null;
33
+ const parsed = JSON.parse(readFileSync(cachePath(), "utf8"));
34
+ if (typeof parsed.lastCheckedAt !== "string" || typeof parsed.latestVersion !== "string")
35
+ return null;
36
+ return parsed;
37
+ }
38
+ catch {
39
+ return null;
40
+ }
41
+ }
42
+ function writeCache(cache) {
43
+ try {
44
+ mkdirSync(cacheDir(), { recursive: true });
45
+ writeFileSync(cachePath(), JSON.stringify(cache), "utf8");
46
+ }
47
+ catch {
48
+ // Best effort — a cache write failure should never surface to the user.
49
+ }
50
+ }
51
+ async function fetchLatestVersion() {
52
+ const controller = new AbortController();
53
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
54
+ try {
55
+ const res = await fetch("https://registry.npmjs.org/nexarch/latest", { signal: controller.signal });
56
+ if (!res.ok)
57
+ return null;
58
+ const body = (await res.json());
59
+ return typeof body.version === "string" ? body.version : null;
60
+ }
61
+ catch {
62
+ // Offline, registry down, DNS failure, timeout — all the same outcome: say nothing.
63
+ return null;
64
+ }
65
+ finally {
66
+ clearTimeout(timer);
67
+ }
68
+ }
69
+ /** Plain numeric semver compare — good enough for release versions, no pre-release handling needed. */
70
+ function isNewer(latest, current) {
71
+ const a = latest.split(".").map((n) => Number.parseInt(n, 10) || 0);
72
+ const b = current.split(".").map((n) => Number.parseInt(n, 10) || 0);
73
+ for (let i = 0; i < Math.max(a.length, b.length); i += 1) {
74
+ const x = a[i] ?? 0;
75
+ const y = b[i] ?? 0;
76
+ if (x !== y)
77
+ return x > y;
78
+ }
79
+ return false;
80
+ }
81
+ /**
82
+ * Starts the check immediately; resolves to update info only when the
83
+ * installed copy is actually behind. Never rejects.
84
+ */
85
+ export async function checkForUpdate() {
86
+ const current = cliVersion();
87
+ const cache = readCache();
88
+ const cacheIsFresh = cache && Date.now() - new Date(cache.lastCheckedAt).getTime() < CHECK_INTERVAL_MS;
89
+ const latest = cacheIsFresh ? cache.latestVersion : await fetchLatestVersion();
90
+ if (!cacheIsFresh && latest) {
91
+ writeCache({ lastCheckedAt: new Date().toISOString(), latestVersion: latest });
92
+ }
93
+ if (!latest || !isNewer(latest, current))
94
+ return null;
95
+ return { current, latest };
96
+ }
97
+ /**
98
+ * Awaits an in-flight check with a short grace period and prints the
99
+ * advisory to stderr if it's ready and outdated — stderr so it never
100
+ * contaminates a command's --json stdout for an agent caller parsing it.
101
+ */
102
+ export async function printUpdateNoticeIfReady(pending) {
103
+ const timeout = new Promise((resolve) => setTimeout(() => resolve(null), PRINT_GRACE_MS));
104
+ const info = await Promise.race([pending, timeout]);
105
+ if (!info)
106
+ return;
107
+ process.stderr.write(`\nA newer version of nexarch is available: ${info.current} -> ${info.latest}\n` +
108
+ `Run with npx nexarch@latest to use it — npx always fetches latest, nothing to install.\n`);
109
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexarch",
3
- "version": "0.12.23",
3
+ "version": "0.12.26",
4
4
  "description": "Your architecture workspace for AI delivery.",
5
5
  "keywords": [
6
6
  "nexarch",
@@ -25,11 +25,15 @@
25
25
  "build": "tsc",
26
26
  "prepublishOnly": "tsc",
27
27
  "dev": "tsx src/index.ts",
28
- "typecheck": "tsc --noEmit"
28
+ "typecheck": "tsc --noEmit",
29
+ "test": "tsx scripts/test-mcp-proxy-response.ts"
29
30
  },
30
31
  "devDependencies": {
31
32
  "@types/node": "^22",
32
33
  "tsx": "^4",
33
34
  "typescript": "^5"
35
+ },
36
+ "dependencies": {
37
+ "yaml": "^2.9.0"
34
38
  }
35
39
  }