@neat.is/core 0.9.18-dev.20260917 → 0.9.18-dev.20260918

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/cli.js CHANGED
@@ -8,7 +8,7 @@ import {
8
8
  resolveNeatVersion,
9
9
  startK8sSubstratePolling,
10
10
  writeDaemonRecord
11
- } from "./chunk-NV4WWJSU.js";
11
+ } from "./chunk-MGQSR6QZ.js";
12
12
  import {
13
13
  buildSearchIndex
14
14
  } from "./chunk-BC53SCT7.js";
@@ -77,7 +77,7 @@ import {
77
77
  startStalenessLoop,
78
78
  upsertConnectorEntry,
79
79
  validateConnectorEntry
80
- } from "./chunk-SSLPBQWY.js";
80
+ } from "./chunk-KIV7OB2K.js";
81
81
  import {
82
82
  startOtelGrpcReceiver
83
83
  } from "./chunk-ERE47MCR.js";
@@ -4861,1139 +4861,1251 @@ async function runConnectorCommand(rawArgs, deps = {}) {
4861
4861
  }
4862
4862
  }
4863
4863
 
4864
- // src/doctor-cli.ts
4864
+ // src/hosted-connect-cli.ts
4865
+ import { spawn as spawn4 } from "child_process";
4866
+
4867
+ // src/login-sso.ts
4868
+ import { createServer } from "http";
4869
+ import { spawn as spawn3 } from "child_process";
4870
+ import { randomBytes as randomBytes2 } from "crypto";
4871
+
4872
+ // src/profiles.ts
4865
4873
  import { promises as fs10 } from "fs";
4874
+ import os from "os";
4866
4875
  import path11 from "path";
4867
-
4868
- // src/cli-client.ts
4869
- import { Provenance as Provenance2 } from "@neat.is/types";
4870
- var HttpError = class extends Error {
4871
- constructor(status, message, responseBody = "") {
4872
- super(message);
4873
- this.status = status;
4874
- this.responseBody = responseBody;
4875
- this.name = "HttpError";
4876
- }
4877
- status;
4878
- responseBody;
4879
- };
4880
- var TransportError = class extends Error {
4881
- constructor(message) {
4882
- super(message);
4883
- this.name = "TransportError";
4884
- }
4885
- };
4886
- function resolveAuthToken(env = process.env) {
4887
- const t = env.NEAT_AUTH_TOKEN;
4888
- return t && t.length > 0 ? t : void 0;
4876
+ var PROFILES_CONFIG_VERSION = 1;
4877
+ function neatHome() {
4878
+ const override = process.env.NEAT_HOME;
4879
+ if (override && override.length > 0) return path11.resolve(override);
4880
+ return path11.join(os.homedir(), ".neat");
4889
4881
  }
4890
- function createHttpClient(baseUrl, bearerToken) {
4891
- const root = baseUrl.replace(/\/$/, "");
4892
- const authHeader = bearerToken && bearerToken.length > 0 ? { authorization: `Bearer ${bearerToken}` } : {};
4893
- return {
4894
- async get(path19) {
4895
- let res;
4896
- try {
4897
- res = await fetch(`${root}${path19}`, {
4898
- headers: { ...authHeader }
4899
- });
4900
- } catch (err) {
4901
- throw new TransportError(
4902
- `cannot reach neat-core at ${root}: ${err.message}`
4903
- );
4904
- }
4905
- if (!res.ok) {
4906
- const body = await res.text().catch(() => "");
4907
- throw new HttpError(
4908
- res.status,
4909
- `${res.status} ${res.statusText} on GET ${path19}: ${body}`,
4910
- body
4911
- );
4912
- }
4913
- return await res.json();
4914
- },
4915
- async post(path19, body) {
4916
- let res;
4917
- try {
4918
- res = await fetch(`${root}${path19}`, {
4919
- method: "POST",
4920
- headers: { "content-type": "application/json", ...authHeader },
4921
- body: JSON.stringify(body)
4922
- });
4923
- } catch (err) {
4924
- throw new TransportError(
4925
- `cannot reach neat-core at ${root}: ${err.message}`
4926
- );
4927
- }
4928
- if (!res.ok) {
4929
- const text = await res.text().catch(() => "");
4930
- throw new HttpError(
4931
- res.status,
4932
- `${res.status} ${res.statusText} on POST ${path19}: ${text}`,
4933
- text
4934
- );
4935
- }
4936
- return await res.json();
4937
- }
4938
- };
4882
+ function profilesConfigPath(home = neatHome()) {
4883
+ return path11.join(home, "profiles.json");
4939
4884
  }
4940
- function projectPath(project, suffix) {
4941
- if (!project) return suffix;
4942
- return `/projects/${encodeURIComponent(project)}${suffix}`;
4885
+ function profilesConfigLockPath(home = neatHome()) {
4886
+ return path11.join(home, "profiles.json.lock");
4943
4887
  }
4944
- async function runRootCause(client, input) {
4945
- const qs = input.errorId ? `?errorId=${encodeURIComponent(input.errorId)}` : "";
4946
- const path19 = projectPath(
4947
- input.project,
4948
- `/graph/root-cause/${encodeURIComponent(input.errorNode)}${qs}`
4949
- );
4888
+ var MODE_MASK_LOOSER_THAN_0600 = 63;
4889
+ async function warnIfModeLooserThan0600(file) {
4890
+ if (process.platform === "win32") return;
4950
4891
  try {
4951
- const result = await client.get(path19);
4952
- const arrowPath = result.traversalPath.join(" \u2190 ");
4953
- const provenances = result.edgeProvenances.length ? result.edgeProvenances.join(", ") : "(direct, no edges traversed)";
4954
- const summary = `Root cause for ${input.errorNode} is ${result.rootCauseNode}. ` + result.rootCauseReason + (result.fixRecommendation ? ` Recommended fix: ${result.fixRecommendation}.` : "");
4955
- const blockLines = [
4956
- `Traversal path: ${arrowPath}`,
4957
- `Edge provenances: ${provenances}`
4958
- ];
4959
- if (result.fixRecommendation) blockLines.push(`Recommended fix: ${result.fixRecommendation}`);
4960
- return {
4961
- summary,
4962
- block: blockLines.join("\n"),
4963
- confidence: result.confidence,
4964
- provenance: result.edgeProvenances.length ? result.edgeProvenances : void 0
4965
- };
4966
- } catch (err) {
4967
- if (err instanceof HttpError && err.status === 404) {
4968
- return {
4969
- summary: `No root cause found for ${input.errorNode}. The node may be healthy, or it may not exist in the graph.`
4970
- };
4892
+ const stat = await fs10.stat(file);
4893
+ if ((stat.mode & MODE_MASK_LOOSER_THAN_0600) !== 0) {
4894
+ const mode = (stat.mode & 511).toString(8).padStart(3, "0");
4895
+ console.warn(
4896
+ `[neat] ${file} is mode 0${mode}, looser than the 0600 this file's token calls for \u2014 run \`chmod 600 ${file}\``
4897
+ );
4971
4898
  }
4972
- throw err;
4899
+ } catch {
4973
4900
  }
4974
4901
  }
4975
- async function runBlastRadius(client, input) {
4976
- const qs = input.depth !== void 0 ? `?depth=${input.depth}` : "";
4977
- const path19 = projectPath(
4978
- input.project,
4979
- `/graph/blast-radius/${encodeURIComponent(input.nodeId)}${qs}`
4980
- );
4902
+ async function readProfilesConfig(home = neatHome()) {
4903
+ const file = profilesConfigPath(home);
4904
+ let raw;
4981
4905
  try {
4982
- const result = await client.get(path19);
4983
- if (result.totalAffected === 0) {
4984
- return {
4985
- summary: `${result.origin} has no dependents. Nothing else would break if it failed.`
4986
- };
4987
- }
4988
- const sorted = [...result.affectedNodes].sort(
4989
- (a, b) => a.distance - b.distance || a.nodeId.localeCompare(b.nodeId)
4990
- );
4991
- const blockLines = sorted.map(formatBlastEntry);
4992
- const minConfidence = sorted.reduce(
4993
- (m, n) => Math.min(m, n.confidence),
4994
- Number.POSITIVE_INFINITY
4995
- );
4996
- const provenances = [...new Set(sorted.map((n) => n.edgeProvenance))];
4997
- return {
4998
- summary: `Blast radius for ${result.origin}: ${result.totalAffected} dependent node${result.totalAffected === 1 ? "" : "s"} would break if it changed.`,
4999
- block: blockLines.join("\n"),
5000
- confidence: Number.isFinite(minConfidence) ? minConfidence : void 0,
5001
- provenance: provenances.length ? provenances : void 0
5002
- };
4906
+ raw = await fs10.readFile(file, "utf8");
5003
4907
  } catch (err) {
5004
- if (err instanceof HttpError && err.status === 404) {
5005
- return { summary: `Node ${input.nodeId} not found in the graph.` };
4908
+ if (err.code === "ENOENT") {
4909
+ return { version: PROFILES_CONFIG_VERSION, profiles: [] };
5006
4910
  }
5007
4911
  throw err;
5008
4912
  }
5009
- }
5010
- function formatBlastEntry(n) {
5011
- const tag = n.edgeProvenance === Provenance2.STALE ? " [STALE \u2014 last seen too long ago]" : "";
5012
- return ` \u2022 ${n.nodeId} (distance ${n.distance}, ${n.edgeProvenance})${tag}`;
5013
- }
5014
- async function runDependencies(client, input) {
5015
- const depth = input.depth ?? 3;
5016
- const path19 = projectPath(
5017
- input.project,
5018
- `/graph/dependencies/${encodeURIComponent(input.nodeId)}?depth=${depth}`
5019
- );
4913
+ await warnIfModeLooserThan0600(file);
4914
+ let parsed;
5020
4915
  try {
5021
- const result = await client.get(path19);
5022
- if (result.total === 0) {
5023
- return {
5024
- summary: depth === 1 ? `${input.nodeId} has no direct dependencies in the graph.` : `${input.nodeId} has no dependencies (BFS to depth ${depth}).`
5025
- };
5026
- }
5027
- const byDistance = /* @__PURE__ */ new Map();
5028
- for (const dep of result.dependencies) {
5029
- const ring = byDistance.get(dep.distance) ?? [];
5030
- ring.push(dep);
5031
- byDistance.set(dep.distance, ring);
5032
- }
5033
- const blockLines = [];
5034
- for (const distance of [...byDistance.keys()].sort((a, b) => a - b)) {
5035
- const label = distance === 1 ? "Direct (distance 1)" : `Distance ${distance}`;
5036
- blockLines.push(`${label}:`);
5037
- for (const dep of byDistance.get(distance)) {
5038
- blockLines.push(` \u2022 ${dep.nodeId} \u2014 ${dep.edgeType} (${dep.provenance})`);
5039
- }
5040
- }
5041
- const provenances = [...new Set(result.dependencies.map((d) => d.provenance))];
5042
- const directCount = byDistance.get(1)?.length ?? 0;
5043
- const summary = depth === 1 ? `${input.nodeId} has ${directCount} direct dependenc${directCount === 1 ? "y" : "ies"}.` : `${input.nodeId} has ${result.total} dependenc${result.total === 1 ? "y" : "ies"} reachable to depth ${depth} (${directCount} direct).`;
5044
- return { summary, block: blockLines.join("\n"), provenance: provenances };
4916
+ parsed = JSON.parse(raw);
5045
4917
  } catch (err) {
5046
- if (err instanceof HttpError && err.status === 404) {
5047
- return { summary: `Node ${input.nodeId} not found in the graph.` };
5048
- }
5049
- throw err;
4918
+ throw new Error(`${file} is not valid JSON: ${err.message}`);
5050
4919
  }
4920
+ return validateConfig(parsed, file);
5051
4921
  }
5052
- function observedDepLine(nodeId, e) {
5053
- const via = e.source !== nodeId ? ` (via ${e.source})` : "";
5054
- return ` \u2022 ${e.target} \u2014 ${e.type}${via}${edgeMeta(e)}`;
5055
- }
5056
- async function runObservedDependencies(client, input) {
5057
- try {
5058
- const result = await client.get(
5059
- projectPath(
5060
- input.project,
5061
- `/graph/observed-dependencies/${encodeURIComponent(input.nodeId)}`
5062
- )
5063
- );
5064
- if (result.dependencies.length === 0) {
5065
- if (result.observed) {
5066
- return {
5067
- summary: `${input.nodeId} makes no outbound runtime calls, but OTel has observed it receiving traffic on ${result.inboundObservedCount} inbound call path${result.inboundObservedCount === 1 ? "" : "s"} \u2014 it's a pure receiver.`,
5068
- provenance: Provenance2.OBSERVED
5069
- };
5070
- }
5071
- const note = result.hasExtractedOutbound ? " Static (EXTRACTED) dependencies exist but no runtime traffic has been seen \u2014 is OTel running?" : "";
5072
- return { summary: `No OBSERVED dependencies for ${input.nodeId}.${note}` };
5073
- }
5074
- const blockLines = result.dependencies.map((e) => observedDepLine(input.nodeId, e));
5075
- return {
5076
- summary: `${input.nodeId} has ${result.dependencies.length} runtime dependenc${result.dependencies.length === 1 ? "y" : "ies"} confirmed by OTel.`,
5077
- block: blockLines.join("\n"),
5078
- provenance: Provenance2.OBSERVED
5079
- };
5080
- } catch (err) {
5081
- if (err instanceof HttpError && err.status === 404) {
5082
- return { summary: `Node ${input.nodeId} not found in the graph.` };
4922
+ function validateConfig(parsed, file) {
4923
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
4924
+ throw new Error(`${file} must be a JSON object with a "profiles" array`);
4925
+ }
4926
+ const obj = parsed;
4927
+ const version = obj.version === void 0 ? PROFILES_CONFIG_VERSION : obj.version;
4928
+ if (typeof version !== "number" || !Number.isInteger(version)) {
4929
+ throw new Error(`${file}: "version" must be an integer`);
4930
+ }
4931
+ const rawProfiles = obj.profiles;
4932
+ if (!Array.isArray(rawProfiles)) {
4933
+ throw new Error(`${file}: "profiles" must be an array`);
4934
+ }
4935
+ const profiles = rawProfiles.map((entry2, i) => validateEntry(entry2, i, file));
4936
+ const seen = /* @__PURE__ */ new Set();
4937
+ for (const p of profiles) {
4938
+ if (seen.has(p.name)) throw new Error(`${file}: duplicate profile name "${p.name}"`);
4939
+ seen.add(p.name);
4940
+ }
4941
+ let active;
4942
+ if (obj.active !== void 0) {
4943
+ if (typeof obj.active !== "string" || obj.active.length === 0) {
4944
+ throw new Error(`${file}: "active" must be a non-empty string when present`);
5083
4945
  }
5084
- throw err;
4946
+ active = seen.has(obj.active) ? obj.active : void 0;
5085
4947
  }
4948
+ return { version, ...active ? { active } : {}, profiles };
5086
4949
  }
5087
- function edgeMeta(e) {
5088
- const bits = [];
5089
- if (e.signal) {
5090
- bits.push(`spans=${e.signal.spanCount}`);
5091
- if (e.signal.errorCount > 0) bits.push(`errors=${e.signal.errorCount}`);
5092
- if (e.signal.lastObservedAgeMs !== void 0) {
5093
- bits.push(`age=${formatDuration(e.signal.lastObservedAgeMs)}`);
5094
- }
5095
- } else if (e.callCount !== void 0) {
5096
- bits.push(`callCount=${e.callCount}`);
4950
+ function validateEntry(entry2, index, file) {
4951
+ const where = `${file}: profiles[${index}]`;
4952
+ if (typeof entry2 !== "object" || entry2 === null || Array.isArray(entry2)) {
4953
+ throw new Error(`${where} must be an object`);
5097
4954
  }
5098
- if (e.lastObserved) bits.push(`lastObserved=${e.lastObserved}`);
5099
- if (e.confidence !== void 0) bits.push(`confidence=${e.confidence}`);
5100
- return bits.length ? ` [${bits.join(", ")}]` : "";
5101
- }
5102
- function formatDuration(ms) {
5103
- if (ms < 1e3) return `${Math.round(ms)}ms`;
5104
- const s = Math.round(ms / 1e3);
5105
- if (s < 60) return `${s}s`;
5106
- const m = Math.round(s / 60);
5107
- if (m < 60) return `${m}m`;
5108
- const h = Math.round(m / 60);
5109
- if (h < 48) return `${h}h`;
5110
- return `${Math.round(h / 24)}d`;
5111
- }
5112
- async function runIncidents(client, input) {
5113
- const path19 = input.nodeId ? projectPath(input.project, `/incidents/${encodeURIComponent(input.nodeId)}`) : projectPath(input.project, "/incidents");
4955
+ const e = entry2;
4956
+ const name = e.name;
4957
+ if (typeof name !== "string" || name.length === 0) {
4958
+ throw new Error(`${where}.name must be a non-empty string`);
4959
+ }
4960
+ const endpoint = e.endpoint;
4961
+ if (typeof endpoint !== "string" || endpoint.length === 0) {
4962
+ throw new Error(`${where}.endpoint must be a non-empty string`);
4963
+ }
4964
+ let parsedUrl;
5114
4965
  try {
5115
- const body = await client.get(path19);
5116
- const events = body.events;
5117
- if (events.length === 0) {
5118
- return {
5119
- summary: input.nodeId ? `No incidents recorded against ${input.nodeId}.` : "No incidents recorded."
5120
- };
5121
- }
5122
- const ordered = [...events].reverse().slice(0, input.limit ?? 20);
5123
- const blockLines = [];
5124
- for (const ev of ordered) {
5125
- blockLines.push(` ${ev.timestamp} \u2014 ${ev.service}: ${ev.errorMessage}`);
5126
- blockLines.push(` trace=${ev.traceId} span=${ev.spanId}`);
5127
- }
5128
- const target = input.nodeId ?? "the project";
5129
- return {
5130
- summary: `${target} has ${body.total} recorded incident${body.total === 1 ? "" : "s"}; showing the ${ordered.length} most recent.`,
5131
- block: blockLines.join("\n"),
5132
- provenance: Provenance2.OBSERVED
5133
- };
5134
- } catch (err) {
5135
- if (err instanceof HttpError && err.status === 404) {
5136
- return { summary: `Node ${input.nodeId ?? ""} not found in the graph.` };
5137
- }
5138
- throw err;
4966
+ parsedUrl = new URL(endpoint);
4967
+ } catch {
4968
+ throw new Error(`${where}.endpoint must be an absolute URL (got "${endpoint}")`);
5139
4969
  }
5140
- }
5141
- async function runSearch(client, input) {
5142
- const result = await client.get(
5143
- projectPath(input.project, `/search?q=${encodeURIComponent(input.query)}`)
5144
- );
5145
- if (result.matches.length === 0) {
5146
- return { summary: `No matches for "${input.query}".` };
4970
+ if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
4971
+ throw new Error(`${where}.endpoint must be an http(s) URL (got "${parsedUrl.protocol}")`);
5147
4972
  }
5148
- const provider = result.provider ?? "substring";
5149
- const blockLines = [];
5150
- let topScore;
5151
- for (const n of result.matches) {
5152
- const score = provider !== "substring" && typeof n.score === "number" ? n.score : void 0;
5153
- const scoreBit = score !== void 0 ? ` [score=${score.toFixed(2)}]` : "";
5154
- if (score !== void 0 && (topScore === void 0 || score > topScore)) topScore = score;
5155
- blockLines.push(
5156
- ` \u2022 ${n.id} (${n.type}) \u2014 ${n.name ?? n.id}${scoreBit}`
5157
- );
4973
+ if (e.authToken !== void 0 && (typeof e.authToken !== "string" || e.authToken.length === 0)) {
4974
+ throw new Error(`${where}.authToken must be a non-empty string when present`);
5158
4975
  }
5159
4976
  return {
5160
- summary: `Found ${result.matches.length} match${result.matches.length === 1 ? "" : "es"} for "${input.query}" via ${provider} provider.`,
5161
- block: blockLines.join("\n"),
5162
- confidence: topScore
4977
+ name,
4978
+ endpoint,
4979
+ ...typeof e.authToken === "string" ? { authToken: e.authToken } : {}
5163
4980
  };
5164
4981
  }
5165
- async function runDiff(client, input) {
5166
- const result = await client.get(
5167
- projectPath(
5168
- input.project,
5169
- `/graph/diff?against=${encodeURIComponent(input.againstSnapshot)}`
5170
- )
5171
- );
5172
- const total = result.added.nodes.length + result.added.edges.length + result.removed.nodes.length + result.removed.edges.length + result.changed.nodes.length + result.changed.edges.length;
5173
- const baseLabel = result.base.exportedAt ?? "unknown";
5174
- if (total === 0) {
5175
- return {
5176
- summary: `No differences between the current graph and ${input.againstSnapshot} (base exportedAt=${baseLabel}).`
5177
- };
5178
- }
5179
- const blockLines = [
5180
- ` base exportedAt: ${baseLabel}`,
5181
- ` current exportedAt: ${result.current.exportedAt}`,
5182
- ""
5183
- ];
5184
- if (result.added.nodes.length || result.added.edges.length) {
5185
- blockLines.push("Added:");
5186
- for (const n of result.added.nodes) blockLines.push(` + node ${n.id} (${n.type})`);
5187
- for (const e of result.added.edges)
5188
- blockLines.push(` + edge ${e.id} \u2014 ${e.source} -> ${e.target} (${e.type}, ${e.provenance})`);
5189
- blockLines.push("");
5190
- }
5191
- if (result.removed.nodes.length || result.removed.edges.length) {
5192
- blockLines.push("Removed:");
5193
- for (const n of result.removed.nodes) blockLines.push(` - node ${n.id} (${n.type})`);
5194
- for (const e of result.removed.edges)
5195
- blockLines.push(` - edge ${e.id} \u2014 ${e.source} -> ${e.target} (${e.type}, ${e.provenance})`);
5196
- blockLines.push("");
4982
+ async function resolveProfile(name, home = neatHome()) {
4983
+ const { profiles } = await readProfilesConfig(home);
4984
+ return profiles.find((p) => p.name === name);
4985
+ }
4986
+ async function getActiveProfile(home = neatHome()) {
4987
+ const { active, profiles } = await readProfilesConfig(home);
4988
+ if (!active) return void 0;
4989
+ return profiles.find((p) => p.name === active);
4990
+ }
4991
+ function serialize(config) {
4992
+ const names = new Set(config.profiles.map((p) => p.name));
4993
+ const active = config.active && names.has(config.active) ? config.active : void 0;
4994
+ const out = {
4995
+ version: config.version ?? PROFILES_CONFIG_VERSION,
4996
+ ...active ? { active } : {},
4997
+ profiles: config.profiles
4998
+ };
4999
+ return `${JSON.stringify(out, null, 2)}
5000
+ `;
5001
+ }
5002
+ async function writeConfigAtomic(config, home) {
5003
+ const file = profilesConfigPath(home);
5004
+ await fs10.mkdir(path11.dirname(file), { recursive: true });
5005
+ const tmp = `${file}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`;
5006
+ const fd = await fs10.open(tmp, "w", 384);
5007
+ try {
5008
+ await fd.writeFile(serialize(config), "utf8");
5009
+ await fd.sync();
5010
+ } finally {
5011
+ await fd.close();
5197
5012
  }
5198
- if (result.changed.nodes.length || result.changed.edges.length) {
5199
- blockLines.push("Changed:");
5200
- for (const c of result.changed.nodes) {
5201
- blockLines.push(` ~ node ${c.id} \u2014 ${summariseAttrDiff(c.before, c.after)}`);
5202
- }
5203
- for (const c of result.changed.edges) {
5204
- const provBit = c.before.provenance !== c.after.provenance ? `provenance ${c.before.provenance} \u2192 ${c.after.provenance}` : summariseAttrDiff(c.before, c.after);
5205
- blockLines.push(` ~ edge ${c.id} \u2014 ${provBit}`);
5013
+ await fs10.rename(tmp, file);
5014
+ }
5015
+ var LOCK_RETRY_MS = 50;
5016
+ var LOCK_TIMEOUT_MS = 5e3;
5017
+ async function acquireLock(lockPath) {
5018
+ await fs10.mkdir(path11.dirname(lockPath), { recursive: true });
5019
+ const deadline = Date.now() + LOCK_TIMEOUT_MS;
5020
+ for (; ; ) {
5021
+ try {
5022
+ const fd = await fs10.open(lockPath, "wx");
5023
+ await fd.writeFile(`${process.pid}
5024
+ `, "utf8");
5025
+ await fd.close();
5026
+ return;
5027
+ } catch (err) {
5028
+ if (err.code !== "EEXIST") throw err;
5029
+ if (Date.now() >= deadline) {
5030
+ throw new Error(
5031
+ `timed out acquiring ${lockPath} after ${LOCK_TIMEOUT_MS}ms \u2014 if no other neat process is running, remove the stale lock file`
5032
+ );
5033
+ }
5034
+ await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_MS));
5206
5035
  }
5207
5036
  }
5208
- return {
5209
- summary: `Diff against ${input.againstSnapshot}: ${total} change${total === 1 ? "" : "s"} between the snapshot and the live graph.`,
5210
- block: blockLines.join("\n").trimEnd()
5211
- };
5212
5037
  }
5213
- function summariseAttrDiff(before, after) {
5214
- const keys = /* @__PURE__ */ new Set([...Object.keys(before), ...Object.keys(after)]);
5215
- const changed = [];
5216
- for (const k of keys) {
5217
- if (JSON.stringify(before[k]) !== JSON.stringify(after[k])) changed.push(k);
5038
+ async function releaseLock(lockPath) {
5039
+ await fs10.rm(lockPath, { force: true });
5040
+ }
5041
+ async function withProfilesLock(home, fn) {
5042
+ const lockPath = profilesConfigLockPath(home);
5043
+ await acquireLock(lockPath);
5044
+ try {
5045
+ return await fn();
5046
+ } finally {
5047
+ await releaseLock(lockPath);
5218
5048
  }
5219
- return changed.length === 0 ? "attributes differ" : `fields changed: ${changed.sort().join(", ")}`;
5220
5049
  }
5221
- async function runStaleEdges(client, input) {
5222
- const params = new URLSearchParams();
5223
- if (input.limit !== void 0) params.set("limit", String(input.limit));
5224
- if (input.edgeType) params.set("edgeType", input.edgeType);
5225
- const qs = params.size > 0 ? `?${params.toString()}` : "";
5226
- const body = await client.get(
5227
- projectPath(input.project, `/stale-events${qs}`)
5228
- );
5229
- const events = body.events;
5230
- if (events.length === 0) {
5050
+ async function upsertProfile(profile, opts = {}) {
5051
+ const home = opts.home ?? neatHome();
5052
+ const validated = validateEntry(profile, 0, profilesConfigPath(home));
5053
+ await withProfilesLock(home, async () => {
5054
+ const config = await readProfilesConfig(home);
5055
+ const others = config.profiles.filter((p) => p.name !== validated.name);
5056
+ const profiles = [...others, validated];
5057
+ const makeActive = opts.makeActive ?? config.profiles.length === 0;
5058
+ const active = makeActive ? validated.name : config.active;
5059
+ await writeConfigAtomic({ version: config.version, ...active ? { active } : {}, profiles }, home);
5060
+ });
5061
+ }
5062
+ async function removeProfile(name, home = neatHome()) {
5063
+ return withProfilesLock(home, async () => {
5064
+ const config = await readProfilesConfig(home);
5065
+ const profiles = config.profiles.filter((p) => p.name !== name);
5066
+ if (profiles.length === config.profiles.length) return false;
5067
+ const active = config.active === name ? void 0 : config.active;
5068
+ await writeConfigAtomic({ version: config.version, ...active ? { active } : {}, profiles }, home);
5069
+ return true;
5070
+ });
5071
+ }
5072
+ async function clearActiveProfile(home = neatHome()) {
5073
+ return withProfilesLock(home, async () => {
5074
+ const config = await readProfilesConfig(home);
5075
+ if (!config.active) return false;
5076
+ await writeConfigAtomic({ version: config.version, profiles: config.profiles }, home);
5077
+ return true;
5078
+ });
5079
+ }
5080
+
5081
+ // src/login-sso.ts
5082
+ var DEFAULT_CP_URL = "https://neat-control-plane-bg5yqctn2q-nw.a.run.app";
5083
+ var DEFAULT_WEB_URL = "https://app.neat.is";
5084
+ var CALLBACK_TIMEOUT_MS = 5 * 6e4;
5085
+ function resolveCpUrl(env = process.env, override) {
5086
+ const v = override ?? env.NEAT_CP_URL;
5087
+ return (v && v.length > 0 ? v : DEFAULT_CP_URL).replace(/\/+$/, "");
5088
+ }
5089
+ function resolveWebUrl(env = process.env, override) {
5090
+ const v = override ?? env.NEAT_WEB_URL;
5091
+ return (v && v.length > 0 ? v : DEFAULT_WEB_URL).replace(/\/+$/, "");
5092
+ }
5093
+ function pickProject(projects, want) {
5094
+ if (want) {
5095
+ const p = projects.find((x) => x.id === want || x.name === want);
5096
+ if (!p) return { error: { code: 1, message: `no project named or id'd "${want}" on this account` } };
5097
+ return { project: p };
5098
+ }
5099
+ const running = projects.filter((p) => p.status === "running");
5100
+ if (running.length === 1) return { project: running[0] };
5101
+ if (running.length === 0) {
5102
+ const listed = projects.length ? ` (have: ${projects.map((p) => `${p.name} [${p.status}]`).join(", ")})` : "";
5231
5103
  return {
5232
- summary: input.edgeType ? `No stale ${input.edgeType} edges recorded.` : "No stale-edge transitions recorded yet."
5104
+ error: {
5105
+ code: 1,
5106
+ message: `no running project to connect to \u2014 create + provision one in the console first${listed}`
5107
+ }
5233
5108
  };
5234
5109
  }
5235
- const blockLines = events.map(
5236
- (e) => ` ${e.transitionedAt} \u2014 ${e.source} -[${e.edgeType}]-> ${e.target} (last seen ${e.lastObserved}, threshold ${formatDuration(e.thresholdMs)})`
5237
- );
5238
5110
  return {
5239
- summary: `${events.length} stale-edge transition${events.length === 1 ? "" : "s"} recorded${input.edgeType ? ` for ${input.edgeType}` : ""}.`,
5240
- block: blockLines.join("\n"),
5241
- provenance: Provenance2.STALE
5111
+ error: {
5112
+ code: 2,
5113
+ message: `several running projects \u2014 pass --project <name>: ${running.map((p) => p.name).join(", ")}`
5114
+ }
5242
5115
  };
5243
5116
  }
5244
- async function runPolicies(client, input) {
5245
- let violations;
5246
- let allowed = true;
5247
- let hypothetical;
5248
- if (input.hypotheticalAction) {
5249
- if (typeof client.post !== "function") {
5250
- throw new Error("HttpClient does not support POST \u2014 required for policies dry-run");
5251
- }
5252
- const body = await client.post(
5253
- projectPath(input.project, "/policies/check"),
5254
- { hypotheticalAction: input.hypotheticalAction }
5255
- );
5256
- violations = body.violations;
5257
- allowed = body.allowed;
5258
- hypothetical = body.hypotheticalAction;
5259
- } else {
5260
- const params = new URLSearchParams();
5261
- if (input.policyId) params.set("policyId", input.policyId);
5262
- const qs = params.size > 0 ? `?${params.toString()}` : "";
5263
- const body = await client.get(
5264
- projectPath(input.project, `/policies/violations${qs}`)
5265
- );
5266
- violations = body.violations;
5267
- allowed = violations.every((v) => v.onViolation !== "block");
5117
+ async function exchangeCredential(cpUrl, accessToken, opts, deps) {
5118
+ const fetchImpl = deps.fetchImpl ?? fetch;
5119
+ const auth = { authorization: `Bearer ${accessToken}` };
5120
+ let meRes;
5121
+ try {
5122
+ meRes = await fetchImpl(`${cpUrl}/me`, { headers: auth });
5123
+ } catch (e) {
5124
+ return { error: { code: 3, message: `can't reach the control plane at ${cpUrl} \u2014 ${e.message}` } };
5268
5125
  }
5269
- if (input.nodeId) {
5270
- violations = violations.filter(
5271
- (v) => v.subject.nodeId === input.nodeId || v.subject.path?.includes(input.nodeId)
5272
- );
5126
+ if (meRes.status === 401) return { error: { code: 1, message: "your session is expired or invalid \u2014 log in again" } };
5127
+ if (!meRes.ok) return { error: { code: 1, message: `the control plane returned HTTP ${meRes.status} on /me` } };
5128
+ const me = await meRes.json().catch(() => ({}));
5129
+ const projects = Array.isArray(me.projects) ? me.projects : [];
5130
+ const picked = pickProject(projects, opts.project);
5131
+ if ("error" in picked) return picked;
5132
+ const project = picked.project;
5133
+ let credRes;
5134
+ try {
5135
+ credRes = await fetchImpl(`${cpUrl}/me/projects/${encodeURIComponent(project.id)}/cli-credential`, {
5136
+ headers: auth
5137
+ });
5138
+ } catch (e) {
5139
+ return { error: { code: 3, message: `can't reach the control plane \u2014 ${e.message}` } };
5273
5140
  }
5274
- if (violations.length === 0) {
5141
+ if (credRes.status === 409) {
5275
5142
  return {
5276
- summary: hypothetical ? `No violations would result from the hypothetical action (${hypothetical.kind}).` : "No policy violations recorded."
5143
+ error: {
5144
+ code: 1,
5145
+ message: `project "${project.name}" isn't provisioned yet (status: ${project.status}) \u2014 provision it first`
5146
+ }
5277
5147
  };
5278
5148
  }
5279
- const blockCount = violations.filter((v) => v.onViolation === "block").length;
5280
- const summaryParts = [];
5281
- if (hypothetical) {
5282
- summaryParts.push(
5283
- `Hypothetical ${hypothetical.kind} would surface ${violations.length} violation${violations.length === 1 ? "" : "s"}`
5284
- );
5285
- } else {
5286
- summaryParts.push(
5287
- `${violations.length} policy violation${violations.length === 1 ? "" : "s"} currently recorded`
5288
- );
5149
+ if (credRes.status === 404) return { error: { code: 1, message: `project "${project.name}" was not found, or isn't yours` } };
5150
+ if (credRes.status === 401) return { error: { code: 1, message: "your session is expired or invalid \u2014 log in again" } };
5151
+ if (!credRes.ok) return { error: { code: 1, message: `the control plane returned HTTP ${credRes.status} for the credential` } };
5152
+ const cred = await credRes.json().catch(() => ({}));
5153
+ if (!cred.endpoint || !cred.authToken) {
5154
+ return { error: { code: 1, message: "the credential response was missing endpoint/authToken" } };
5289
5155
  }
5290
- if (blockCount > 0) summaryParts.push(`${blockCount} of which block`);
5291
- if (!allowed && hypothetical) summaryParts.push("action denied");
5292
- const summary = summaryParts.join("; ") + ".";
5293
- const blockLines = violations.map((v) => {
5294
- const subject = v.subject.nodeId ?? v.subject.edgeId ?? v.subject.path?.[0] ?? "(global)";
5295
- return ` \u2022 [${v.severity}/${v.onViolation}] ${v.policyName}: ${v.message} \u2014 ${subject}`;
5296
- });
5297
- const severities = [...new Set(violations.map((v) => v.severity))];
5298
- return {
5299
- summary,
5300
- block: blockLines.join("\n"),
5301
- confidence: hypothetical ? 0.7 : 1,
5302
- provenance: severities.join(" ")
5303
- };
5156
+ return { project, cred };
5304
5157
  }
5305
- function formatDivergenceLine(d) {
5306
- switch (d.type) {
5307
- case "missing-observed":
5308
- case "missing-extracted":
5309
- if (d.column) {
5310
- return ` \u2022 [${d.type}] ${d.table ?? d.source} column ${d.column} \u2014 confidence ${d.confidence.toFixed(2)}`;
5158
+ function defaultOpenBrowser(url) {
5159
+ const platform = process.platform;
5160
+ const cmd = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
5161
+ const args = platform === "win32" ? ["/c", "start", "", url] : [url];
5162
+ try {
5163
+ const child = spawn3(cmd, args, { detached: true, stdio: "ignore" });
5164
+ child.on("error", () => {
5165
+ });
5166
+ child.unref();
5167
+ return true;
5168
+ } catch {
5169
+ return false;
5170
+ }
5171
+ }
5172
+ async function loopbackReceiveToken(webUrl, deps, opts = {}) {
5173
+ const out = deps.out ?? (() => {
5174
+ });
5175
+ const openFn = deps.openBrowser ?? defaultOpenBrowser;
5176
+ const state = randomBytes2(16).toString("hex");
5177
+ return new Promise((resolve) => {
5178
+ let settled = false;
5179
+ let timer;
5180
+ const done = (r) => {
5181
+ if (settled) return;
5182
+ settled = true;
5183
+ clearTimeout(timer);
5184
+ try {
5185
+ server.close();
5186
+ } catch {
5311
5187
  }
5312
- return ` \u2022 [${d.type}] ${d.source} \u2192 ${d.target} (${d.edgeType}) \u2014 confidence ${d.confidence.toFixed(2)}`;
5313
- case "version-mismatch":
5314
- return ` \u2022 [${d.type}] ${d.source} \u2192 ${d.target} \u2014 declared ${d.extractedVersion}, observed engine ${d.observedVersion} (${d.compatibility})`;
5315
- case "host-mismatch":
5316
- return ` \u2022 [${d.type}] ${d.source} \u2192 ${d.target} \u2014 declared host ${d.extractedHost}, observed host ${d.observedHost}`;
5317
- case "compat-violation":
5318
- return ` \u2022 [${d.type}] ${d.source} \u2192 ${d.target} \u2014 ${d.rule.kind}${d.rule.package ? ` (${d.rule.package})` : ""}`;
5319
- case "observed-symbol-mismatch": {
5320
- const at = d.location ? ` at ${d.location}` : "";
5321
- const member = d.symbol ? ` ${d.symbol}` : "";
5322
- return ` \u2022 [${d.type}] ${d.source}${member}${at} (${d.mismatchKind}) \u2014 confidence ${d.confidence.toFixed(2)}`;
5323
- }
5324
- case "observed-failing": {
5325
- if (d.edgeType) {
5326
- const rate = d.errorRate !== void 0 ? ` ${Math.round(d.errorRate * 100)}% errors` : "";
5327
- return ` \u2022 [${d.type}] ${d.source} \u2192 ${d.target} (${d.edgeType}) \u2014${rate} (${d.failureKind}) \u2014 confidence ${d.confidence.toFixed(2)}`;
5188
+ resolve(r);
5189
+ };
5190
+ const server = createServer((req, res) => {
5191
+ const reqUrl = new URL(req.url ?? "/", "http://127.0.0.1");
5192
+ if (reqUrl.pathname !== "/callback") {
5193
+ res.writeHead(404);
5194
+ res.end();
5195
+ return;
5328
5196
  }
5329
- const at = d.location ? ` at ${d.location}` : "";
5330
- return ` \u2022 [${d.type}] ${d.source}${at} (${d.failureKind}) \u2014 confidence ${d.confidence.toFixed(2)}`;
5331
- }
5332
- case "deploy-mismatch": {
5333
- if (d.kind === "image") {
5334
- return ` \u2022 [${d.type}] ${d.source} \u2014 declared image ${d.declaredImage ?? "?"}, running ${d.observedImage ?? "?"} \u2014 confidence ${d.confidence.toFixed(2)}`;
5197
+ const token = reqUrl.searchParams.get("token");
5198
+ const gotState = reqUrl.searchParams.get("state");
5199
+ if (!token || gotState !== state) {
5200
+ res.writeHead(400, { "content-type": "text/html" });
5201
+ res.end("<h1>NEAT login failed</h1><p>Invalid or mismatched token. You can close this tab.</p>");
5202
+ done({ error: { code: 1, message: "the browser returned an invalid or mismatched token" } });
5203
+ return;
5335
5204
  }
5336
- return ` \u2022 [${d.type}] ${d.source} \u2014 declared ${d.declaredReplicas ?? "?"} replicas, ${d.observedReplicas ?? "?"} ready \u2014 confidence ${d.confidence.toFixed(2)}`;
5205
+ res.writeHead(200, { "content-type": "text/html" });
5206
+ res.end("<h1>You're logged in to NEAT.</h1><p>You can close this tab and return to the terminal.</p>");
5207
+ done({ token });
5208
+ });
5209
+ server.on("error", (e) => done({ error: { code: 3, message: `couldn't start the local login listener \u2014 ${e.message}` } }));
5210
+ timer = setTimeout(
5211
+ () => done({ error: { code: 1, message: "timed out waiting for the browser login" } }),
5212
+ opts.timeoutMs ?? CALLBACK_TIMEOUT_MS
5213
+ );
5214
+ server.listen(0, "127.0.0.1", () => {
5215
+ const addr = server.address();
5216
+ const port = typeof addr === "object" && addr ? addr.port : 0;
5217
+ const callback = `http://127.0.0.1:${port}/callback`;
5218
+ const authUrl = `${webUrl}/cli/auth?callback=${encodeURIComponent(callback)}&state=${state}`;
5219
+ out("Opening your browser to log in to NEAT\u2026");
5220
+ out(`If it doesn't open, visit:
5221
+ ${authUrl}`);
5222
+ openFn(authUrl);
5223
+ });
5224
+ });
5225
+ }
5226
+ async function runSsoLogin(opts, deps) {
5227
+ const out = deps.out ?? ((l) => console.log(l));
5228
+ const err = deps.err ?? ((l) => console.error(l));
5229
+ let accessToken = opts.ssoToken;
5230
+ if (!accessToken) {
5231
+ const lb = await loopbackReceiveToken(opts.webUrl, deps, { timeoutMs: opts.timeoutMs ?? CALLBACK_TIMEOUT_MS });
5232
+ if ("error" in lb) {
5233
+ err(`neat login: ${lb.error.message}`);
5234
+ return lb.error.code;
5337
5235
  }
5236
+ accessToken = lb.token;
5338
5237
  }
5339
- }
5340
- async function runDivergences(client, input) {
5341
- const params = new URLSearchParams();
5342
- if (input.type && input.type.length > 0) params.set("type", input.type.join(","));
5343
- if (input.minConfidence !== void 0) {
5344
- params.set("minConfidence", String(input.minConfidence));
5345
- }
5346
- if (input.node) params.set("node", input.node);
5347
- const qs = params.size > 0 ? `?${params.toString()}` : "";
5348
- const result = await client.get(
5349
- projectPath(input.project, `/graph/divergences${qs}`)
5350
- );
5351
- if (result.totalAffected === 0) {
5352
- return {
5353
- summary: "No divergences found between the declared (EXTRACTED) and observed (OBSERVED) views of the graph."
5354
- };
5355
- }
5356
- const headline = result.divergences[0];
5357
- const summary = `Found ${result.totalAffected} divergence${result.totalAffected === 1 ? "" : "s"} between code and production. Highest-confidence: ${headline.type} on ${headline.source} \u2192 ${headline.target}. ${headline.reason}`;
5358
- const blockLines = [];
5359
- for (const d of result.divergences) {
5360
- blockLines.push(formatDivergenceLine(d));
5361
- blockLines.push(` reason: ${d.reason}`);
5362
- blockLines.push(` recommendation: ${d.recommendation}`);
5238
+ const ex = await exchangeCredential(opts.cpUrl, accessToken, { project: opts.project }, deps);
5239
+ if ("error" in ex) {
5240
+ err(`neat login: ${ex.error.message}`);
5241
+ return ex.error.code;
5363
5242
  }
5364
- const maxConfidence = result.divergences.reduce(
5365
- (m, d) => Math.max(m, d.confidence),
5366
- 0
5367
- );
5368
- return {
5369
- summary,
5370
- block: blockLines.join("\n"),
5371
- confidence: maxConfidence,
5372
- provenance: "composite (EXTRACTED + OBSERVED)"
5373
- };
5374
- }
5375
- async function runAsk(client, input) {
5376
- const result = await client.get(
5377
- projectPath(input.project, `/graph/ask?q=${encodeURIComponent(input.question)}`)
5243
+ const { project, cred } = ex;
5244
+ await upsertProfile(
5245
+ { name: opts.name, endpoint: cred.endpoint, authToken: cred.authToken },
5246
+ { makeActive: true, ...deps.home ? { home: deps.home } : {} }
5378
5247
  );
5379
- const blockLines = [];
5380
- if (result.matched.length > 0) {
5381
- blockLines.push(
5382
- `Matched: ${result.matched.map((m) => `${m.nodeId} [${m.via} ${m.score.toFixed(2)}]`).join(", ")}`
5248
+ if (opts.json) {
5249
+ out(
5250
+ JSON.stringify(
5251
+ {
5252
+ status: "logged-in",
5253
+ profile: opts.name,
5254
+ project: project.name,
5255
+ endpoint: cred.endpoint,
5256
+ ...cred.ingestEndpoint ? { ingestEndpoint: cred.ingestEndpoint } : {}
5257
+ },
5258
+ null,
5259
+ 2
5260
+ )
5383
5261
  );
5384
- blockLines.push(`Intent: ${result.intent}`);
5385
- } else if (result.scope === "global") {
5386
- blockLines.push(`Graph-wide answer (${result.intent}) \u2014 no entity named.`);
5387
- }
5388
- for (const section of result.sections) {
5389
- blockLines.push("", section.heading + ":");
5390
- for (const fact of section.facts) {
5391
- const tag = fact.provenance ? ` [${fact.provenance}${fact.confidence !== void 0 ? ` ${fact.confidence.toFixed(2)}` : ""}]` : fact.confidence !== void 0 ? ` [confidence ${fact.confidence.toFixed(2)}]` : "";
5392
- blockLines.push(` \u2022 ${fact.text}${tag}`);
5262
+ } else {
5263
+ out(`Logged in \u2014 profile "${opts.name}" \u2192 ${project.name} (${cred.endpoint})`);
5264
+ out("The neat CLI and the MCP server now read this hosted graph by default.");
5265
+ if (cred.ingestEndpoint && cred.otelToken) {
5266
+ out("");
5267
+ out("To fill the OBSERVED layer, instrument your app to send OpenTelemetry to the hosted daemon:");
5268
+ out(` OTEL_EXPORTER_OTLP_ENDPOINT=${cred.ingestEndpoint}`);
5269
+ out(` OTEL_EXPORTER_OTLP_HEADERS=Authorization=Bearer ${cred.otelToken}`);
5393
5270
  }
5271
+ out("Run `neat logout` to switch back to your local daemon.");
5394
5272
  }
5395
- return {
5396
- summary: result.answer,
5397
- block: blockLines.join("\n").trim(),
5398
- ...result.confidence !== void 0 ? { confidence: result.confidence } : {},
5399
- ...result.provenance.length > 0 ? { provenance: result.provenance } : {}
5400
- };
5401
- }
5402
- function formatFooter(confidence, provenance) {
5403
- const c = confidence === void 0 ? "n/a" : confidence.toFixed(2);
5404
- const p = provenance === void 0 ? "n/a" : Array.isArray(provenance) ? [...new Set(provenance)].join(", ") : provenance;
5405
- return `confidence: ${c} \xB7 provenance: ${p}`;
5406
- }
5407
- function formatHuman(result) {
5408
- const sections = [result.summary.trim()];
5409
- if (result.block && result.block.trim().length > 0) sections.push(result.block.trimEnd());
5410
- sections.push(formatFooter(result.confidence, result.provenance));
5411
- return sections.join("\n\n");
5412
- }
5413
- function formatJson(result) {
5414
- return JSON.stringify(
5415
- {
5416
- summary: result.summary,
5417
- block: result.block ?? "",
5418
- confidence: result.confidence ?? null,
5419
- provenance: result.provenance ?? null
5420
- },
5421
- null,
5422
- 2
5423
- );
5273
+ return 0;
5424
5274
  }
5425
- function exitCodeForError(err) {
5426
- if (err instanceof TransportError) return 3;
5427
- if (err instanceof HttpError) return 1;
5428
- return 1;
5275
+
5276
+ // src/hosted-connect-cli.ts
5277
+ function defaultOpenBrowser2(url) {
5278
+ const platform = process.platform;
5279
+ const cmd = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
5280
+ const args = platform === "win32" ? ["/c", "start", "", url] : [url];
5281
+ try {
5282
+ const child = spawn4(cmd, args, { detached: true, stdio: "ignore" });
5283
+ child.on("error", () => {
5284
+ });
5285
+ child.unref();
5286
+ return true;
5287
+ } catch {
5288
+ return false;
5289
+ }
5429
5290
  }
5430
- function createSnapshotPushClient(baseUrl, token) {
5431
- return createHttpClient(baseUrl, token && token.length > 0 ? token : void 0);
5291
+ function resolveCreds(env) {
5292
+ const apiKey = env.NEAT_API_KEY;
5293
+ const projectId = env.NEAT_CP_PROJECT_ID;
5294
+ if (!apiKey) return { error: "not logged in \u2014 run `neat login`, or set NEAT_API_KEY (a neat_pat_\u2026 key)" };
5295
+ if (!projectId) return { error: "no hosted project \u2014 run `neat login`, or set NEAT_CP_PROJECT_ID" };
5296
+ return { cpUrl: resolveCpUrl(env).replace(/\/+$/, ""), apiKey, projectId };
5432
5297
  }
5433
- async function pushSnapshotToRemote(input) {
5434
- const client = createSnapshotPushClient(input.baseUrl, input.token);
5435
- if (typeof client.post !== "function") {
5436
- throw new Error("HttpClient does not support POST \u2014 required for snapshot push");
5298
+ async function runConnectCommand(rawArgs, deps = {}) {
5299
+ const env = deps.env ?? process.env;
5300
+ const out = deps.out ?? ((l) => console.log(l));
5301
+ const err = deps.err ?? ((l) => console.error(l));
5302
+ const fetchImpl = deps.fetchImpl ?? fetch;
5303
+ const open = deps.openBrowser ?? defaultOpenBrowser2;
5304
+ const pollMs = deps.pollMs ?? 2e3;
5305
+ const timeoutMs = deps.timeoutMs ?? 18e4;
5306
+ const sleep2 = deps.sleepImpl ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
5307
+ const provider = rawArgs.find((a) => !a.startsWith("-"));
5308
+ if (!provider || rawArgs.includes("-h") || rawArgs.includes("--help")) {
5309
+ out("usage: neat connect <provider> connect a provider to your hosted NEAT project over OAuth");
5310
+ out(" Opens the provider's consent screen in your browser and waits for the connection to land.");
5311
+ out(" Needs a hosted login (`neat login`) or NEAT_CP_URL + NEAT_API_KEY + NEAT_CP_PROJECT_ID.");
5312
+ out(" For a local/self-hosted daemon, use `neat connector add` instead.");
5313
+ return provider ? 0 : 2;
5314
+ }
5315
+ const creds = resolveCreds(env);
5316
+ if ("error" in creds) {
5317
+ err(`neat connect: ${creds.error}`);
5318
+ return 2;
5437
5319
  }
5438
- return client.post(
5439
- `/projects/${encodeURIComponent(input.project)}/snapshot`,
5440
- { snapshot: input.snapshot }
5320
+ const connectionsUrl = `${creds.cpUrl}/me/projects/${encodeURIComponent(creds.projectId)}/connections`;
5321
+ let authorizeUrl;
5322
+ try {
5323
+ const res = await fetchImpl(`${connectionsUrl}/${encodeURIComponent(provider)}/authorize`, {
5324
+ method: "POST",
5325
+ headers: { authorization: `Bearer ${creds.apiKey}` }
5326
+ });
5327
+ if (res.status === 401) {
5328
+ err("neat connect: not authorized \u2014 run `neat login` (or check NEAT_API_KEY).");
5329
+ return 1;
5330
+ }
5331
+ if (res.status === 404 || res.status === 501) {
5332
+ err(`neat connect: ${provider} isn't available to connect over OAuth yet.`);
5333
+ return 1;
5334
+ }
5335
+ if (!res.ok) {
5336
+ err(`neat connect: couldn't start connecting ${provider} (HTTP ${res.status}).`);
5337
+ return 1;
5338
+ }
5339
+ const body = await res.json().catch(() => ({}));
5340
+ if (!body.authorizeUrl) {
5341
+ err(`neat connect: the control plane returned no authorize URL for ${provider}.`);
5342
+ return 1;
5343
+ }
5344
+ authorizeUrl = body.authorizeUrl;
5345
+ } catch (e) {
5346
+ err(`neat connect: couldn't reach the control plane at ${creds.cpUrl} \u2014 ${e.message}`);
5347
+ return 1;
5348
+ }
5349
+ out(`Opening ${provider}'s consent screen in your browser\u2026`);
5350
+ if (!open(authorizeUrl)) out("Could not open a browser automatically \u2014 open this URL to authorize:");
5351
+ out(` ${authorizeUrl}`);
5352
+ out("Waiting for you to authorize\u2026");
5353
+ const deadline = Date.now() + timeoutMs;
5354
+ while (Date.now() < deadline) {
5355
+ await sleep2(pollMs);
5356
+ try {
5357
+ const res = await fetchImpl(connectionsUrl, { headers: { authorization: `Bearer ${creds.apiKey}` } });
5358
+ if (res.ok) {
5359
+ const conns = await res.json().catch(() => []);
5360
+ const hit = Array.isArray(conns) ? conns.find((c) => c.provider === provider) : void 0;
5361
+ if (hit) {
5362
+ out(`\u2713 ${provider} connected${hit.status ? ` (${hit.status})` : ""}.`);
5363
+ return 0;
5364
+ }
5365
+ }
5366
+ } catch {
5367
+ }
5368
+ }
5369
+ err(
5370
+ `neat connect: timed out waiting for ${provider}. If you authorized, run \`neat connect ${provider}\` again to re-check \u2014 the connection may still land.`
5441
5371
  );
5372
+ return 1;
5442
5373
  }
5443
5374
 
5444
5375
  // src/doctor-cli.ts
5445
- function resolveDeps2(deps) {
5376
+ import { promises as fs11 } from "fs";
5377
+ import path12 from "path";
5378
+
5379
+ // src/cli-client.ts
5380
+ import { Provenance as Provenance2 } from "@neat.is/types";
5381
+ var HttpError = class extends Error {
5382
+ constructor(status, message, responseBody = "") {
5383
+ super(message);
5384
+ this.status = status;
5385
+ this.responseBody = responseBody;
5386
+ this.name = "HttpError";
5387
+ }
5388
+ status;
5389
+ responseBody;
5390
+ };
5391
+ var TransportError = class extends Error {
5392
+ constructor(message) {
5393
+ super(message);
5394
+ this.name = "TransportError";
5395
+ }
5396
+ };
5397
+ function resolveAuthToken(env = process.env) {
5398
+ const t = env.NEAT_AUTH_TOKEN;
5399
+ return t && t.length > 0 ? t : void 0;
5400
+ }
5401
+ function createHttpClient(baseUrl, bearerToken, fetchImpl = fetch) {
5402
+ const root = baseUrl.replace(/\/$/, "");
5403
+ const authHeader = bearerToken && bearerToken.length > 0 ? { authorization: `Bearer ${bearerToken}` } : {};
5446
5404
  return {
5447
- cwd: deps.cwd ?? process.cwd(),
5448
- env: deps.env ?? process.env,
5449
- nodeVersion: deps.nodeVersion ?? process.versions.node,
5450
- fetchImpl: deps.fetchImpl ?? fetch,
5451
- readRecord: deps.readRecord ?? readDaemonRecord,
5452
- out: deps.out ?? ((line) => console.log(line))
5405
+ async get(path19) {
5406
+ let res;
5407
+ try {
5408
+ res = await fetchImpl(`${root}${path19}`, {
5409
+ headers: { ...authHeader }
5410
+ });
5411
+ } catch (err) {
5412
+ throw new TransportError(
5413
+ `cannot reach neat-core at ${root}: ${err.message}`
5414
+ );
5415
+ }
5416
+ if (!res.ok) {
5417
+ const body = await res.text().catch(() => "");
5418
+ throw new HttpError(
5419
+ res.status,
5420
+ `${res.status} ${res.statusText} on GET ${path19}: ${body}`,
5421
+ body
5422
+ );
5423
+ }
5424
+ return await res.json();
5425
+ },
5426
+ async post(path19, body) {
5427
+ let res;
5428
+ try {
5429
+ res = await fetchImpl(`${root}${path19}`, {
5430
+ method: "POST",
5431
+ headers: { "content-type": "application/json", ...authHeader },
5432
+ body: JSON.stringify(body)
5433
+ });
5434
+ } catch (err) {
5435
+ throw new TransportError(
5436
+ `cannot reach neat-core at ${root}: ${err.message}`
5437
+ );
5438
+ }
5439
+ if (!res.ok) {
5440
+ const text = await res.text().catch(() => "");
5441
+ throw new HttpError(
5442
+ res.status,
5443
+ `${res.status} ${res.statusText} on POST ${path19}: ${text}`,
5444
+ text
5445
+ );
5446
+ }
5447
+ return await res.json();
5448
+ }
5453
5449
  };
5454
5450
  }
5455
- var NODE_FLOOR = 20;
5456
- var HEALTH_TIMEOUT_MS = 3e3;
5457
- function checkNode(nodeVersion) {
5458
- const major = Number.parseInt(nodeVersion.split(".")[0] ?? "", 10);
5459
- const ok = Number.isFinite(major) && major >= NODE_FLOOR;
5460
- return ok ? { name: "node", ok, detail: `v${nodeVersion} (>= ${NODE_FLOOR} required)` } : {
5461
- name: "node",
5462
- ok,
5463
- detail: `v${nodeVersion} \u2014 NEAT needs Node ${NODE_FLOOR} or newer`,
5464
- fix: `install Node ${NODE_FLOOR}.x (e.g. \`nvm install ${NODE_FLOOR}\`) and re-run`
5465
- };
5451
+ function projectPath(project, suffix) {
5452
+ if (!project) return suffix;
5453
+ return `/projects/${encodeURIComponent(project)}${suffix}`;
5466
5454
  }
5467
- async function neatOutExists(cwd) {
5455
+ async function runRootCause(client, input) {
5456
+ const qs = input.errorId ? `?errorId=${encodeURIComponent(input.errorId)}` : "";
5457
+ const path19 = projectPath(
5458
+ input.project,
5459
+ `/graph/root-cause/${encodeURIComponent(input.errorNode)}${qs}`
5460
+ );
5468
5461
  try {
5469
- const st = await fs10.stat(path11.join(cwd, "neat-out"));
5470
- return st.isDirectory();
5471
- } catch {
5472
- return false;
5473
- }
5474
- }
5475
- async function checkProject(cwd, record) {
5476
- if (record) {
5462
+ const result = await client.get(path19);
5463
+ const arrowPath = result.traversalPath.join(" \u2190 ");
5464
+ const provenances = result.edgeProvenances.length ? result.edgeProvenances.join(", ") : "(direct, no edges traversed)";
5465
+ const summary = `Root cause for ${input.errorNode} is ${result.rootCauseNode}. ` + result.rootCauseReason + (result.fixRecommendation ? ` Recommended fix: ${result.fixRecommendation}.` : "");
5466
+ const blockLines = [
5467
+ `Traversal path: ${arrowPath}`,
5468
+ `Edge provenances: ${provenances}`
5469
+ ];
5470
+ if (result.fixRecommendation) blockLines.push(`Recommended fix: ${result.fixRecommendation}`);
5477
5471
  return {
5478
- name: "project",
5479
- ok: true,
5480
- detail: `"${record.project}" \u2014 set up in this directory (daemon record on REST ${record.ports.rest})`
5472
+ summary,
5473
+ block: blockLines.join("\n"),
5474
+ confidence: result.confidence,
5475
+ provenance: result.edgeProvenances.length ? result.edgeProvenances : void 0
5481
5476
  };
5477
+ } catch (err) {
5478
+ if (err instanceof HttpError && err.status === 404) {
5479
+ return {
5480
+ summary: `No root cause found for ${input.errorNode}. The node may be healthy, or it may not exist in the graph.`
5481
+ };
5482
+ }
5483
+ throw err;
5482
5484
  }
5483
- if (await neatOutExists(cwd)) {
5485
+ }
5486
+ async function runBlastRadius(client, input) {
5487
+ const qs = input.depth !== void 0 ? `?depth=${input.depth}` : "";
5488
+ const path19 = projectPath(
5489
+ input.project,
5490
+ `/graph/blast-radius/${encodeURIComponent(input.nodeId)}${qs}`
5491
+ );
5492
+ try {
5493
+ const result = await client.get(path19);
5494
+ if (result.totalAffected === 0) {
5495
+ return {
5496
+ summary: `${result.origin} has no dependents. Nothing else would break if it failed.`
5497
+ };
5498
+ }
5499
+ const sorted = [...result.affectedNodes].sort(
5500
+ (a, b) => a.distance - b.distance || a.nodeId.localeCompare(b.nodeId)
5501
+ );
5502
+ const blockLines = sorted.map(formatBlastEntry);
5503
+ const minConfidence = sorted.reduce(
5504
+ (m, n) => Math.min(m, n.confidence),
5505
+ Number.POSITIVE_INFINITY
5506
+ );
5507
+ const provenances = [...new Set(sorted.map((n) => n.edgeProvenance))];
5484
5508
  return {
5485
- name: "project",
5486
- ok: true,
5487
- detail: "set up in this directory (no live daemon record \u2014 it may be stopped)"
5509
+ summary: `Blast radius for ${result.origin}: ${result.totalAffected} dependent node${result.totalAffected === 1 ? "" : "s"} would break if it changed.`,
5510
+ block: blockLines.join("\n"),
5511
+ confidence: Number.isFinite(minConfidence) ? minConfidence : void 0,
5512
+ provenance: provenances.length ? provenances : void 0
5488
5513
  };
5514
+ } catch (err) {
5515
+ if (err instanceof HttpError && err.status === 404) {
5516
+ return { summary: `Node ${input.nodeId} not found in the graph.` };
5517
+ }
5518
+ throw err;
5489
5519
  }
5490
- return {
5491
- name: "project",
5492
- ok: false,
5493
- detail: "no NEAT project in this directory",
5494
- fix: "set one up: `neat .`"
5495
- };
5496
5520
  }
5497
- function resolveHealthUrl(env, record) {
5498
- const explicit = env.NEAT_API_URL ?? env.NEAT_CORE_URL;
5499
- if (explicit && explicit.length > 0) return explicit.replace(/\/$/, "");
5500
- if (record) return `http://localhost:${record.ports.rest}`;
5501
- return "http://localhost:8080";
5502
- }
5503
- function summariseHealth(url, body) {
5504
- const projects = body.projects ?? [];
5505
- const nodes = projects.reduce((n, p) => n + (p.nodeCount ?? 0), 0);
5506
- const edges = projects.reduce((n, p) => n + (p.edgeCount ?? 0), 0);
5507
- const proj = body.project ?? projects[0]?.name;
5508
- const graph = projects.length > 0 ? ` \u2014 ${nodes} nodes / ${edges} edges` : "";
5509
- return `up at ${url}${proj ? ` (project "${proj}"${graph})` : ""}`;
5521
+ function formatBlastEntry(n) {
5522
+ const tag = n.edgeProvenance === Provenance2.STALE ? " [STALE \u2014 last seen too long ago]" : "";
5523
+ return ` \u2022 ${n.nodeId} (distance ${n.distance}, ${n.edgeProvenance})${tag}`;
5510
5524
  }
5511
- async function checkDaemon(deps, record) {
5512
- const url = resolveHealthUrl(deps.env, record);
5513
- const token = resolveAuthToken(deps.env);
5514
- const headers = token ? { authorization: `Bearer ${token}` } : {};
5525
+ async function runDependencies(client, input) {
5526
+ const depth = input.depth ?? 3;
5527
+ const path19 = projectPath(
5528
+ input.project,
5529
+ `/graph/dependencies/${encodeURIComponent(input.nodeId)}?depth=${depth}`
5530
+ );
5515
5531
  try {
5516
- const res = await deps.fetchImpl(`${url}/health`, {
5517
- headers,
5518
- signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS)
5519
- });
5520
- if (res.status === 401 || res.status === 403) {
5532
+ const result = await client.get(path19);
5533
+ if (result.total === 0) {
5521
5534
  return {
5522
- name: "daemon",
5523
- ok: false,
5524
- detail: `up at ${url}, but rejected the request (${res.status})`,
5525
- fix: "set NEAT_AUTH_TOKEN to the daemon's token"
5535
+ summary: depth === 1 ? `${input.nodeId} has no direct dependencies in the graph.` : `${input.nodeId} has no dependencies (BFS to depth ${depth}).`
5526
5536
  };
5527
5537
  }
5528
- if (!res.ok) {
5529
- return {
5530
- name: "daemon",
5531
- ok: false,
5532
- detail: `reachable at ${url} but /health returned ${res.status}`,
5533
- fix: "check the daemon logs"
5534
- };
5538
+ const byDistance = /* @__PURE__ */ new Map();
5539
+ for (const dep of result.dependencies) {
5540
+ const ring = byDistance.get(dep.distance) ?? [];
5541
+ ring.push(dep);
5542
+ byDistance.set(dep.distance, ring);
5535
5543
  }
5536
- const body = await res.json().catch(() => ({}));
5537
- return { name: "daemon", ok: true, detail: summariseHealth(url, body) };
5538
- } catch {
5539
- return {
5540
- name: "daemon",
5541
- ok: false,
5542
- detail: `down \u2014 nothing answering at ${url}`,
5543
- fix: "start it: `neat .` (or `neat watch`)"
5544
- };
5544
+ const blockLines = [];
5545
+ for (const distance of [...byDistance.keys()].sort((a, b) => a - b)) {
5546
+ const label = distance === 1 ? "Direct (distance 1)" : `Distance ${distance}`;
5547
+ blockLines.push(`${label}:`);
5548
+ for (const dep of byDistance.get(distance)) {
5549
+ blockLines.push(` \u2022 ${dep.nodeId} \u2014 ${dep.edgeType} (${dep.provenance})`);
5550
+ }
5551
+ }
5552
+ const provenances = [...new Set(result.dependencies.map((d) => d.provenance))];
5553
+ const directCount = byDistance.get(1)?.length ?? 0;
5554
+ const summary = depth === 1 ? `${input.nodeId} has ${directCount} direct dependenc${directCount === 1 ? "y" : "ies"}.` : `${input.nodeId} has ${result.total} dependenc${result.total === 1 ? "y" : "ies"} reachable to depth ${depth} (${directCount} direct).`;
5555
+ return { summary, block: blockLines.join("\n"), provenance: provenances };
5556
+ } catch (err) {
5557
+ if (err instanceof HttpError && err.status === 404) {
5558
+ return { summary: `Node ${input.nodeId} not found in the graph.` };
5559
+ }
5560
+ throw err;
5545
5561
  }
5546
5562
  }
5547
- async function runDoctorChecks(deps = {}) {
5548
- const d = resolveDeps2(deps);
5549
- const record = await d.readRecord(d.cwd).catch(() => null);
5550
- return [checkNode(d.nodeVersion), await checkProject(d.cwd, record), await checkDaemon(d, record)];
5563
+ function observedDepLine(nodeId, e) {
5564
+ const via = e.source !== nodeId ? ` (via ${e.source})` : "";
5565
+ return ` \u2022 ${e.target} \u2014 ${e.type}${via}${edgeMeta(e)}`;
5551
5566
  }
5552
- var NAME_COL = "project".length;
5553
- function renderHuman(checks, out) {
5554
- out("neat doctor \u2014 checking this project's setup");
5555
- out("");
5556
- for (const c of checks) {
5557
- const mark = c.ok ? "\u2713" : "\u2717";
5558
- out(` ${mark} ${c.name.padEnd(NAME_COL)} ${c.detail}`);
5559
- if (!c.ok && c.fix) out(` ${" ".repeat(NAME_COL + 3)}fix: ${c.fix}`);
5567
+ async function runObservedDependencies(client, input) {
5568
+ try {
5569
+ const result = await client.get(
5570
+ projectPath(
5571
+ input.project,
5572
+ `/graph/observed-dependencies/${encodeURIComponent(input.nodeId)}`
5573
+ )
5574
+ );
5575
+ if (result.dependencies.length === 0) {
5576
+ if (result.observed) {
5577
+ return {
5578
+ summary: `${input.nodeId} makes no outbound runtime calls, but OTel has observed it receiving traffic on ${result.inboundObservedCount} inbound call path${result.inboundObservedCount === 1 ? "" : "s"} \u2014 it's a pure receiver.`,
5579
+ provenance: Provenance2.OBSERVED
5580
+ };
5581
+ }
5582
+ const note = result.hasExtractedOutbound ? " Static (EXTRACTED) dependencies exist but no runtime traffic has been seen \u2014 is OTel running?" : "";
5583
+ return { summary: `No OBSERVED dependencies for ${input.nodeId}.${note}` };
5584
+ }
5585
+ const blockLines = result.dependencies.map((e) => observedDepLine(input.nodeId, e));
5586
+ return {
5587
+ summary: `${input.nodeId} has ${result.dependencies.length} runtime dependenc${result.dependencies.length === 1 ? "y" : "ies"} confirmed by OTel.`,
5588
+ block: blockLines.join("\n"),
5589
+ provenance: Provenance2.OBSERVED
5590
+ };
5591
+ } catch (err) {
5592
+ if (err instanceof HttpError && err.status === 404) {
5593
+ return { summary: `Node ${input.nodeId} not found in the graph.` };
5594
+ }
5595
+ throw err;
5560
5596
  }
5561
- out("");
5562
- const failed = checks.filter((c) => !c.ok).length;
5563
- out(failed === 0 ? "all good." : `${failed} check${failed === 1 ? "" : "s"} failed.`);
5564
5597
  }
5565
- async function runDoctorCommand(argv, deps = {}) {
5566
- const out = deps.out ?? ((line) => console.log(line));
5567
- let json = false;
5568
- for (const arg of argv) {
5569
- if (arg === "--json") json = true;
5570
- else if (arg === "-h" || arg === "--help") {
5571
- out("usage: neat doctor [--json]");
5572
- out(" Probe this directory's NEAT setup: Node version, project, daemon.");
5573
- out(" Exit 0 when every check passes, 1 when any fails.");
5574
- return 0;
5575
- } else {
5576
- out(`neat doctor: unknown argument "${arg}"`);
5577
- return 2;
5598
+ function edgeMeta(e) {
5599
+ const bits = [];
5600
+ if (e.signal) {
5601
+ bits.push(`spans=${e.signal.spanCount}`);
5602
+ if (e.signal.errorCount > 0) bits.push(`errors=${e.signal.errorCount}`);
5603
+ if (e.signal.lastObservedAgeMs !== void 0) {
5604
+ bits.push(`age=${formatDuration(e.signal.lastObservedAgeMs)}`);
5578
5605
  }
5606
+ } else if (e.callCount !== void 0) {
5607
+ bits.push(`callCount=${e.callCount}`);
5579
5608
  }
5580
- const checks = await runDoctorChecks(deps);
5581
- if (json) out(JSON.stringify({ ok: checks.every((c) => c.ok), checks }, null, 2));
5582
- else renderHuman(checks, out);
5583
- return checks.every((c) => c.ok) ? 0 : 1;
5584
- }
5585
-
5586
- // src/login-cli.ts
5587
- import readline3 from "readline/promises";
5588
-
5589
- // src/profiles.ts
5590
- import { promises as fs11 } from "fs";
5591
- import os from "os";
5592
- import path12 from "path";
5593
- var PROFILES_CONFIG_VERSION = 1;
5594
- function neatHome() {
5595
- const override = process.env.NEAT_HOME;
5596
- if (override && override.length > 0) return path12.resolve(override);
5597
- return path12.join(os.homedir(), ".neat");
5598
- }
5599
- function profilesConfigPath(home = neatHome()) {
5600
- return path12.join(home, "profiles.json");
5609
+ if (e.lastObserved) bits.push(`lastObserved=${e.lastObserved}`);
5610
+ if (e.confidence !== void 0) bits.push(`confidence=${e.confidence}`);
5611
+ return bits.length ? ` [${bits.join(", ")}]` : "";
5601
5612
  }
5602
- function profilesConfigLockPath(home = neatHome()) {
5603
- return path12.join(home, "profiles.json.lock");
5613
+ function formatDuration(ms) {
5614
+ if (ms < 1e3) return `${Math.round(ms)}ms`;
5615
+ const s = Math.round(ms / 1e3);
5616
+ if (s < 60) return `${s}s`;
5617
+ const m = Math.round(s / 60);
5618
+ if (m < 60) return `${m}m`;
5619
+ const h = Math.round(m / 60);
5620
+ if (h < 48) return `${h}h`;
5621
+ return `${Math.round(h / 24)}d`;
5604
5622
  }
5605
- var MODE_MASK_LOOSER_THAN_0600 = 63;
5606
- async function warnIfModeLooserThan0600(file) {
5607
- if (process.platform === "win32") return;
5623
+ async function runIncidents(client, input) {
5624
+ const path19 = input.nodeId ? projectPath(input.project, `/incidents/${encodeURIComponent(input.nodeId)}`) : projectPath(input.project, "/incidents");
5608
5625
  try {
5609
- const stat = await fs11.stat(file);
5610
- if ((stat.mode & MODE_MASK_LOOSER_THAN_0600) !== 0) {
5611
- const mode = (stat.mode & 511).toString(8).padStart(3, "0");
5612
- console.warn(
5613
- `[neat] ${file} is mode 0${mode}, looser than the 0600 this file's token calls for \u2014 run \`chmod 600 ${file}\``
5614
- );
5626
+ const body = await client.get(path19);
5627
+ const events = body.events;
5628
+ if (events.length === 0) {
5629
+ return {
5630
+ summary: input.nodeId ? `No incidents recorded against ${input.nodeId}.` : "No incidents recorded."
5631
+ };
5615
5632
  }
5616
- } catch {
5617
- }
5618
- }
5619
- async function readProfilesConfig(home = neatHome()) {
5620
- const file = profilesConfigPath(home);
5621
- let raw;
5622
- try {
5623
- raw = await fs11.readFile(file, "utf8");
5633
+ const ordered = [...events].reverse().slice(0, input.limit ?? 20);
5634
+ const blockLines = [];
5635
+ for (const ev of ordered) {
5636
+ blockLines.push(` ${ev.timestamp} \u2014 ${ev.service}: ${ev.errorMessage}`);
5637
+ blockLines.push(` trace=${ev.traceId} span=${ev.spanId}`);
5638
+ }
5639
+ const target = input.nodeId ?? "the project";
5640
+ return {
5641
+ summary: `${target} has ${body.total} recorded incident${body.total === 1 ? "" : "s"}; showing the ${ordered.length} most recent.`,
5642
+ block: blockLines.join("\n"),
5643
+ provenance: Provenance2.OBSERVED
5644
+ };
5624
5645
  } catch (err) {
5625
- if (err.code === "ENOENT") {
5626
- return { version: PROFILES_CONFIG_VERSION, profiles: [] };
5646
+ if (err instanceof HttpError && err.status === 404) {
5647
+ return { summary: `Node ${input.nodeId ?? ""} not found in the graph.` };
5627
5648
  }
5628
5649
  throw err;
5629
5650
  }
5630
- await warnIfModeLooserThan0600(file);
5631
- let parsed;
5632
- try {
5633
- parsed = JSON.parse(raw);
5634
- } catch (err) {
5635
- throw new Error(`${file} is not valid JSON: ${err.message}`);
5651
+ }
5652
+ async function runSearch(client, input) {
5653
+ const result = await client.get(
5654
+ projectPath(input.project, `/search?q=${encodeURIComponent(input.query)}`)
5655
+ );
5656
+ if (result.matches.length === 0) {
5657
+ return { summary: `No matches for "${input.query}".` };
5636
5658
  }
5637
- return validateConfig(parsed, file);
5659
+ const provider = result.provider ?? "substring";
5660
+ const blockLines = [];
5661
+ let topScore;
5662
+ for (const n of result.matches) {
5663
+ const score = provider !== "substring" && typeof n.score === "number" ? n.score : void 0;
5664
+ const scoreBit = score !== void 0 ? ` [score=${score.toFixed(2)}]` : "";
5665
+ if (score !== void 0 && (topScore === void 0 || score > topScore)) topScore = score;
5666
+ blockLines.push(
5667
+ ` \u2022 ${n.id} (${n.type}) \u2014 ${n.name ?? n.id}${scoreBit}`
5668
+ );
5669
+ }
5670
+ return {
5671
+ summary: `Found ${result.matches.length} match${result.matches.length === 1 ? "" : "es"} for "${input.query}" via ${provider} provider.`,
5672
+ block: blockLines.join("\n"),
5673
+ confidence: topScore
5674
+ };
5638
5675
  }
5639
- function validateConfig(parsed, file) {
5640
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
5641
- throw new Error(`${file} must be a JSON object with a "profiles" array`);
5676
+ async function runDiff(client, input) {
5677
+ const result = await client.get(
5678
+ projectPath(
5679
+ input.project,
5680
+ `/graph/diff?against=${encodeURIComponent(input.againstSnapshot)}`
5681
+ )
5682
+ );
5683
+ const total = result.added.nodes.length + result.added.edges.length + result.removed.nodes.length + result.removed.edges.length + result.changed.nodes.length + result.changed.edges.length;
5684
+ const baseLabel = result.base.exportedAt ?? "unknown";
5685
+ if (total === 0) {
5686
+ return {
5687
+ summary: `No differences between the current graph and ${input.againstSnapshot} (base exportedAt=${baseLabel}).`
5688
+ };
5642
5689
  }
5643
- const obj = parsed;
5644
- const version = obj.version === void 0 ? PROFILES_CONFIG_VERSION : obj.version;
5645
- if (typeof version !== "number" || !Number.isInteger(version)) {
5646
- throw new Error(`${file}: "version" must be an integer`);
5690
+ const blockLines = [
5691
+ ` base exportedAt: ${baseLabel}`,
5692
+ ` current exportedAt: ${result.current.exportedAt}`,
5693
+ ""
5694
+ ];
5695
+ if (result.added.nodes.length || result.added.edges.length) {
5696
+ blockLines.push("Added:");
5697
+ for (const n of result.added.nodes) blockLines.push(` + node ${n.id} (${n.type})`);
5698
+ for (const e of result.added.edges)
5699
+ blockLines.push(` + edge ${e.id} \u2014 ${e.source} -> ${e.target} (${e.type}, ${e.provenance})`);
5700
+ blockLines.push("");
5647
5701
  }
5648
- const rawProfiles = obj.profiles;
5649
- if (!Array.isArray(rawProfiles)) {
5650
- throw new Error(`${file}: "profiles" must be an array`);
5702
+ if (result.removed.nodes.length || result.removed.edges.length) {
5703
+ blockLines.push("Removed:");
5704
+ for (const n of result.removed.nodes) blockLines.push(` - node ${n.id} (${n.type})`);
5705
+ for (const e of result.removed.edges)
5706
+ blockLines.push(` - edge ${e.id} \u2014 ${e.source} -> ${e.target} (${e.type}, ${e.provenance})`);
5707
+ blockLines.push("");
5651
5708
  }
5652
- const profiles = rawProfiles.map((entry2, i) => validateEntry(entry2, i, file));
5653
- const seen = /* @__PURE__ */ new Set();
5654
- for (const p of profiles) {
5655
- if (seen.has(p.name)) throw new Error(`${file}: duplicate profile name "${p.name}"`);
5656
- seen.add(p.name);
5709
+ if (result.changed.nodes.length || result.changed.edges.length) {
5710
+ blockLines.push("Changed:");
5711
+ for (const c of result.changed.nodes) {
5712
+ blockLines.push(` ~ node ${c.id} \u2014 ${summariseAttrDiff(c.before, c.after)}`);
5713
+ }
5714
+ for (const c of result.changed.edges) {
5715
+ const provBit = c.before.provenance !== c.after.provenance ? `provenance ${c.before.provenance} \u2192 ${c.after.provenance}` : summariseAttrDiff(c.before, c.after);
5716
+ blockLines.push(` ~ edge ${c.id} \u2014 ${provBit}`);
5717
+ }
5657
5718
  }
5658
- let active;
5659
- if (obj.active !== void 0) {
5660
- if (typeof obj.active !== "string" || obj.active.length === 0) {
5661
- throw new Error(`${file}: "active" must be a non-empty string when present`);
5719
+ return {
5720
+ summary: `Diff against ${input.againstSnapshot}: ${total} change${total === 1 ? "" : "s"} between the snapshot and the live graph.`,
5721
+ block: blockLines.join("\n").trimEnd()
5722
+ };
5723
+ }
5724
+ function summariseAttrDiff(before, after) {
5725
+ const keys = /* @__PURE__ */ new Set([...Object.keys(before), ...Object.keys(after)]);
5726
+ const changed = [];
5727
+ for (const k of keys) {
5728
+ if (JSON.stringify(before[k]) !== JSON.stringify(after[k])) changed.push(k);
5729
+ }
5730
+ return changed.length === 0 ? "attributes differ" : `fields changed: ${changed.sort().join(", ")}`;
5731
+ }
5732
+ async function runStaleEdges(client, input) {
5733
+ const params = new URLSearchParams();
5734
+ if (input.limit !== void 0) params.set("limit", String(input.limit));
5735
+ if (input.edgeType) params.set("edgeType", input.edgeType);
5736
+ const qs = params.size > 0 ? `?${params.toString()}` : "";
5737
+ const body = await client.get(
5738
+ projectPath(input.project, `/stale-events${qs}`)
5739
+ );
5740
+ const events = body.events;
5741
+ if (events.length === 0) {
5742
+ return {
5743
+ summary: input.edgeType ? `No stale ${input.edgeType} edges recorded.` : "No stale-edge transitions recorded yet."
5744
+ };
5745
+ }
5746
+ const blockLines = events.map(
5747
+ (e) => ` ${e.transitionedAt} \u2014 ${e.source} -[${e.edgeType}]-> ${e.target} (last seen ${e.lastObserved}, threshold ${formatDuration(e.thresholdMs)})`
5748
+ );
5749
+ return {
5750
+ summary: `${events.length} stale-edge transition${events.length === 1 ? "" : "s"} recorded${input.edgeType ? ` for ${input.edgeType}` : ""}.`,
5751
+ block: blockLines.join("\n"),
5752
+ provenance: Provenance2.STALE
5753
+ };
5754
+ }
5755
+ async function runPolicies(client, input) {
5756
+ let violations;
5757
+ let allowed = true;
5758
+ let hypothetical;
5759
+ if (input.hypotheticalAction) {
5760
+ if (typeof client.post !== "function") {
5761
+ throw new Error("HttpClient does not support POST \u2014 required for policies dry-run");
5662
5762
  }
5663
- active = seen.has(obj.active) ? obj.active : void 0;
5763
+ const body = await client.post(
5764
+ projectPath(input.project, "/policies/check"),
5765
+ { hypotheticalAction: input.hypotheticalAction }
5766
+ );
5767
+ violations = body.violations;
5768
+ allowed = body.allowed;
5769
+ hypothetical = body.hypotheticalAction;
5770
+ } else {
5771
+ const params = new URLSearchParams();
5772
+ if (input.policyId) params.set("policyId", input.policyId);
5773
+ const qs = params.size > 0 ? `?${params.toString()}` : "";
5774
+ const body = await client.get(
5775
+ projectPath(input.project, `/policies/violations${qs}`)
5776
+ );
5777
+ violations = body.violations;
5778
+ allowed = violations.every((v) => v.onViolation !== "block");
5664
5779
  }
5665
- return { version, ...active ? { active } : {}, profiles };
5780
+ if (input.nodeId) {
5781
+ violations = violations.filter(
5782
+ (v) => v.subject.nodeId === input.nodeId || v.subject.path?.includes(input.nodeId)
5783
+ );
5784
+ }
5785
+ if (violations.length === 0) {
5786
+ return {
5787
+ summary: hypothetical ? `No violations would result from the hypothetical action (${hypothetical.kind}).` : "No policy violations recorded."
5788
+ };
5789
+ }
5790
+ const blockCount = violations.filter((v) => v.onViolation === "block").length;
5791
+ const summaryParts = [];
5792
+ if (hypothetical) {
5793
+ summaryParts.push(
5794
+ `Hypothetical ${hypothetical.kind} would surface ${violations.length} violation${violations.length === 1 ? "" : "s"}`
5795
+ );
5796
+ } else {
5797
+ summaryParts.push(
5798
+ `${violations.length} policy violation${violations.length === 1 ? "" : "s"} currently recorded`
5799
+ );
5800
+ }
5801
+ if (blockCount > 0) summaryParts.push(`${blockCount} of which block`);
5802
+ if (!allowed && hypothetical) summaryParts.push("action denied");
5803
+ const summary = summaryParts.join("; ") + ".";
5804
+ const blockLines = violations.map((v) => {
5805
+ const subject = v.subject.nodeId ?? v.subject.edgeId ?? v.subject.path?.[0] ?? "(global)";
5806
+ return ` \u2022 [${v.severity}/${v.onViolation}] ${v.policyName}: ${v.message} \u2014 ${subject}`;
5807
+ });
5808
+ const severities = [...new Set(violations.map((v) => v.severity))];
5809
+ return {
5810
+ summary,
5811
+ block: blockLines.join("\n"),
5812
+ confidence: hypothetical ? 0.7 : 1,
5813
+ provenance: severities.join(" ")
5814
+ };
5666
5815
  }
5667
- function validateEntry(entry2, index, file) {
5668
- const where = `${file}: profiles[${index}]`;
5669
- if (typeof entry2 !== "object" || entry2 === null || Array.isArray(entry2)) {
5670
- throw new Error(`${where} must be an object`);
5816
+ function formatDivergenceLine(d) {
5817
+ switch (d.type) {
5818
+ case "missing-observed":
5819
+ case "missing-extracted":
5820
+ if (d.column) {
5821
+ return ` \u2022 [${d.type}] ${d.table ?? d.source} column ${d.column} \u2014 confidence ${d.confidence.toFixed(2)}`;
5822
+ }
5823
+ return ` \u2022 [${d.type}] ${d.source} \u2192 ${d.target} (${d.edgeType}) \u2014 confidence ${d.confidence.toFixed(2)}`;
5824
+ case "version-mismatch":
5825
+ return ` \u2022 [${d.type}] ${d.source} \u2192 ${d.target} \u2014 declared ${d.extractedVersion}, observed engine ${d.observedVersion} (${d.compatibility})`;
5826
+ case "host-mismatch":
5827
+ return ` \u2022 [${d.type}] ${d.source} \u2192 ${d.target} \u2014 declared host ${d.extractedHost}, observed host ${d.observedHost}`;
5828
+ case "compat-violation":
5829
+ return ` \u2022 [${d.type}] ${d.source} \u2192 ${d.target} \u2014 ${d.rule.kind}${d.rule.package ? ` (${d.rule.package})` : ""}`;
5830
+ case "observed-symbol-mismatch": {
5831
+ const at = d.location ? ` at ${d.location}` : "";
5832
+ const member = d.symbol ? ` ${d.symbol}` : "";
5833
+ return ` \u2022 [${d.type}] ${d.source}${member}${at} (${d.mismatchKind}) \u2014 confidence ${d.confidence.toFixed(2)}`;
5834
+ }
5835
+ case "observed-failing": {
5836
+ if (d.edgeType) {
5837
+ const rate = d.errorRate !== void 0 ? ` ${Math.round(d.errorRate * 100)}% errors` : "";
5838
+ return ` \u2022 [${d.type}] ${d.source} \u2192 ${d.target} (${d.edgeType}) \u2014${rate} (${d.failureKind}) \u2014 confidence ${d.confidence.toFixed(2)}`;
5839
+ }
5840
+ const at = d.location ? ` at ${d.location}` : "";
5841
+ return ` \u2022 [${d.type}] ${d.source}${at} (${d.failureKind}) \u2014 confidence ${d.confidence.toFixed(2)}`;
5842
+ }
5843
+ case "deploy-mismatch": {
5844
+ if (d.kind === "image") {
5845
+ return ` \u2022 [${d.type}] ${d.source} \u2014 declared image ${d.declaredImage ?? "?"}, running ${d.observedImage ?? "?"} \u2014 confidence ${d.confidence.toFixed(2)}`;
5846
+ }
5847
+ return ` \u2022 [${d.type}] ${d.source} \u2014 declared ${d.declaredReplicas ?? "?"} replicas, ${d.observedReplicas ?? "?"} ready \u2014 confidence ${d.confidence.toFixed(2)}`;
5848
+ }
5671
5849
  }
5672
- const e = entry2;
5673
- const name = e.name;
5674
- if (typeof name !== "string" || name.length === 0) {
5675
- throw new Error(`${where}.name must be a non-empty string`);
5850
+ }
5851
+ async function runDivergences(client, input) {
5852
+ const params = new URLSearchParams();
5853
+ if (input.type && input.type.length > 0) params.set("type", input.type.join(","));
5854
+ if (input.minConfidence !== void 0) {
5855
+ params.set("minConfidence", String(input.minConfidence));
5676
5856
  }
5677
- const endpoint = e.endpoint;
5678
- if (typeof endpoint !== "string" || endpoint.length === 0) {
5679
- throw new Error(`${where}.endpoint must be a non-empty string`);
5857
+ if (input.node) params.set("node", input.node);
5858
+ const qs = params.size > 0 ? `?${params.toString()}` : "";
5859
+ const result = await client.get(
5860
+ projectPath(input.project, `/graph/divergences${qs}`)
5861
+ );
5862
+ if (result.totalAffected === 0) {
5863
+ return {
5864
+ summary: "No divergences found between the declared (EXTRACTED) and observed (OBSERVED) views of the graph."
5865
+ };
5680
5866
  }
5681
- let parsedUrl;
5682
- try {
5683
- parsedUrl = new URL(endpoint);
5684
- } catch {
5685
- throw new Error(`${where}.endpoint must be an absolute URL (got "${endpoint}")`);
5867
+ const headline = result.divergences[0];
5868
+ const summary = `Found ${result.totalAffected} divergence${result.totalAffected === 1 ? "" : "s"} between code and production. Highest-confidence: ${headline.type} on ${headline.source} \u2192 ${headline.target}. ${headline.reason}`;
5869
+ const blockLines = [];
5870
+ for (const d of result.divergences) {
5871
+ blockLines.push(formatDivergenceLine(d));
5872
+ blockLines.push(` reason: ${d.reason}`);
5873
+ blockLines.push(` recommendation: ${d.recommendation}`);
5686
5874
  }
5687
- if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
5688
- throw new Error(`${where}.endpoint must be an http(s) URL (got "${parsedUrl.protocol}")`);
5875
+ const maxConfidence = result.divergences.reduce(
5876
+ (m, d) => Math.max(m, d.confidence),
5877
+ 0
5878
+ );
5879
+ return {
5880
+ summary,
5881
+ block: blockLines.join("\n"),
5882
+ confidence: maxConfidence,
5883
+ provenance: "composite (EXTRACTED + OBSERVED)"
5884
+ };
5885
+ }
5886
+ async function runAsk(client, input) {
5887
+ const result = await client.get(
5888
+ projectPath(input.project, `/graph/ask?q=${encodeURIComponent(input.question)}`)
5889
+ );
5890
+ const blockLines = [];
5891
+ if (result.matched.length > 0) {
5892
+ blockLines.push(
5893
+ `Matched: ${result.matched.map((m) => `${m.nodeId} [${m.via} ${m.score.toFixed(2)}]`).join(", ")}`
5894
+ );
5895
+ blockLines.push(`Intent: ${result.intent}`);
5896
+ } else if (result.scope === "global") {
5897
+ blockLines.push(`Graph-wide answer (${result.intent}) \u2014 no entity named.`);
5689
5898
  }
5690
- if (e.authToken !== void 0 && (typeof e.authToken !== "string" || e.authToken.length === 0)) {
5691
- throw new Error(`${where}.authToken must be a non-empty string when present`);
5899
+ for (const section of result.sections) {
5900
+ blockLines.push("", section.heading + ":");
5901
+ for (const fact of section.facts) {
5902
+ const tag = fact.provenance ? ` [${fact.provenance}${fact.confidence !== void 0 ? ` ${fact.confidence.toFixed(2)}` : ""}]` : fact.confidence !== void 0 ? ` [confidence ${fact.confidence.toFixed(2)}]` : "";
5903
+ blockLines.push(` \u2022 ${fact.text}${tag}`);
5904
+ }
5692
5905
  }
5693
5906
  return {
5694
- name,
5695
- endpoint,
5696
- ...typeof e.authToken === "string" ? { authToken: e.authToken } : {}
5907
+ summary: result.answer,
5908
+ block: blockLines.join("\n").trim(),
5909
+ ...result.confidence !== void 0 ? { confidence: result.confidence } : {},
5910
+ ...result.provenance.length > 0 ? { provenance: result.provenance } : {}
5697
5911
  };
5698
5912
  }
5699
- async function resolveProfile(name, home = neatHome()) {
5700
- const { profiles } = await readProfilesConfig(home);
5701
- return profiles.find((p) => p.name === name);
5702
- }
5703
- async function getActiveProfile(home = neatHome()) {
5704
- const { active, profiles } = await readProfilesConfig(home);
5705
- if (!active) return void 0;
5706
- return profiles.find((p) => p.name === active);
5913
+ function formatFooter(confidence, provenance) {
5914
+ const c = confidence === void 0 ? "n/a" : confidence.toFixed(2);
5915
+ const p = provenance === void 0 ? "n/a" : Array.isArray(provenance) ? [...new Set(provenance)].join(", ") : provenance;
5916
+ return `confidence: ${c} \xB7 provenance: ${p}`;
5707
5917
  }
5708
- function serialize(config) {
5709
- const names = new Set(config.profiles.map((p) => p.name));
5710
- const active = config.active && names.has(config.active) ? config.active : void 0;
5711
- const out = {
5712
- version: config.version ?? PROFILES_CONFIG_VERSION,
5713
- ...active ? { active } : {},
5714
- profiles: config.profiles
5715
- };
5716
- return `${JSON.stringify(out, null, 2)}
5717
- `;
5918
+ function formatHuman(result) {
5919
+ const sections = [result.summary.trim()];
5920
+ if (result.block && result.block.trim().length > 0) sections.push(result.block.trimEnd());
5921
+ sections.push(formatFooter(result.confidence, result.provenance));
5922
+ return sections.join("\n\n");
5718
5923
  }
5719
- async function writeConfigAtomic(config, home) {
5720
- const file = profilesConfigPath(home);
5721
- await fs11.mkdir(path12.dirname(file), { recursive: true });
5722
- const tmp = `${file}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`;
5723
- const fd = await fs11.open(tmp, "w", 384);
5724
- try {
5725
- await fd.writeFile(serialize(config), "utf8");
5726
- await fd.sync();
5727
- } finally {
5728
- await fd.close();
5729
- }
5730
- await fs11.rename(tmp, file);
5924
+ function formatJson(result) {
5925
+ return JSON.stringify(
5926
+ {
5927
+ summary: result.summary,
5928
+ block: result.block ?? "",
5929
+ confidence: result.confidence ?? null,
5930
+ provenance: result.provenance ?? null
5931
+ },
5932
+ null,
5933
+ 2
5934
+ );
5731
5935
  }
5732
- var LOCK_RETRY_MS = 50;
5733
- var LOCK_TIMEOUT_MS = 5e3;
5734
- async function acquireLock(lockPath) {
5735
- await fs11.mkdir(path12.dirname(lockPath), { recursive: true });
5736
- const deadline = Date.now() + LOCK_TIMEOUT_MS;
5737
- for (; ; ) {
5738
- try {
5739
- const fd = await fs11.open(lockPath, "wx");
5740
- await fd.writeFile(`${process.pid}
5741
- `, "utf8");
5742
- await fd.close();
5743
- return;
5744
- } catch (err) {
5745
- if (err.code !== "EEXIST") throw err;
5746
- if (Date.now() >= deadline) {
5747
- throw new Error(
5748
- `timed out acquiring ${lockPath} after ${LOCK_TIMEOUT_MS}ms \u2014 if no other neat process is running, remove the stale lock file`
5749
- );
5750
- }
5751
- await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_MS));
5752
- }
5753
- }
5936
+ function exitCodeForError(err) {
5937
+ if (err instanceof TransportError) return 3;
5938
+ if (err instanceof HttpError) return 1;
5939
+ return 1;
5754
5940
  }
5755
- async function releaseLock(lockPath) {
5756
- await fs11.rm(lockPath, { force: true });
5941
+ function createSnapshotPushClient(baseUrl, token, fetchImpl) {
5942
+ return createHttpClient(baseUrl, token && token.length > 0 ? token : void 0, fetchImpl);
5757
5943
  }
5758
- async function withProfilesLock(home, fn) {
5759
- const lockPath = profilesConfigLockPath(home);
5760
- await acquireLock(lockPath);
5944
+ async function resolveRemoteProjectName(input, fetchImpl) {
5945
+ const client = createSnapshotPushClient(input.baseUrl, input.token, fetchImpl);
5761
5946
  try {
5762
- return await fn();
5763
- } finally {
5764
- await releaseLock(lockPath);
5947
+ const projects = await client.get("/projects");
5948
+ const hosted = Array.isArray(projects) ? projects.find((p) => p?.hostedHere) : void 0;
5949
+ return hosted?.name ?? input.fallback;
5950
+ } catch {
5951
+ return input.fallback;
5765
5952
  }
5766
5953
  }
5767
- async function upsertProfile(profile, opts = {}) {
5768
- const home = opts.home ?? neatHome();
5769
- const validated = validateEntry(profile, 0, profilesConfigPath(home));
5770
- await withProfilesLock(home, async () => {
5771
- const config = await readProfilesConfig(home);
5772
- const others = config.profiles.filter((p) => p.name !== validated.name);
5773
- const profiles = [...others, validated];
5774
- const makeActive = opts.makeActive ?? config.profiles.length === 0;
5775
- const active = makeActive ? validated.name : config.active;
5776
- await writeConfigAtomic({ version: config.version, ...active ? { active } : {}, profiles }, home);
5777
- });
5778
- }
5779
- async function removeProfile(name, home = neatHome()) {
5780
- return withProfilesLock(home, async () => {
5781
- const config = await readProfilesConfig(home);
5782
- const profiles = config.profiles.filter((p) => p.name !== name);
5783
- if (profiles.length === config.profiles.length) return false;
5784
- const active = config.active === name ? void 0 : config.active;
5785
- await writeConfigAtomic({ version: config.version, ...active ? { active } : {}, profiles }, home);
5786
- return true;
5787
- });
5788
- }
5789
- async function clearActiveProfile(home = neatHome()) {
5790
- return withProfilesLock(home, async () => {
5791
- const config = await readProfilesConfig(home);
5792
- if (!config.active) return false;
5793
- await writeConfigAtomic({ version: config.version, profiles: config.profiles }, home);
5794
- return true;
5795
- });
5954
+ async function pushSnapshotToRemote(input) {
5955
+ const client = createSnapshotPushClient(input.baseUrl, input.token);
5956
+ if (typeof client.post !== "function") {
5957
+ throw new Error("HttpClient does not support POST \u2014 required for snapshot push");
5958
+ }
5959
+ return client.post(
5960
+ `/projects/${encodeURIComponent(input.project)}/snapshot`,
5961
+ { snapshot: input.snapshot }
5962
+ );
5796
5963
  }
5797
5964
 
5798
- // src/login-sso.ts
5799
- import { createServer } from "http";
5800
- import { spawn as spawn3 } from "child_process";
5801
- import { randomBytes as randomBytes2 } from "crypto";
5802
- var DEFAULT_CP_URL = "https://neat-control-plane-bg5yqctn2q-nw.a.run.app";
5803
- var DEFAULT_WEB_URL = "https://app.neat.is";
5804
- var CALLBACK_TIMEOUT_MS = 5 * 6e4;
5805
- function resolveCpUrl(env = process.env, override) {
5806
- const v = override ?? env.NEAT_CP_URL;
5807
- return (v && v.length > 0 ? v : DEFAULT_CP_URL).replace(/\/+$/, "");
5808
- }
5809
- function resolveWebUrl(env = process.env, override) {
5810
- const v = override ?? env.NEAT_WEB_URL;
5811
- return (v && v.length > 0 ? v : DEFAULT_WEB_URL).replace(/\/+$/, "");
5812
- }
5813
- function pickProject(projects, want) {
5814
- if (want) {
5815
- const p = projects.find((x) => x.id === want || x.name === want);
5816
- if (!p) return { error: { code: 1, message: `no project named or id'd "${want}" on this account` } };
5817
- return { project: p };
5818
- }
5819
- const running = projects.filter((p) => p.status === "running");
5820
- if (running.length === 1) return { project: running[0] };
5821
- if (running.length === 0) {
5822
- const listed = projects.length ? ` (have: ${projects.map((p) => `${p.name} [${p.status}]`).join(", ")})` : "";
5823
- return {
5824
- error: {
5825
- code: 1,
5826
- message: `no running project to connect to \u2014 create + provision one in the console first${listed}`
5827
- }
5828
- };
5829
- }
5965
+ // src/doctor-cli.ts
5966
+ function resolveDeps2(deps) {
5830
5967
  return {
5831
- error: {
5832
- code: 2,
5833
- message: `several running projects \u2014 pass --project <name>: ${running.map((p) => p.name).join(", ")}`
5834
- }
5835
- };
5836
- }
5837
- async function exchangeCredential(cpUrl, accessToken, opts, deps) {
5838
- const fetchImpl = deps.fetchImpl ?? fetch;
5839
- const auth = { authorization: `Bearer ${accessToken}` };
5840
- let meRes;
5841
- try {
5842
- meRes = await fetchImpl(`${cpUrl}/me`, { headers: auth });
5843
- } catch (e) {
5844
- return { error: { code: 3, message: `can't reach the control plane at ${cpUrl} \u2014 ${e.message}` } };
5845
- }
5846
- if (meRes.status === 401) return { error: { code: 1, message: "your session is expired or invalid \u2014 log in again" } };
5847
- if (!meRes.ok) return { error: { code: 1, message: `the control plane returned HTTP ${meRes.status} on /me` } };
5848
- const me = await meRes.json().catch(() => ({}));
5849
- const projects = Array.isArray(me.projects) ? me.projects : [];
5850
- const picked = pickProject(projects, opts.project);
5851
- if ("error" in picked) return picked;
5852
- const project = picked.project;
5853
- let credRes;
5968
+ cwd: deps.cwd ?? process.cwd(),
5969
+ env: deps.env ?? process.env,
5970
+ nodeVersion: deps.nodeVersion ?? process.versions.node,
5971
+ fetchImpl: deps.fetchImpl ?? fetch,
5972
+ readRecord: deps.readRecord ?? readDaemonRecord,
5973
+ out: deps.out ?? ((line) => console.log(line))
5974
+ };
5975
+ }
5976
+ var NODE_FLOOR = 20;
5977
+ var HEALTH_TIMEOUT_MS = 3e3;
5978
+ function checkNode(nodeVersion) {
5979
+ const major = Number.parseInt(nodeVersion.split(".")[0] ?? "", 10);
5980
+ const ok = Number.isFinite(major) && major >= NODE_FLOOR;
5981
+ return ok ? { name: "node", ok, detail: `v${nodeVersion} (>= ${NODE_FLOOR} required)` } : {
5982
+ name: "node",
5983
+ ok,
5984
+ detail: `v${nodeVersion} \u2014 NEAT needs Node ${NODE_FLOOR} or newer`,
5985
+ fix: `install Node ${NODE_FLOOR}.x (e.g. \`nvm install ${NODE_FLOOR}\`) and re-run`
5986
+ };
5987
+ }
5988
+ async function neatOutExists(cwd) {
5854
5989
  try {
5855
- credRes = await fetchImpl(`${cpUrl}/me/projects/${encodeURIComponent(project.id)}/cli-credential`, {
5856
- headers: auth
5857
- });
5858
- } catch (e) {
5859
- return { error: { code: 3, message: `can't reach the control plane \u2014 ${e.message}` } };
5990
+ const st = await fs11.stat(path12.join(cwd, "neat-out"));
5991
+ return st.isDirectory();
5992
+ } catch {
5993
+ return false;
5860
5994
  }
5861
- if (credRes.status === 409) {
5995
+ }
5996
+ async function checkProject(cwd, record) {
5997
+ if (record) {
5862
5998
  return {
5863
- error: {
5864
- code: 1,
5865
- message: `project "${project.name}" isn't provisioned yet (status: ${project.status}) \u2014 provision it first`
5866
- }
5999
+ name: "project",
6000
+ ok: true,
6001
+ detail: `"${record.project}" \u2014 set up in this directory (daemon record on REST ${record.ports.rest})`
5867
6002
  };
5868
6003
  }
5869
- if (credRes.status === 404) return { error: { code: 1, message: `project "${project.name}" was not found, or isn't yours` } };
5870
- if (credRes.status === 401) return { error: { code: 1, message: "your session is expired or invalid \u2014 log in again" } };
5871
- if (!credRes.ok) return { error: { code: 1, message: `the control plane returned HTTP ${credRes.status} for the credential` } };
5872
- const cred = await credRes.json().catch(() => ({}));
5873
- if (!cred.endpoint || !cred.authToken) {
5874
- return { error: { code: 1, message: "the credential response was missing endpoint/authToken" } };
6004
+ if (await neatOutExists(cwd)) {
6005
+ return {
6006
+ name: "project",
6007
+ ok: true,
6008
+ detail: "set up in this directory (no live daemon record \u2014 it may be stopped)"
6009
+ };
5875
6010
  }
5876
- return { project, cred };
6011
+ return {
6012
+ name: "project",
6013
+ ok: false,
6014
+ detail: "no NEAT project in this directory",
6015
+ fix: "set one up: `neat .`"
6016
+ };
5877
6017
  }
5878
- function defaultOpenBrowser(url) {
5879
- const platform = process.platform;
5880
- const cmd = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
5881
- const args = platform === "win32" ? ["/c", "start", "", url] : [url];
6018
+ function resolveHealthUrl(env, record) {
6019
+ const explicit = env.NEAT_API_URL ?? env.NEAT_CORE_URL;
6020
+ if (explicit && explicit.length > 0) return explicit.replace(/\/$/, "");
6021
+ if (record) return `http://localhost:${record.ports.rest}`;
6022
+ return "http://localhost:8080";
6023
+ }
6024
+ function summariseHealth(url, body) {
6025
+ const projects = body.projects ?? [];
6026
+ const nodes = projects.reduce((n, p) => n + (p.nodeCount ?? 0), 0);
6027
+ const edges = projects.reduce((n, p) => n + (p.edgeCount ?? 0), 0);
6028
+ const proj = body.project ?? projects[0]?.name;
6029
+ const graph = projects.length > 0 ? ` \u2014 ${nodes} nodes / ${edges} edges` : "";
6030
+ return `up at ${url}${proj ? ` (project "${proj}"${graph})` : ""}`;
6031
+ }
6032
+ async function checkDaemon(deps, record) {
6033
+ const url = resolveHealthUrl(deps.env, record);
6034
+ const token = resolveAuthToken(deps.env);
6035
+ const headers = token ? { authorization: `Bearer ${token}` } : {};
5882
6036
  try {
5883
- const child = spawn3(cmd, args, { detached: true, stdio: "ignore" });
5884
- child.on("error", () => {
6037
+ const res = await deps.fetchImpl(`${url}/health`, {
6038
+ headers,
6039
+ signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS)
5885
6040
  });
5886
- child.unref();
5887
- return true;
6041
+ if (res.status === 401 || res.status === 403) {
6042
+ return {
6043
+ name: "daemon",
6044
+ ok: false,
6045
+ detail: `up at ${url}, but rejected the request (${res.status})`,
6046
+ fix: "set NEAT_AUTH_TOKEN to the daemon's token"
6047
+ };
6048
+ }
6049
+ if (!res.ok) {
6050
+ return {
6051
+ name: "daemon",
6052
+ ok: false,
6053
+ detail: `reachable at ${url} but /health returned ${res.status}`,
6054
+ fix: "check the daemon logs"
6055
+ };
6056
+ }
6057
+ const body = await res.json().catch(() => ({}));
6058
+ return { name: "daemon", ok: true, detail: summariseHealth(url, body) };
5888
6059
  } catch {
5889
- return false;
6060
+ return {
6061
+ name: "daemon",
6062
+ ok: false,
6063
+ detail: `down \u2014 nothing answering at ${url}`,
6064
+ fix: "start it: `neat .` (or `neat watch`)"
6065
+ };
5890
6066
  }
5891
6067
  }
5892
- async function loopbackReceiveToken(webUrl, deps, opts = {}) {
5893
- const out = deps.out ?? (() => {
5894
- });
5895
- const openFn = deps.openBrowser ?? defaultOpenBrowser;
5896
- const state = randomBytes2(16).toString("hex");
5897
- return new Promise((resolve) => {
5898
- let settled = false;
5899
- let timer;
5900
- const done = (r) => {
5901
- if (settled) return;
5902
- settled = true;
5903
- clearTimeout(timer);
5904
- try {
5905
- server.close();
5906
- } catch {
5907
- }
5908
- resolve(r);
5909
- };
5910
- const server = createServer((req, res) => {
5911
- const reqUrl = new URL(req.url ?? "/", "http://127.0.0.1");
5912
- if (reqUrl.pathname !== "/callback") {
5913
- res.writeHead(404);
5914
- res.end();
5915
- return;
5916
- }
5917
- const token = reqUrl.searchParams.get("token");
5918
- const gotState = reqUrl.searchParams.get("state");
5919
- if (!token || gotState !== state) {
5920
- res.writeHead(400, { "content-type": "text/html" });
5921
- res.end("<h1>NEAT login failed</h1><p>Invalid or mismatched token. You can close this tab.</p>");
5922
- done({ error: { code: 1, message: "the browser returned an invalid or mismatched token" } });
5923
- return;
5924
- }
5925
- res.writeHead(200, { "content-type": "text/html" });
5926
- res.end("<h1>You're logged in to NEAT.</h1><p>You can close this tab and return to the terminal.</p>");
5927
- done({ token });
5928
- });
5929
- server.on("error", (e) => done({ error: { code: 3, message: `couldn't start the local login listener \u2014 ${e.message}` } }));
5930
- timer = setTimeout(
5931
- () => done({ error: { code: 1, message: "timed out waiting for the browser login" } }),
5932
- opts.timeoutMs ?? CALLBACK_TIMEOUT_MS
5933
- );
5934
- server.listen(0, "127.0.0.1", () => {
5935
- const addr = server.address();
5936
- const port = typeof addr === "object" && addr ? addr.port : 0;
5937
- const callback = `http://127.0.0.1:${port}/callback`;
5938
- const authUrl = `${webUrl}/cli/auth?callback=${encodeURIComponent(callback)}&state=${state}`;
5939
- out("Opening your browser to log in to NEAT\u2026");
5940
- out(`If it doesn't open, visit:
5941
- ${authUrl}`);
5942
- openFn(authUrl);
5943
- });
5944
- });
6068
+ async function runDoctorChecks(deps = {}) {
6069
+ const d = resolveDeps2(deps);
6070
+ const record = await d.readRecord(d.cwd).catch(() => null);
6071
+ return [checkNode(d.nodeVersion), await checkProject(d.cwd, record), await checkDaemon(d, record)];
5945
6072
  }
5946
- async function runSsoLogin(opts, deps) {
5947
- const out = deps.out ?? ((l) => console.log(l));
5948
- const err = deps.err ?? ((l) => console.error(l));
5949
- let accessToken = opts.ssoToken;
5950
- if (!accessToken) {
5951
- const lb = await loopbackReceiveToken(opts.webUrl, deps, { timeoutMs: opts.timeoutMs ?? CALLBACK_TIMEOUT_MS });
5952
- if ("error" in lb) {
5953
- err(`neat login: ${lb.error.message}`);
5954
- return lb.error.code;
5955
- }
5956
- accessToken = lb.token;
5957
- }
5958
- const ex = await exchangeCredential(opts.cpUrl, accessToken, { project: opts.project }, deps);
5959
- if ("error" in ex) {
5960
- err(`neat login: ${ex.error.message}`);
5961
- return ex.error.code;
6073
+ var NAME_COL = "project".length;
6074
+ function renderHuman(checks, out) {
6075
+ out("neat doctor \u2014 checking this project's setup");
6076
+ out("");
6077
+ for (const c of checks) {
6078
+ const mark = c.ok ? "\u2713" : "\u2717";
6079
+ out(` ${mark} ${c.name.padEnd(NAME_COL)} ${c.detail}`);
6080
+ if (!c.ok && c.fix) out(` ${" ".repeat(NAME_COL + 3)}fix: ${c.fix}`);
5962
6081
  }
5963
- const { project, cred } = ex;
5964
- await upsertProfile(
5965
- { name: opts.name, endpoint: cred.endpoint, authToken: cred.authToken },
5966
- { makeActive: true, ...deps.home ? { home: deps.home } : {} }
5967
- );
5968
- if (opts.json) {
5969
- out(
5970
- JSON.stringify(
5971
- {
5972
- status: "logged-in",
5973
- profile: opts.name,
5974
- project: project.name,
5975
- endpoint: cred.endpoint,
5976
- ...cred.ingestEndpoint ? { ingestEndpoint: cred.ingestEndpoint } : {}
5977
- },
5978
- null,
5979
- 2
5980
- )
5981
- );
5982
- } else {
5983
- out(`Logged in \u2014 profile "${opts.name}" \u2192 ${project.name} (${cred.endpoint})`);
5984
- out("The neat CLI and the MCP server now read this hosted graph by default.");
5985
- if (cred.ingestEndpoint && cred.otelToken) {
5986
- out("");
5987
- out("To fill the OBSERVED layer, instrument your app to send OpenTelemetry to the hosted daemon:");
5988
- out(` OTEL_EXPORTER_OTLP_ENDPOINT=${cred.ingestEndpoint}`);
5989
- out(` OTEL_EXPORTER_OTLP_HEADERS=Authorization=Bearer ${cred.otelToken}`);
6082
+ out("");
6083
+ const failed = checks.filter((c) => !c.ok).length;
6084
+ out(failed === 0 ? "all good." : `${failed} check${failed === 1 ? "" : "s"} failed.`);
6085
+ }
6086
+ async function runDoctorCommand(argv, deps = {}) {
6087
+ const out = deps.out ?? ((line) => console.log(line));
6088
+ let json = false;
6089
+ for (const arg of argv) {
6090
+ if (arg === "--json") json = true;
6091
+ else if (arg === "-h" || arg === "--help") {
6092
+ out("usage: neat doctor [--json]");
6093
+ out(" Probe this directory's NEAT setup: Node version, project, daemon.");
6094
+ out(" Exit 0 when every check passes, 1 when any fails.");
6095
+ return 0;
6096
+ } else {
6097
+ out(`neat doctor: unknown argument "${arg}"`);
6098
+ return 2;
5990
6099
  }
5991
- out("Run `neat logout` to switch back to your local daemon.");
5992
6100
  }
5993
- return 0;
6101
+ const checks = await runDoctorChecks(deps);
6102
+ if (json) out(JSON.stringify({ ok: checks.every((c) => c.ok), checks }, null, 2));
6103
+ else renderHuman(checks, out);
6104
+ return checks.every((c) => c.ok) ? 0 : 1;
5994
6105
  }
5995
6106
 
5996
6107
  // src/login-cli.ts
6108
+ import readline3 from "readline/promises";
5997
6109
  var PROBE_ATTEMPT_TIMEOUT_MS = 3e4;
5998
6110
  var PROBE_TOTAL_BUDGET_MS = 12e4;
5999
6111
  var PROBE_RETRY_PAUSE_MS = 2e3;
@@ -7781,6 +7893,14 @@ async function checkDaemonHealth(baseUrl) {
7781
7893
  return false;
7782
7894
  }
7783
7895
  }
7896
+ function daemonHint(body) {
7897
+ try {
7898
+ const parsed = JSON.parse(body);
7899
+ return typeof parsed.hint === "string" ? parsed.hint : void 0;
7900
+ } catch {
7901
+ return void 0;
7902
+ }
7903
+ }
7784
7904
  function snapshotForGraph(persisted) {
7785
7905
  return {
7786
7906
  // Stamp the live schema version the daemon validates against on the
@@ -7848,21 +7968,34 @@ async function runSync(opts) {
7848
7968
  let daemonState = "skipped";
7849
7969
  let exitCode = 0;
7850
7970
  const mode = opts.dryRun ? "dry-run" : opts.to ? "remote" : "local";
7971
+ let pushedProject = entry2.name;
7851
7972
  if (!opts.dryRun) {
7852
7973
  const snapshot = snapshotForGraph(persisted);
7853
7974
  if (opts.to) {
7854
7975
  const token = opts.token ?? process.env.NEAT_REMOTE_TOKEN;
7976
+ pushedProject = await resolveRemoteProjectName({
7977
+ baseUrl: opts.to,
7978
+ token,
7979
+ fallback: entry2.name
7980
+ });
7855
7981
  try {
7856
7982
  await pushSnapshotToRemote({
7857
7983
  baseUrl: opts.to,
7858
7984
  token,
7859
- project: entry2.name,
7985
+ project: pushedProject,
7860
7986
  snapshot
7861
7987
  });
7862
7988
  daemonState = "remote-ok";
7863
7989
  } catch (err) {
7864
7990
  if (err instanceof HttpError) {
7865
- console.error(`neat sync: ${err.message}`);
7991
+ if (err.status === 404) {
7992
+ const hint = daemonHint(err.responseBody);
7993
+ console.error(
7994
+ `neat sync: the daemon at ${opts.to} has no project "${pushedProject}" to receive this snapshot. ` + (hint ?? "GET /projects lists what it serves (see hostedHere).")
7995
+ );
7996
+ } else {
7997
+ console.error(`neat sync: ${err.message}`);
7998
+ }
7866
7999
  exitCode = 1;
7867
8000
  } else if (err instanceof TransportError) {
7868
8001
  console.error(`neat sync: ${err.message}`);
@@ -7903,7 +8036,7 @@ async function runSync(opts) {
7903
8036
  }
7904
8037
  const result = {
7905
8038
  exitCode,
7906
- project: entry2.name,
8039
+ project: pushedProject,
7907
8040
  scanPath: entry2.path,
7908
8041
  nodesAdded: persisted.nodesAdded,
7909
8042
  edgesAdded: persisted.edgesAdded,
@@ -8518,6 +8651,11 @@ async function main() {
8518
8651
  if (code !== 0) process.exit(code);
8519
8652
  return;
8520
8653
  }
8654
+ if (cmd0 === "connect") {
8655
+ const code = await runConnectCommand(argv.slice(1));
8656
+ if (code !== 0) process.exit(code);
8657
+ return;
8658
+ }
8521
8659
  if (cmd0 === "doctor") {
8522
8660
  const code = await runDoctorCommand(argv.slice(1));
8523
8661
  if (code !== 0) process.exit(code);