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.
- package/dist/commands/enroll.js +128 -0
- package/dist/commands/init-agent.js +38 -9
- 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/index.js +30 -0
- package/dist/lib/client-config-writers.js +61 -0
- package/dist/lib/data-project-detect.js +204 -0
- package/dist/lib/mcp.js +17 -2
- package/dist/lib/trust.js +4 -1
- package/dist/lib/update-check.js +109 -0
- package/package.json +6 -2
|
@@ -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
|
|
1262
|
-
//
|
|
1263
|
-
//
|
|
1264
|
-
//
|
|
1265
|
-
// attestation
|
|
1266
|
-
//
|
|
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
|
-
?
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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.
|
|
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
|
}
|