@skill-map/cli 0.10.0 → 0.11.1

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
@@ -409,11 +409,11 @@ function buildIgnoreFilter(opts = {}) {
409
409
  ig.add(opts.ignoreFileText);
410
410
  }
411
411
  return {
412
- ignores(relativePath) {
413
- if (relativePath === "" || relativePath === "." || relativePath === "./") {
412
+ ignores(relativePath2) {
413
+ if (relativePath2 === "" || relativePath2 === "." || relativePath2 === "./") {
414
414
  return false;
415
415
  }
416
- const normalised = relativePath.replace(/^\.\//, "").replace(/\\/g, "/").replace(/^\//, "");
416
+ const normalised = relativePath2.replace(/^\.\//, "").replace(/\\/g, "/").replace(/^\//, "");
417
417
  if (normalised === "") return false;
418
418
  return ig.ignores(normalised);
419
419
  }
@@ -624,31 +624,79 @@ var claudeProvider = {
624
624
  // pairs the relative manifest-style schema path (mirrors what the
625
625
  // spec's provider.schema.json validates) with the loaded JSON Schema
626
626
  // (`schemaJson`) the kernel registers with AJV at scan boot.
627
+ // Step 14.5.d: each kind declares its UI presentation (label, color,
628
+ // dark variant, icon). The UI consumes this registry via the
629
+ // `kindRegistry` field embedded in REST envelopes; it derives bg/fg
630
+ // tints from `color` per theme via a deterministic helper, so the
631
+ // Provider only declares intent (one base color per theme) instead of
632
+ // four hex values. Colors and SVG paths transplanted verbatim from
633
+ // the previous static UI catalog (`ui/src/styles.css` for hex,
634
+ // `ui/src/app/components/kind-icon/kind-icon.html` for SVG path data,
635
+ // `ui/src/i18n/kinds.texts.ts` for labels).
627
636
  kinds: {
628
637
  agent: {
629
638
  schema: "./schemas/agent.schema.json",
630
639
  schemaJson: agent_schema_default,
631
- defaultRefreshAction: "claude/summarize-agent"
640
+ defaultRefreshAction: "claude/summarize-agent",
641
+ ui: {
642
+ label: "Agents",
643
+ color: "#3b82f6",
644
+ colorDark: "#60a5fa",
645
+ icon: { kind: "pi", id: "pi-user" }
646
+ }
632
647
  },
633
648
  command: {
634
649
  schema: "./schemas/command.schema.json",
635
650
  schemaJson: command_schema_default,
636
- defaultRefreshAction: "claude/summarize-command"
651
+ defaultRefreshAction: "claude/summarize-command",
652
+ ui: {
653
+ label: "Commands",
654
+ color: "#f59e0b",
655
+ colorDark: "#fbbf24",
656
+ icon: {
657
+ kind: "svg",
658
+ path: "M4 17 L10 11 L4 5 M12 19 L20 19"
659
+ }
660
+ }
637
661
  },
638
662
  hook: {
639
663
  schema: "./schemas/hook.schema.json",
640
664
  schemaJson: hook_schema_default,
641
- defaultRefreshAction: "claude/summarize-hook"
665
+ defaultRefreshAction: "claude/summarize-hook",
666
+ ui: {
667
+ label: "Hooks",
668
+ color: "#8b5cf6",
669
+ colorDark: "#a78bfa",
670
+ icon: {
671
+ kind: "svg",
672
+ path: "M12 2 a3 3 0 1 0 0 6 a3 3 0 1 0 0 -6 M12 8 L12 22 M5 12 H2 a10 10 0 0 0 20 0 H19"
673
+ }
674
+ }
642
675
  },
643
676
  skill: {
644
677
  schema: "./schemas/skill.schema.json",
645
678
  schemaJson: skill_schema_default,
646
- defaultRefreshAction: "claude/summarize-skill"
679
+ defaultRefreshAction: "claude/summarize-skill",
680
+ ui: {
681
+ label: "Skills",
682
+ color: "#10b981",
683
+ colorDark: "#34d399",
684
+ icon: { kind: "pi", id: "pi-bolt" }
685
+ }
647
686
  },
648
687
  note: {
649
688
  schema: "./schemas/note.schema.json",
650
689
  schemaJson: note_schema_default,
651
- defaultRefreshAction: "claude/summarize-note"
690
+ defaultRefreshAction: "claude/summarize-note",
691
+ ui: {
692
+ label: "Notes",
693
+ color: "#5b908c",
694
+ colorDark: "#9bbcb8",
695
+ icon: {
696
+ kind: "svg",
697
+ path: "M14 2 H6 a2 2 0 0 0 -2 2 V20 a2 2 0 0 0 2 2 H18 a2 2 0 0 0 2 -2 V8 L14 2 M14 2 V8 H20 M16 13 H8 M16 17 H8 M10 9 H8"
698
+ }
699
+ }
652
700
  }
653
701
  },
654
702
  async *walk(roots, options = {}) {
@@ -6980,7 +7028,7 @@ import { Command as Command8, Option as Option8 } from "clipanion";
6980
7028
  // package.json
6981
7029
  var package_default = {
6982
7030
  name: "@skill-map/cli",
6983
- version: "0.10.0",
7031
+ version: "0.11.1",
6984
7032
  description: "skill-map reference implementation \u2014 kernel + CLI + adapters.",
6985
7033
  license: "MIT",
6986
7034
  type: "module",
@@ -9208,8 +9256,8 @@ function renderTable(rows) {
9208
9256
  HISTORY_TEXTS.tableHeaderTokens,
9209
9257
  HISTORY_TEXTS.tableHeaderNodes
9210
9258
  );
9211
- const sep4 = "-".repeat(header.length);
9212
- const lines = [header, sep4];
9259
+ const sep5 = "-".repeat(header.length);
9260
+ const lines = [header, sep5];
9213
9261
  for (const r of rows) {
9214
9262
  const tokens = `${r.tokensIn ?? 0}/${r.tokensOut ?? 0}`;
9215
9263
  const duration = r.durationMs === null || r.durationMs === void 0 ? "-" : formatElapsed(r.durationMs);
@@ -9618,8 +9666,8 @@ function renderTable2(nodes, issuesByNode) {
9618
9666
  LIST_TEXTS.tableHeaderIssues,
9619
9667
  LIST_TEXTS.tableHeaderBytes
9620
9668
  );
9621
- const sep4 = "-".repeat(header.length);
9622
- const lines = [header, sep4];
9669
+ const sep5 = "-".repeat(header.length);
9670
+ const lines = [header, sep5];
9623
9671
  for (const node of nodes) {
9624
9672
  lines.push(
9625
9673
  formatRow2(
@@ -11813,8 +11861,178 @@ import { WebSocketServer } from "ws";
11813
11861
 
11814
11862
  // server/app.ts
11815
11863
  import { Hono } from "hono";
11864
+ import { HTTPException as HTTPException5 } from "hono/http-exception";
11865
+
11866
+ // server/routes/config.ts
11816
11867
  import { HTTPException } from "hono/http-exception";
11817
11868
 
11869
+ // server/envelope.ts
11870
+ var REST_ENVELOPE_SCHEMA_VERSION = "1";
11871
+ function buildListEnvelope(opts) {
11872
+ const counts = {
11873
+ total: opts.total,
11874
+ returned: opts.items.length
11875
+ };
11876
+ if (opts.page) counts.page = opts.page;
11877
+ return {
11878
+ schemaVersion: REST_ENVELOPE_SCHEMA_VERSION,
11879
+ kind: opts.kind,
11880
+ items: opts.items,
11881
+ filters: opts.filters,
11882
+ counts,
11883
+ kindRegistry: opts.kindRegistry
11884
+ };
11885
+ }
11886
+ function buildValueEnvelope(kind, value, kindRegistry) {
11887
+ return {
11888
+ schemaVersion: REST_ENVELOPE_SCHEMA_VERSION,
11889
+ kind,
11890
+ value,
11891
+ kindRegistry
11892
+ };
11893
+ }
11894
+
11895
+ // server/routes/config.ts
11896
+ function registerConfigRoute(app, deps) {
11897
+ app.get("/api/config", (c) => {
11898
+ let loaded;
11899
+ try {
11900
+ loaded = loadConfig({
11901
+ scope: deps.options.scope,
11902
+ cwd: deps.runtimeContext.cwd,
11903
+ homedir: deps.runtimeContext.homedir
11904
+ });
11905
+ } catch (err) {
11906
+ throw new HTTPException(500, { message: formatErrorMessage(err) });
11907
+ }
11908
+ for (const warn of loaded.warnings) {
11909
+ process.stderr.write(`${warn}
11910
+ `);
11911
+ }
11912
+ return c.json(buildValueEnvelope("config", loaded.effective, deps.kindRegistry));
11913
+ });
11914
+ }
11915
+
11916
+ // server/routes/graph.ts
11917
+ import { HTTPException as HTTPException2 } from "hono/http-exception";
11918
+
11919
+ // server/i18n/server.texts.ts
11920
+ var SERVER_TEXTS = {
11921
+ // Boot banner — printed by the server itself when it begins to listen.
11922
+ // The CLI verb `sm serve` formats its own boot banner separately
11923
+ // (SERVE_TEXTS.boot) so the two surfaces can diverge if needed.
11924
+ listening: "skill-map server listening on http://{{host}}:{{port}}\n",
11925
+ // UI bundle missing — non-fatal when the path was auto-resolved (the
11926
+ // server keeps running with an inline placeholder at `/`). Becomes
11927
+ // ExitCode.Error when `--ui-dist <path>` was explicit.
11928
+ uiBundleMissing: 'skill-map server: UI bundle not found at {{path}} \u2014 serving inline placeholder at "/" (run "npm run build --workspace=ui" to populate).\n',
11929
+ // Loopback-only deprecation hint — Decision #119. Logged once at boot
11930
+ // when `--host` resolves to a non-loopback address. Multi-host serve
11931
+ // re-opens post-v0.6.0.
11932
+ hostNonLoopbackHint: "skill-map server: --host {{host}} is non-loopback \u2014 through v0.6.0 the BFF assumes loopback-only (no auth). See Decision #119 in ROADMAP.\n",
11933
+ // Shutdown trace — printed by the close path so test runs that bring
11934
+ // the server up and down have a clear marker.
11935
+ closed: "skill-map server: closed.\n",
11936
+ // ---- error envelope messages (Step 14.2) ---------------------------------
11937
+ // Persisted scan absent and the route can't degrade to an empty result.
11938
+ // Hint nudges the user toward `sm scan` so the SPA can call it via the
11939
+ // CLI side-by-side with the server.
11940
+ dbMissingHint: "No persisted scan available at {{path}}. Run `sm scan` to populate the DB.",
11941
+ // `?fresh=1` was requested but the server was booted with --no-built-ins
11942
+ // or --no-plugins. A fresh scan with neither pipeline yields an empty /
11943
+ // partial result that would surprise the SPA. Reject up front.
11944
+ freshScanRequiresPipeline: "?fresh=1 cannot run while the server was started with --no-built-ins or --no-plugins (would yield empty / partial results).",
11945
+ // Unknown formatter on /api/graph — the user asked for a `format` value
11946
+ // that no registered formatter advertises. Mirrors `sm graph`'s message.
11947
+ graphUnknownFormat: 'Unknown graph format "{{format}}". Available: {{available}}.',
11948
+ // Pagination caps on /api/nodes.
11949
+ paginationLimitTooLarge: "limit={{value}} exceeds the maximum of {{max}}.",
11950
+ paginationInvalidInteger: "{{name}}={{value}} is not a non-negative integer.",
11951
+ // Node lookup miss on /api/nodes/:pathB64. Both the missing-node and
11952
+ // the malformed-pathB64 cases funnel here — the client experience is
11953
+ // the same (the resource isn't there).
11954
+ nodeNotFound: 'No node with path "{{path}}".',
11955
+ pathB64Malformed: "Malformed pathB64 \u2014 not a valid base64url-encoded node.path.",
11956
+ // ---- WS broadcaster + watcher (Step 14.4.a) ------------------------------
11957
+ // Logged once on watcher boot after chokidar's initial walk completes.
11958
+ // Marks the broadcaster as armed and the live event stream as flowing.
11959
+ watcherReady: 'skill-map server: watcher ready (roots="{{roots}}", debounceMs={{debounceMs}}).\n',
11960
+ // Watcher boot failure inside `createServer`. Non-fatal — the REST
11961
+ // surface stays alive so the operator can fix the underlying issue
11962
+ // (config, plugin, FS permission) and restart.
11963
+ watcherBootFailed: "skill-map server: watcher boot failed \u2014 {{message}}. /api/* still serving; pass --no-watcher to silence this on the next boot.\n",
11964
+ // Per-batch failure inside the watcher's scan+persist pipeline. The
11965
+ // watcher loop continues — a transient FS error must not kill the
11966
+ // broadcaster.
11967
+ watcherBatchFailed: "skill-map server: watcher batch failed \u2014 {{message}}.\n",
11968
+ // chokidar surfaced an error. The watcher stays open per IFsWatcher's
11969
+ // contract; the BFF also broadcasts a `watcher.error` advisory so the
11970
+ // SPA can surface it in the live event log.
11971
+ watcherError: "skill-map server: watcher error \u2014 {{message}}.\n",
11972
+ // chokidar.close() rejected during graceful shutdown. Logged but not
11973
+ // surfaced — close() is best-effort and idempotent.
11974
+ watcherCloseFailed: "skill-map server: watcher close failed \u2014 {{message}}.\n",
11975
+ // A connected client's outbound buffer exceeded the backpressure
11976
+ // threshold. The broadcaster closes the client with code 1009 and
11977
+ // unregisters it. Logged so operators can spot a wedged consumer.
11978
+ wsBackpressureEvicted: "skill-map server: ws client evicted (bufferedAmount={{buffered}} > threshold={{threshold}}).\n",
11979
+ // `WebSocket.send()` threw on a registered client. The client is
11980
+ // unregistered; the broadcast continues with the remaining clients.
11981
+ wsClientSendFailed: "skill-map server: ws send failed \u2014 {{message}}.\n",
11982
+ // `JSON.stringify(envelope)` threw inside `broadcast()`. The event is
11983
+ // dropped. Per spec/job-events.md §Error handling, the right shape
11984
+ // is a synthetic `emitter.error` event; v14.4.a does not yet route
11985
+ // it through the broadcaster (would re-enter the same stringify
11986
+ // path), so we degrade to a logged warning.
11987
+ wsBroadcastSerializeFailed: "skill-map server: ws broadcast dropped \u2014 failed to serialize event: {{message}}.\n"
11988
+ };
11989
+
11990
+ // server/routes/graph.ts
11991
+ var DEFAULT_FORMAT2 = "ascii";
11992
+ function registerGraphRoute(app, deps) {
11993
+ app.get("/api/graph", async (c) => {
11994
+ const format = c.req.query("format") ?? DEFAULT_FORMAT2;
11995
+ const pluginRuntime = deps.options.noPlugins ? emptyPluginRuntime() : await loadPluginRuntime({ scope: deps.options.scope });
11996
+ for (const warn of pluginRuntime.warnings) {
11997
+ process.stderr.write(`${warn}
11998
+ `);
11999
+ }
12000
+ const formatters = composeFormatters({
12001
+ noBuiltIns: deps.options.noBuiltIns,
12002
+ pluginRuntime
12003
+ });
12004
+ const formatter = formatters.find((f) => f.formatId === format);
12005
+ if (!formatter) {
12006
+ const available = formatters.map((f) => f.formatId).sort().join(", ");
12007
+ throw new HTTPException2(400, {
12008
+ message: tx(SERVER_TEXTS.graphUnknownFormat, {
12009
+ format,
12010
+ available: available || "(none)"
12011
+ })
12012
+ });
12013
+ }
12014
+ const loaded = await tryWithSqlite(
12015
+ { databasePath: deps.options.dbPath, autoBackup: false },
12016
+ (adapter) => adapter.scans.load()
12017
+ );
12018
+ const scan = loaded ?? { nodes: [], links: [], issues: [] };
12019
+ const text = formatter.format({
12020
+ nodes: scan.nodes,
12021
+ links: scan.links,
12022
+ issues: scan.issues
12023
+ });
12024
+ const body = text.endsWith("\n") ? text : text + "\n";
12025
+ return c.body(body, 200, { "content-type": contentTypeFor(format) });
12026
+ });
12027
+ }
12028
+ function contentTypeFor(format) {
12029
+ if (format === "json") return "application/json; charset=utf-8";
12030
+ if (format === "md" || format === "markdown" || format === "mermaid") {
12031
+ return "text/markdown; charset=utf-8";
12032
+ }
12033
+ return "text/plain; charset=utf-8";
12034
+ }
12035
+
11818
12036
  // server/health.ts
11819
12037
  import { existsSync as existsSync15 } from "fs";
11820
12038
  var FALLBACK_SCHEMA_VERSION = "1";
@@ -11838,9 +12056,435 @@ async function resolveSpecVersion2() {
11838
12056
  }
11839
12057
  }
11840
12058
 
12059
+ // server/routes/health.ts
12060
+ function registerHealthRoute(app, deps) {
12061
+ app.get("/api/health", (c) => {
12062
+ const payload = buildHealth({
12063
+ dbPath: deps.options.dbPath,
12064
+ scope: deps.options.scope,
12065
+ specVersion: deps.specVersion
12066
+ });
12067
+ return c.json(payload);
12068
+ });
12069
+ }
12070
+
12071
+ // server/routes/issues.ts
12072
+ function registerIssuesRoute(app, deps) {
12073
+ app.get("/api/issues", async (c) => {
12074
+ const params = new URL(c.req.url).searchParams;
12075
+ const severityFilter = parseCsv(params.get("severity"));
12076
+ const ruleFilter = parseRulesFilter(params.get("ruleId"));
12077
+ const nodePath = params.get("node");
12078
+ const loaded = await tryWithSqlite(
12079
+ { databasePath: deps.options.dbPath, autoBackup: false },
12080
+ (adapter) => adapter.issues.listAll()
12081
+ );
12082
+ const allIssues = loaded ?? [];
12083
+ const filtered = allIssues.filter((issue) => {
12084
+ if (severityFilter && !severityFilter.includes(issue.severity)) return false;
12085
+ if (ruleFilter && !matchesRuleFilter2(issue.ruleId, ruleFilter)) return false;
12086
+ if (nodePath !== null && !issue.nodeIds.includes(nodePath)) return false;
12087
+ return true;
12088
+ });
12089
+ return c.json(
12090
+ buildListEnvelope({
12091
+ kind: "issues",
12092
+ items: filtered,
12093
+ filters: {
12094
+ severity: severityFilter ?? null,
12095
+ ruleId: ruleFilter ? [...ruleFilter] : null,
12096
+ node: nodePath ?? null
12097
+ },
12098
+ total: filtered.length,
12099
+ kindRegistry: deps.kindRegistry
12100
+ })
12101
+ );
12102
+ });
12103
+ }
12104
+ function parseCsv(raw) {
12105
+ if (raw === null) return null;
12106
+ const list = raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
12107
+ return list.length > 0 ? list : null;
12108
+ }
12109
+ function parseRulesFilter(raw) {
12110
+ const list = parseCsv(raw);
12111
+ return list ? new Set(list) : null;
12112
+ }
12113
+ function matchesRuleFilter2(ruleId, filter) {
12114
+ if (filter.has(ruleId)) return true;
12115
+ const slashIdx = ruleId.indexOf("/");
12116
+ if (slashIdx >= 0) {
12117
+ const short = ruleId.slice(slashIdx + 1);
12118
+ if (filter.has(short)) return true;
12119
+ }
12120
+ return false;
12121
+ }
12122
+
12123
+ // server/routes/links.ts
12124
+ function registerLinksRoute(app, deps) {
12125
+ app.get("/api/links", async (c) => {
12126
+ const params = new URL(c.req.url).searchParams;
12127
+ const kindFilter = parseCsv2(params.get("kind"));
12128
+ const from = params.get("from");
12129
+ const to = params.get("to");
12130
+ const loaded = await tryWithSqlite(
12131
+ { databasePath: deps.options.dbPath, autoBackup: false },
12132
+ (adapter) => adapter.scans.load()
12133
+ );
12134
+ const allLinks = loaded?.links ?? [];
12135
+ const filtered = allLinks.filter((link2) => {
12136
+ if (kindFilter && !kindFilter.includes(link2.kind)) return false;
12137
+ if (from !== null && link2.source !== from) return false;
12138
+ if (to !== null && link2.target !== to) return false;
12139
+ return true;
12140
+ });
12141
+ return c.json(
12142
+ buildListEnvelope({
12143
+ kind: "links",
12144
+ items: filtered,
12145
+ filters: {
12146
+ kind: kindFilter ?? null,
12147
+ from: from ?? null,
12148
+ to: to ?? null
12149
+ },
12150
+ total: filtered.length,
12151
+ kindRegistry: deps.kindRegistry
12152
+ })
12153
+ );
12154
+ });
12155
+ }
12156
+ function parseCsv2(raw) {
12157
+ if (raw === null) return null;
12158
+ const list = raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
12159
+ return list.length > 0 ? list : null;
12160
+ }
12161
+
12162
+ // server/routes/nodes.ts
12163
+ import { HTTPException as HTTPException3 } from "hono/http-exception";
12164
+
12165
+ // server/node-body.ts
12166
+ import { readFile as readFile4 } from "fs/promises";
12167
+ import { isAbsolute as isAbsolute4, resolve as resolvePath, relative as relativePath, sep as sep4 } from "path";
12168
+ async function readNodeBody(cwd, relPath) {
12169
+ if (isAbsolute4(relPath)) return null;
12170
+ const absRoot = resolvePath(cwd);
12171
+ const absFile = resolvePath(absRoot, relPath);
12172
+ const rel = relativePath(absRoot, absFile);
12173
+ if (rel.startsWith("..") || rel.startsWith(sep4) || rel.length === 0) {
12174
+ return null;
12175
+ }
12176
+ let raw;
12177
+ try {
12178
+ raw = await readFile4(absFile, "utf-8");
12179
+ } catch (err) {
12180
+ if (isExpectedFsError(err)) return null;
12181
+ throw err;
12182
+ }
12183
+ return stripFrontmatter(raw);
12184
+ }
12185
+ function stripFrontmatter(raw) {
12186
+ if (!raw.startsWith("---")) return raw;
12187
+ const match = raw.match(/^---\r?\n[\s\S]*?^---\r?\n?/m);
12188
+ if (!match) return raw;
12189
+ return raw.slice(match[0].length);
12190
+ }
12191
+ var EXPECTED_FS_ERROR_CODES = /* @__PURE__ */ new Set(["ENOENT", "EACCES", "EISDIR", "ENOTDIR"]);
12192
+ function isExpectedFsError(err) {
12193
+ if (err === null || typeof err !== "object") return false;
12194
+ const code = err.code;
12195
+ return typeof code === "string" && EXPECTED_FS_ERROR_CODES.has(code);
12196
+ }
12197
+
12198
+ // server/path-codec.ts
12199
+ var PathCodecError = class extends Error {
12200
+ constructor(message) {
12201
+ super(message);
12202
+ this.name = "PathCodecError";
12203
+ }
12204
+ };
12205
+ function encodeNodePath(path) {
12206
+ return Buffer.from(path, "utf8").toString("base64url");
12207
+ }
12208
+ function decodeNodePath(encoded) {
12209
+ if (encoded.length === 0) {
12210
+ throw new PathCodecError("empty pathB64");
12211
+ }
12212
+ if (!/^[A-Za-z0-9_-]+$/.test(encoded)) {
12213
+ throw new PathCodecError("pathB64 contains characters outside the base64url alphabet");
12214
+ }
12215
+ const decoded = Buffer.from(encoded, "base64url").toString("utf8");
12216
+ if (encodeNodePath(decoded) !== encoded) {
12217
+ throw new PathCodecError("pathB64 did not round-trip cleanly through base64url");
12218
+ }
12219
+ return decoded;
12220
+ }
12221
+
12222
+ // server/query-adapter.ts
12223
+ function urlParamsToExportQuery(params) {
12224
+ const filters = {};
12225
+ const tokens = [];
12226
+ const kindRaw = params.get("kind");
12227
+ if (kindRaw !== null) {
12228
+ const kinds = splitCsv(kindRaw);
12229
+ if (kinds.length === 0) {
12230
+ throw new ExportQueryError("kind: empty value list");
12231
+ }
12232
+ filters.kinds = kinds;
12233
+ tokens.push(`kind=${kinds.join(",")}`);
12234
+ }
12235
+ const hasIssuesRaw = params.get("hasIssues");
12236
+ if (hasIssuesRaw !== null) {
12237
+ const lower = hasIssuesRaw.toLowerCase();
12238
+ if (lower === "true") {
12239
+ filters.hasIssues = true;
12240
+ tokens.push("has=issues");
12241
+ } else if (lower === "false") {
12242
+ filters.hasIssues = false;
12243
+ } else {
12244
+ throw new ExportQueryError(`hasIssues: expected "true" or "false", got "${hasIssuesRaw}"`);
12245
+ }
12246
+ }
12247
+ const pathRaw = params.get("path");
12248
+ if (pathRaw !== null) {
12249
+ const globs = splitCsv(pathRaw);
12250
+ if (globs.length === 0) {
12251
+ throw new ExportQueryError("path: empty value list");
12252
+ }
12253
+ filters.pathGlobs = globs;
12254
+ tokens.push(`path=${globs.join(",")}`);
12255
+ }
12256
+ const raw = tokens.join(" ");
12257
+ const query = parseExportQuery(raw);
12258
+ return { query, filters };
12259
+ }
12260
+ function filterNodesWithoutIssues(nodes, issues) {
12261
+ if (issues.length === 0) return nodes;
12262
+ const nodesWithIssues = /* @__PURE__ */ new Set();
12263
+ for (const issue of issues) {
12264
+ for (const id of issue.nodeIds) nodesWithIssues.add(id);
12265
+ }
12266
+ return nodes.filter((n) => !nodesWithIssues.has(n.path));
12267
+ }
12268
+ function splitCsv(raw) {
12269
+ return raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
12270
+ }
12271
+
12272
+ // server/routes/nodes.ts
12273
+ var DEFAULT_LIMIT = 100;
12274
+ var MAX_LIMIT = 1e3;
12275
+ function registerNodesRoutes(app, deps) {
12276
+ app.get("/api/nodes/:pathB64", async (c) => {
12277
+ const pathB64 = c.req.param("pathB64");
12278
+ let nodePath;
12279
+ try {
12280
+ nodePath = decodeNodePath(pathB64);
12281
+ } catch (err) {
12282
+ if (err instanceof PathCodecError) {
12283
+ throw new HTTPException3(404, { message: SERVER_TEXTS.pathB64Malformed });
12284
+ }
12285
+ throw err;
12286
+ }
12287
+ const bundle = await tryWithSqlite(
12288
+ { databasePath: deps.options.dbPath, autoBackup: false },
12289
+ (adapter) => adapter.scans.findNode(nodePath)
12290
+ );
12291
+ if (!bundle) {
12292
+ throw new HTTPException3(404, {
12293
+ message: tx(SERVER_TEXTS.nodeNotFound, { path: nodePath })
12294
+ });
12295
+ }
12296
+ const includes = parseIncludes(new URL(c.req.url).searchParams.get("include"));
12297
+ const item = includes.has("body") ? { ...bundle.node, body: await readNodeBody(deps.runtimeContext.cwd, nodePath) } : bundle.node;
12298
+ return c.json({
12299
+ schemaVersion: REST_ENVELOPE_SCHEMA_VERSION,
12300
+ kind: "node",
12301
+ item,
12302
+ links: { incoming: bundle.linksIn, outgoing: bundle.linksOut },
12303
+ issues: bundle.issues,
12304
+ kindRegistry: deps.kindRegistry
12305
+ });
12306
+ });
12307
+ app.get("/api/nodes", async (c) => {
12308
+ const params = new URL(c.req.url).searchParams;
12309
+ const { query, filters } = urlParamsToExportQuery(params);
12310
+ const { offset, limit } = parsePagination(params);
12311
+ const loaded = await tryWithSqlite(
12312
+ { databasePath: deps.options.dbPath, autoBackup: false },
12313
+ (adapter) => adapter.scans.load()
12314
+ );
12315
+ const scan = loaded ?? { nodes: [], links: [], issues: [] };
12316
+ const subset = applyExportQuery(scan, query);
12317
+ let nodes = subset.nodes;
12318
+ if (filters.hasIssues === false) {
12319
+ nodes = filterNodesWithoutIssues(nodes, scan.issues);
12320
+ }
12321
+ const total = nodes.length;
12322
+ const items = nodes.slice(offset, offset + limit);
12323
+ return c.json(
12324
+ buildListEnvelope({
12325
+ kind: "nodes",
12326
+ items,
12327
+ filters: {
12328
+ kind: filters.kinds ?? null,
12329
+ hasIssues: filters.hasIssues ?? null,
12330
+ path: filters.pathGlobs ?? null
12331
+ },
12332
+ total,
12333
+ page: { offset, limit },
12334
+ kindRegistry: deps.kindRegistry
12335
+ })
12336
+ );
12337
+ });
12338
+ }
12339
+ function parsePagination(params) {
12340
+ const offset = parseNonNegativeInt(params.get("offset"), "offset", 0);
12341
+ const limit = parseNonNegativeInt(params.get("limit"), "limit", DEFAULT_LIMIT);
12342
+ if (limit > MAX_LIMIT) {
12343
+ throw new HTTPException3(400, {
12344
+ message: tx(SERVER_TEXTS.paginationLimitTooLarge, { value: limit, max: MAX_LIMIT })
12345
+ });
12346
+ }
12347
+ return { offset, limit };
12348
+ }
12349
+ function parseNonNegativeInt(raw, name, fallback) {
12350
+ if (raw === null || raw.length === 0) return fallback;
12351
+ const trimmed = raw.trim();
12352
+ const parsed = Number.parseInt(trimmed, 10);
12353
+ if (!Number.isInteger(parsed) || parsed < 0 || String(parsed) !== trimmed) {
12354
+ throw new HTTPException3(400, {
12355
+ message: tx(SERVER_TEXTS.paginationInvalidInteger, { name, value: raw })
12356
+ });
12357
+ }
12358
+ return parsed;
12359
+ }
12360
+ function parseIncludes(raw) {
12361
+ if (raw === null || raw.length === 0) return /* @__PURE__ */ new Set();
12362
+ return new Set(raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0));
12363
+ }
12364
+
12365
+ // server/routes/plugins.ts
12366
+ function registerPluginsRoute(app, deps) {
12367
+ app.get("/api/plugins", async (c) => {
12368
+ const pluginRuntime = deps.options.noPlugins ? emptyPluginRuntime() : await loadPluginRuntime({ scope: deps.options.scope });
12369
+ for (const warn of pluginRuntime.warnings) {
12370
+ process.stderr.write(`${warn}
12371
+ `);
12372
+ }
12373
+ const items = [
12374
+ ...deps.options.noBuiltIns ? [] : buildBuiltInItems(pluginRuntime.resolveEnabled),
12375
+ ...buildDiscoveredItems(pluginRuntime.discovered, deps)
12376
+ ];
12377
+ return c.json(
12378
+ buildListEnvelope({
12379
+ kind: "plugins",
12380
+ items,
12381
+ filters: {},
12382
+ total: items.length,
12383
+ kindRegistry: deps.kindRegistry
12384
+ })
12385
+ );
12386
+ });
12387
+ }
12388
+ function buildBuiltInItems(resolveEnabled) {
12389
+ return builtInBundles.map((bundle) => ({
12390
+ id: bundle.id,
12391
+ version: firstVersion(bundle.extensions),
12392
+ kinds: uniqueKinds(bundle.extensions.map((e) => e.kind)),
12393
+ status: resolveEnabled(bundle.id) ? "enabled" : "disabled",
12394
+ reason: null,
12395
+ source: "built-in"
12396
+ }));
12397
+ }
12398
+ function buildDiscoveredItems(discovered, deps) {
12399
+ return discovered.map((plugin) => ({
12400
+ id: plugin.id,
12401
+ version: plugin.manifest?.version ?? null,
12402
+ kinds: uniqueKinds(plugin.extensions?.map((e) => e.kind) ?? []),
12403
+ status: plugin.status,
12404
+ reason: plugin.reason ?? null,
12405
+ source: classifyPluginSource(plugin.path, deps)
12406
+ }));
12407
+ }
12408
+ function uniqueKinds(kinds) {
12409
+ return [...new Set(kinds)].sort();
12410
+ }
12411
+ function firstVersion(extensions) {
12412
+ for (const ext of extensions) {
12413
+ if (ext.version) return ext.version;
12414
+ }
12415
+ return null;
12416
+ }
12417
+ function classifyPluginSource(pluginPath, deps) {
12418
+ const projectDir = defaultProjectPluginsDir(deps.runtimeContext);
12419
+ return pluginPath.startsWith(projectDir) ? "project" : "global";
12420
+ }
12421
+
12422
+ // server/routes/scan.ts
12423
+ import { HTTPException as HTTPException4 } from "hono/http-exception";
12424
+ function registerScanRoute(app, deps) {
12425
+ app.get("/api/scan", async (c) => {
12426
+ const fresh = c.req.query("fresh");
12427
+ if (fresh === "1" || fresh === "true") {
12428
+ return c.json(await runFreshScan(deps));
12429
+ }
12430
+ return c.json(await loadPersistedScan(deps));
12431
+ });
12432
+ }
12433
+ async function loadPersistedScan(deps) {
12434
+ const loaded = await tryWithSqlite(
12435
+ { databasePath: deps.options.dbPath, autoBackup: false },
12436
+ (adapter) => adapter.scans.load()
12437
+ );
12438
+ if (loaded !== null) return loaded;
12439
+ return emptyScanResult();
12440
+ }
12441
+ async function runFreshScan(deps) {
12442
+ if (deps.options.noBuiltIns || deps.options.noPlugins) {
12443
+ throw new HTTPException4(400, { message: SERVER_TEXTS.freshScanRequiresPipeline });
12444
+ }
12445
+ const outcome = await runScanForCommand({
12446
+ roots: [deps.runtimeContext.cwd],
12447
+ noBuiltIns: false,
12448
+ noPlugins: false,
12449
+ noTokens: false,
12450
+ dryRun: true,
12451
+ changed: false,
12452
+ allowEmpty: true,
12453
+ strict: false,
12454
+ stderr: process.stderr,
12455
+ ctx: deps.runtimeContext
12456
+ });
12457
+ if (outcome.kind !== "ok") {
12458
+ throw new HTTPException4(500, {
12459
+ message: outcome.kind === "guard-trip" ? `fresh scan refused (existing rows: ${outcome.existing})` : outcome.message
12460
+ });
12461
+ }
12462
+ return outcome.result;
12463
+ }
12464
+ function emptyScanResult() {
12465
+ return {
12466
+ schemaVersion: 1,
12467
+ scannedAt: Date.now(),
12468
+ scope: "project",
12469
+ roots: ["."],
12470
+ providers: [],
12471
+ nodes: [],
12472
+ links: [],
12473
+ issues: [],
12474
+ stats: {
12475
+ filesWalked: 0,
12476
+ filesSkipped: 0,
12477
+ nodesCount: 0,
12478
+ linksCount: 0,
12479
+ issuesCount: 0,
12480
+ durationMs: 0
12481
+ }
12482
+ };
12483
+ }
12484
+
11841
12485
  // server/static.ts
11842
12486
  import { existsSync as existsSync16 } from "fs";
11843
- import { readFile as readFile4 } from "fs/promises";
12487
+ import { readFile as readFile5 } from "fs/promises";
11844
12488
  import { extname, join as join13 } from "path";
11845
12489
  import { serveStatic } from "@hono/node-server/serve-static";
11846
12490
  var INDEX_HTML = "index.html";
@@ -11914,10 +12558,48 @@ function htmlResponse(c, html) {
11914
12558
  return c.body(html, 200, { "content-type": "text/html; charset=UTF-8" });
11915
12559
  }
11916
12560
  async function fileResponse(c, absPath) {
11917
- const buf = await readFile4(absPath);
12561
+ const buf = await readFile5(absPath);
11918
12562
  return c.body(buf, 200, { "content-type": mimeFor(absPath) });
11919
12563
  }
11920
12564
 
12565
+ // server/ws.ts
12566
+ import { upgradeWebSocket } from "@hono/node-server";
12567
+ var WS_PATH = "/ws";
12568
+ function attachBroadcasterRoute(app, broadcaster) {
12569
+ app.get(
12570
+ WS_PATH,
12571
+ upgradeWebSocket(() => ({
12572
+ onOpen(_event, ws) {
12573
+ const raw = ws.raw;
12574
+ if (!raw) {
12575
+ broadcaster.register({
12576
+ send: (data) => ws.send(data),
12577
+ close: (code, reason) => ws.close(code, reason),
12578
+ bufferedAmount: 0,
12579
+ readyState: ws.readyState
12580
+ });
12581
+ return;
12582
+ }
12583
+ broadcaster.register(raw);
12584
+ },
12585
+ onClose(_event, ws) {
12586
+ const raw = ws.raw;
12587
+ if (raw) broadcaster.unregister(raw);
12588
+ },
12589
+ onError(event, ws) {
12590
+ const raw = ws.raw;
12591
+ if (raw) broadcaster.unregister(raw);
12592
+ const message = event?.message ?? "unknown";
12593
+ log.warn(
12594
+ tx(SERVER_TEXTS.wsClientSendFailed, {
12595
+ message: sanitizeForTerminal(message)
12596
+ })
12597
+ );
12598
+ }
12599
+ }))
12600
+ );
12601
+ }
12602
+
11921
12603
  // server/app.ts
11922
12604
  function createApp(deps) {
11923
12605
  const app = new Hono();
@@ -11930,22 +12612,27 @@ function createApp(deps) {
11930
12612
  });
11931
12613
  app.options("*", (c) => c.body(null, 204));
11932
12614
  }
11933
- app.get("/api/health", (c) => {
11934
- const payload = buildHealth({
11935
- dbPath: deps.options.dbPath,
11936
- scope: deps.options.scope,
11937
- specVersion: deps.specVersion
11938
- });
11939
- return c.json(payload);
11940
- });
12615
+ registerHealthRoute(app, { options: deps.options, specVersion: deps.specVersion });
12616
+ const routeDeps = {
12617
+ options: deps.options,
12618
+ runtimeContext: deps.runtimeContext,
12619
+ kindRegistry: deps.kindRegistry
12620
+ };
12621
+ registerScanRoute(app, routeDeps);
12622
+ registerNodesRoutes(app, routeDeps);
12623
+ registerLinksRoute(app, routeDeps);
12624
+ registerIssuesRoute(app, routeDeps);
12625
+ registerGraphRoute(app, routeDeps);
12626
+ registerConfigRoute(app, routeDeps);
12627
+ registerPluginsRoute(app, routeDeps);
11941
12628
  app.all("/api/*", (c) => {
11942
- throw new HTTPException(404, { message: `Unknown API endpoint: ${c.req.path}` });
12629
+ throw new HTTPException5(404, { message: `Unknown API endpoint: ${c.req.path}` });
11943
12630
  });
11944
- deps.attachWs(app);
12631
+ attachBroadcasterRoute(app, deps.broadcaster);
11945
12632
  app.use("*", createStaticHandler(deps.options.uiDist));
11946
12633
  app.get("*", createSpaFallback(deps.options.uiDist));
11947
12634
  app.notFound((c) => {
11948
- throw new HTTPException(404, { message: `Not found: ${c.req.path}` });
12635
+ throw new HTTPException5(404, { message: `Not found: ${c.req.path}` });
11949
12636
  });
11950
12637
  app.onError((err, c) => {
11951
12638
  return formatError2(err, c);
@@ -11958,7 +12645,7 @@ function codeForStatus(status) {
11958
12645
  return "internal";
11959
12646
  }
11960
12647
  function formatError2(err, c) {
11961
- if (err instanceof HTTPException) {
12648
+ if (err instanceof HTTPException5) {
11962
12649
  const status = err.status;
11963
12650
  const envelope2 = {
11964
12651
  ok: false,
@@ -11970,6 +12657,17 @@ function formatError2(err, c) {
11970
12657
  };
11971
12658
  return c.json(envelope2, status);
11972
12659
  }
12660
+ if (err instanceof ExportQueryError) {
12661
+ const envelope2 = {
12662
+ ok: false,
12663
+ error: {
12664
+ code: "bad-query",
12665
+ message: err.message,
12666
+ details: null
12667
+ }
12668
+ };
12669
+ return c.json(envelope2, 400);
12670
+ }
11973
12671
  const envelope = {
11974
12672
  ok: false,
11975
12673
  error: {
@@ -11981,19 +12679,328 @@ function formatError2(err, c) {
11981
12679
  return c.json(envelope, 500);
11982
12680
  }
11983
12681
 
11984
- // server/ws.ts
11985
- import { upgradeWebSocket } from "@hono/node-server";
11986
- var WS_PATH = "/ws";
11987
- var NOOP_CLOSE_CODE = 1e3;
11988
- var NOOP_CLOSE_REASON = "no broadcaster yet";
11989
- function noopWebSocketRoute(app) {
11990
- app.get(
11991
- WS_PATH,
11992
- upgradeWebSocket(() => ({
11993
- onOpen(_event, ws) {
11994
- ws.close(NOOP_CLOSE_CODE, NOOP_CLOSE_REASON);
12682
+ // server/broadcaster.ts
12683
+ var MAX_BUFFERED_BYTES = 4 * 1024 * 1024;
12684
+ var CLOSE_CODE_GOING_AWAY = 1001;
12685
+ var CLOSE_CODE_MESSAGE_TOO_BIG = 1009;
12686
+ var READY_STATE_OPEN = 1;
12687
+ var WsBroadcaster = class {
12688
+ #clients = /* @__PURE__ */ new Set();
12689
+ #shutDown = false;
12690
+ /** Number of currently-registered clients. Read-only — for tests / `/api/health`. */
12691
+ get clientCount() {
12692
+ return this.#clients.size;
12693
+ }
12694
+ /**
12695
+ * Register a client. Called from the `/ws` `onOpen` handler with the
12696
+ * raw `WebSocket` instance. After shutdown the broadcaster refuses
12697
+ * new registrations and immediately closes the offered socket so a
12698
+ * late upgrade doesn't leak.
12699
+ */
12700
+ register(ws) {
12701
+ if (this.#shutDown) {
12702
+ try {
12703
+ ws.close(CLOSE_CODE_GOING_AWAY, "server shutdown");
12704
+ } catch {
11995
12705
  }
11996
- }))
12706
+ return;
12707
+ }
12708
+ this.#clients.add(ws);
12709
+ }
12710
+ /**
12711
+ * Unregister a client. Called from the `/ws` `onClose` / `onError`
12712
+ * handlers and from the backpressure path. Idempotent — calling on a
12713
+ * client that was never registered (or was already removed) is a no-op.
12714
+ */
12715
+ unregister(ws) {
12716
+ this.#clients.delete(ws);
12717
+ }
12718
+ /**
12719
+ * Serialize the envelope once and fan out to every open client.
12720
+ * Closed / closing clients are silently skipped (the `onClose` handler
12721
+ * has already removed them, but we double-check `readyState` because a
12722
+ * close in the middle of the loop is observable as a transient
12723
+ * `OPEN → CLOSING` flip).
12724
+ *
12725
+ * Per-client `send()` failures are caught: one rogue socket cannot
12726
+ * stop the rest from receiving the event. A failing socket is closed
12727
+ * + unregistered so the next broadcast doesn't waste cycles on it.
12728
+ *
12729
+ * Backpressure check (per AGENTS.md §Watcher integration): if a
12730
+ * client's `bufferedAmount` exceeds `MAX_BUFFERED_BYTES`, it's evicted
12731
+ * with close code 1009. The check runs BEFORE `send` so the threshold
12732
+ * acts as an admission gate, not a post-mortem.
12733
+ */
12734
+ broadcast(envelope) {
12735
+ if (this.#shutDown) return;
12736
+ let payload;
12737
+ try {
12738
+ payload = JSON.stringify(envelope);
12739
+ } catch (err) {
12740
+ const message = err instanceof Error ? err.message : String(err);
12741
+ log.warn(
12742
+ tx(SERVER_TEXTS.wsBroadcastSerializeFailed, {
12743
+ message: sanitizeForTerminal(message)
12744
+ })
12745
+ );
12746
+ return;
12747
+ }
12748
+ const snapshot = Array.from(this.#clients);
12749
+ for (const client of snapshot) {
12750
+ this.#deliver(client, payload);
12751
+ }
12752
+ }
12753
+ /**
12754
+ * Drain every connected socket with code 1001 ('going away') + reason
12755
+ * `'server shutdown'`. Idempotent — a second call after the first
12756
+ * `shutdown()` is a no-op. After shutdown, `register()` immediately
12757
+ * closes any new client offered.
12758
+ */
12759
+ shutdown() {
12760
+ if (this.#shutDown) return;
12761
+ this.#shutDown = true;
12762
+ const snapshot = Array.from(this.#clients);
12763
+ this.#clients.clear();
12764
+ for (const client of snapshot) {
12765
+ try {
12766
+ client.close(CLOSE_CODE_GOING_AWAY, "server shutdown");
12767
+ } catch {
12768
+ }
12769
+ }
12770
+ }
12771
+ /**
12772
+ * Per-client delivery: backpressure check, then `send()`. Eviction +
12773
+ * unregistration on either failure mode.
12774
+ */
12775
+ #deliver(client, payload) {
12776
+ if (client.readyState !== READY_STATE_OPEN) {
12777
+ this.#clients.delete(client);
12778
+ return;
12779
+ }
12780
+ if (client.bufferedAmount > MAX_BUFFERED_BYTES) {
12781
+ this.#clients.delete(client);
12782
+ try {
12783
+ client.close(CLOSE_CODE_MESSAGE_TOO_BIG, "backpressure exceeded");
12784
+ } catch {
12785
+ }
12786
+ log.warn(
12787
+ tx(SERVER_TEXTS.wsBackpressureEvicted, {
12788
+ buffered: String(client.bufferedAmount),
12789
+ threshold: String(MAX_BUFFERED_BYTES)
12790
+ })
12791
+ );
12792
+ return;
12793
+ }
12794
+ try {
12795
+ client.send(payload);
12796
+ } catch (err) {
12797
+ this.#clients.delete(client);
12798
+ try {
12799
+ client.close();
12800
+ } catch {
12801
+ }
12802
+ const message = err instanceof Error ? err.message : String(err);
12803
+ log.warn(
12804
+ tx(SERVER_TEXTS.wsClientSendFailed, {
12805
+ message: sanitizeForTerminal(message)
12806
+ })
12807
+ );
12808
+ }
12809
+ }
12810
+ };
12811
+
12812
+ // server/kind-registry.ts
12813
+ function buildKindRegistry(providers) {
12814
+ const registry = {};
12815
+ for (const provider of providers) {
12816
+ for (const [kindName, kindEntry] of Object.entries(provider.kinds)) {
12817
+ if (registry[kindName]) continue;
12818
+ const ui = kindEntry.ui;
12819
+ const entry = {
12820
+ providerId: provider.id,
12821
+ label: ui.label,
12822
+ color: ui.color
12823
+ };
12824
+ if (ui.colorDark !== void 0) entry.colorDark = ui.colorDark;
12825
+ if (ui.emoji !== void 0) entry.emoji = ui.emoji;
12826
+ if (ui.icon !== void 0) entry.icon = ui.icon;
12827
+ registry[kindName] = entry;
12828
+ }
12829
+ }
12830
+ return registry;
12831
+ }
12832
+
12833
+ // server/events.ts
12834
+ function buildWatcherStartedEvent(data) {
12835
+ return {
12836
+ type: "watcher.started",
12837
+ timestamp: Date.now(),
12838
+ jobId: null,
12839
+ data
12840
+ };
12841
+ }
12842
+ function buildWatcherErrorEvent(data) {
12843
+ return {
12844
+ type: "watcher.error",
12845
+ timestamp: Date.now(),
12846
+ jobId: null,
12847
+ data
12848
+ };
12849
+ }
12850
+
12851
+ // server/watcher.ts
12852
+ var WATCH_ROOT = ".";
12853
+ function createWatcherService(opts) {
12854
+ let chokidarHandle = null;
12855
+ let stopped = false;
12856
+ const start = async () => {
12857
+ const cfg = loadConfig({
12858
+ scope: opts.options.scope,
12859
+ cwd: opts.runtimeContext.cwd,
12860
+ homedir: opts.runtimeContext.homedir
12861
+ }).effective;
12862
+ const ignoreFileText = readIgnoreFileText(opts.runtimeContext.cwd);
12863
+ const ignoreFilterOpts = {};
12864
+ if (cfg.ignore.length > 0) ignoreFilterOpts.configIgnore = cfg.ignore;
12865
+ if (ignoreFileText !== void 0) ignoreFilterOpts.ignoreFileText = ignoreFileText;
12866
+ const ignoreFilter = buildIgnoreFilter(ignoreFilterOpts);
12867
+ const debounceMs = opts.debounceMsOverride ?? cfg.scan.watch.debounceMs;
12868
+ const pluginRuntime = opts.options.noPlugins ? emptyPluginRuntime() : await loadPluginRuntime({ scope: opts.options.scope });
12869
+ for (const warn of pluginRuntime.warnings) {
12870
+ log.warn(sanitizeForTerminal(warn));
12871
+ }
12872
+ const runOneBatch = async () => {
12873
+ const kernel = createKernel();
12874
+ registerKernelExtensions(kernel, pluginRuntime, opts.options.noBuiltIns);
12875
+ const emitter = buildBroadcasterEmitter(opts.broadcaster);
12876
+ const priorState = await loadPriorState(opts.options.dbPath);
12877
+ const composed = composeScanExtensions({
12878
+ noBuiltIns: opts.options.noBuiltIns,
12879
+ pluginRuntime
12880
+ });
12881
+ const runOptions = assembleRunOptions({
12882
+ scope: opts.options.scope,
12883
+ tokenize: cfg.scan.tokenize !== false,
12884
+ strict: cfg.scan.strict === true,
12885
+ ignoreFilter,
12886
+ emitter,
12887
+ composed,
12888
+ priorState
12889
+ });
12890
+ const ran = await runScanWithRenames(kernel, runOptions);
12891
+ await persistOutcome(opts.options.dbPath, ran);
12892
+ };
12893
+ chokidarHandle = createChokidarWatcher({
12894
+ roots: [WATCH_ROOT],
12895
+ cwd: opts.runtimeContext.cwd,
12896
+ debounceMs,
12897
+ ignoreFilter,
12898
+ onBatch: async () => {
12899
+ if (stopped) return;
12900
+ try {
12901
+ await runOneBatch();
12902
+ } catch (err) {
12903
+ const message = formatErrorMessage(err);
12904
+ log.warn(
12905
+ tx(SERVER_TEXTS.watcherBatchFailed, {
12906
+ message: sanitizeForTerminal(message)
12907
+ })
12908
+ );
12909
+ }
12910
+ },
12911
+ onError: (err) => {
12912
+ const message = err.message;
12913
+ log.warn(
12914
+ tx(SERVER_TEXTS.watcherError, {
12915
+ message: sanitizeForTerminal(message)
12916
+ })
12917
+ );
12918
+ opts.broadcaster.broadcast(buildWatcherErrorEvent({ message }));
12919
+ }
12920
+ });
12921
+ if ("ready" in chokidarHandle && chokidarHandle.ready instanceof Promise) {
12922
+ await chokidarHandle.ready;
12923
+ }
12924
+ opts.broadcaster.broadcast(
12925
+ buildWatcherStartedEvent({ roots: [WATCH_ROOT], debounceMs })
12926
+ );
12927
+ log.info(
12928
+ tx(SERVER_TEXTS.watcherReady, {
12929
+ roots: WATCH_ROOT,
12930
+ debounceMs: String(debounceMs)
12931
+ })
12932
+ );
12933
+ };
12934
+ const stop = async () => {
12935
+ if (stopped) return;
12936
+ stopped = true;
12937
+ if (chokidarHandle) {
12938
+ try {
12939
+ await chokidarHandle.close();
12940
+ } catch (err) {
12941
+ const message = err instanceof Error ? err.message : String(err);
12942
+ log.warn(
12943
+ tx(SERVER_TEXTS.watcherCloseFailed, {
12944
+ message: sanitizeForTerminal(message)
12945
+ })
12946
+ );
12947
+ }
12948
+ chokidarHandle = null;
12949
+ }
12950
+ };
12951
+ return { start, stop };
12952
+ }
12953
+ function registerKernelExtensions(kernel, pluginRuntime, noBuiltIns) {
12954
+ if (!noBuiltIns) {
12955
+ const enabledBuiltIns = filterBuiltInManifests(
12956
+ listBuiltIns(),
12957
+ pluginRuntime.resolveEnabled
12958
+ );
12959
+ for (const manifest of enabledBuiltIns) kernel.registry.register(manifest);
12960
+ }
12961
+ for (const manifest of pluginRuntime.manifests) kernel.registry.register(manifest);
12962
+ }
12963
+ function buildBroadcasterEmitter(broadcaster) {
12964
+ return {
12965
+ emit(event) {
12966
+ broadcaster.broadcast(event);
12967
+ },
12968
+ subscribe() {
12969
+ return () => {
12970
+ };
12971
+ }
12972
+ };
12973
+ }
12974
+ async function loadPriorState(dbPath) {
12975
+ return tryWithSqlite({ databasePath: dbPath, autoBackup: false }, async (reader) => {
12976
+ const loaded = await reader.scans.load();
12977
+ if (loaded.nodes.length === 0) return null;
12978
+ const extractorRuns = await reader.scans.loadExtractorRuns();
12979
+ return { snapshot: loaded, extractorRuns };
12980
+ });
12981
+ }
12982
+ function assembleRunOptions(args2) {
12983
+ const runOptions = {
12984
+ roots: [WATCH_ROOT],
12985
+ scope: args2.scope,
12986
+ tokenize: args2.tokenize,
12987
+ ignoreFilter: args2.ignoreFilter,
12988
+ strict: args2.strict,
12989
+ emitter: args2.emitter
12990
+ };
12991
+ if (args2.composed) runOptions.extensions = args2.composed;
12992
+ if (args2.priorState) {
12993
+ runOptions.priorSnapshot = args2.priorState.snapshot;
12994
+ runOptions.enableCache = true;
12995
+ runOptions.priorExtractorRuns = args2.priorState.extractorRuns;
12996
+ }
12997
+ return runOptions;
12998
+ }
12999
+ async function persistOutcome(dbPath, ran) {
13000
+ const { result, renameOps, extractorRuns, enrichments } = ran;
13001
+ await withSqlite(
13002
+ { databasePath: dbPath },
13003
+ (writer) => writer.scans.persist(result, { renameOps, extractorRuns, enrichments })
11997
13004
  );
11998
13005
  }
11999
13006
 
@@ -12013,20 +13020,26 @@ function validateServerOptions(input) {
12013
13020
  if (scopeError !== null) return { ok: false, error: scopeError };
12014
13021
  const hostError = validateHost(filled.host, filled.devCors);
12015
13022
  if (hostError !== null) return { ok: false, error: hostError };
12016
- return {
12017
- ok: true,
12018
- options: {
12019
- port: filled.port,
12020
- host: filled.host,
12021
- scope: filled.scope,
12022
- dbPath: input.dbPath,
12023
- uiDist: filled.uiDist,
12024
- noBuiltIns: filled.noBuiltIns,
12025
- noPlugins: filled.noPlugins,
12026
- open: filled.open,
12027
- devCors: filled.devCors
12028
- }
13023
+ const watcherError = validateWatcher(filled.noWatcher, filled.noBuiltIns, filled.noPlugins);
13024
+ if (watcherError !== null) return { ok: false, error: watcherError };
13025
+ const debounceError = validateWatcherDebounce(input.watcherDebounceMs);
13026
+ if (debounceError !== null) return { ok: false, error: debounceError };
13027
+ const options = {
13028
+ port: filled.port,
13029
+ host: filled.host,
13030
+ scope: filled.scope,
13031
+ dbPath: input.dbPath,
13032
+ uiDist: filled.uiDist,
13033
+ noBuiltIns: filled.noBuiltIns,
13034
+ noPlugins: filled.noPlugins,
13035
+ open: filled.open,
13036
+ devCors: filled.devCors,
13037
+ noWatcher: filled.noWatcher
12029
13038
  };
13039
+ if (input.watcherDebounceMs !== void 0) {
13040
+ options.watcherDebounceMs = input.watcherDebounceMs;
13041
+ }
13042
+ return { ok: true, options };
12030
13043
  }
12031
13044
  function applyDefaults(input) {
12032
13045
  return {
@@ -12037,7 +13050,8 @@ function applyDefaults(input) {
12037
13050
  noBuiltIns: input.noBuiltIns ?? false,
12038
13051
  noPlugins: input.noPlugins ?? false,
12039
13052
  open: input.open ?? true,
12040
- devCors: input.devCors ?? false
13053
+ devCors: input.devCors ?? false,
13054
+ noWatcher: input.noWatcher ?? false
12041
13055
  };
12042
13056
  }
12043
13057
  function validatePort(port) {
@@ -12069,10 +13083,32 @@ function validateHost(host, devCors) {
12069
13083
  }
12070
13084
  return null;
12071
13085
  }
13086
+ function validateWatcher(noWatcher, noBuiltIns, _noPlugins) {
13087
+ if (noWatcher) return null;
13088
+ if (noBuiltIns) {
13089
+ return {
13090
+ code: "watcher-requires-pipeline",
13091
+ message: "the watcher cannot run with --no-built-ins (would persist empty scans on every batch). Pass --no-watcher to opt out, or drop --no-built-ins.",
13092
+ value: "no-built-ins"
13093
+ };
13094
+ }
13095
+ return null;
13096
+ }
13097
+ function validateWatcherDebounce(value) {
13098
+ if (value === void 0) return null;
13099
+ if (!Number.isInteger(value) || value < 0) {
13100
+ return {
13101
+ code: "watcher-debounce-invalid",
13102
+ message: `--watcher-debounce-ms must be a non-negative integer (got ${value})`,
13103
+ value: String(value)
13104
+ };
13105
+ }
13106
+ return null;
13107
+ }
12072
13108
 
12073
13109
  // server/paths.ts
12074
13110
  import { existsSync as existsSync17, statSync as statSync5 } from "fs";
12075
- import { dirname as dirname9, isAbsolute as isAbsolute4, join as join14, resolve as resolve19 } from "path";
13111
+ import { dirname as dirname9, isAbsolute as isAbsolute5, join as join14, resolve as resolve19 } from "path";
12076
13112
  var DEFAULT_UI_REL = join14("ui", "dist", "browser");
12077
13113
  var INDEX_HTML2 = "index.html";
12078
13114
  function resolveDefaultUiDist(ctx) {
@@ -12087,7 +13123,7 @@ function resolveDefaultUiDist(ctx) {
12087
13123
  return null;
12088
13124
  }
12089
13125
  function resolveExplicitUiDist(ctx, raw) {
12090
- return isAbsolute4(raw) ? raw : resolve19(ctx.cwd, raw);
13126
+ return isAbsolute5(raw) ? raw : resolve19(ctx.cwd, raw);
12091
13127
  }
12092
13128
  function isUiBundleDir(path) {
12093
13129
  if (!existsSync17(path)) return false;
@@ -12100,21 +13136,74 @@ function isUiBundleDir(path) {
12100
13136
  }
12101
13137
 
12102
13138
  // server/index.ts
12103
- async function createServer(options) {
13139
+ async function createServer(options, extra = {}) {
12104
13140
  const specVersion = await resolveSpecVersion2();
12105
- const app = createApp({ options, specVersion, attachWs: noopWebSocketRoute });
13141
+ const runtimeContext = extra.runtimeContext ?? defaultRuntimeContext();
13142
+ const broadcaster = new WsBroadcaster();
13143
+ const kindRegistry = await assembleKindRegistry(options);
13144
+ const app = createApp({
13145
+ options,
13146
+ specVersion,
13147
+ broadcaster,
13148
+ runtimeContext,
13149
+ kindRegistry
13150
+ });
12106
13151
  const wss = new WebSocketServer({ noServer: true });
12107
13152
  const server = await listenAsync(app.fetch, wss, options.host, options.port);
12108
13153
  const addr = server.address();
12109
13154
  const address = normalizeAddress(addr, options.host, options.port);
13155
+ let watcherService = null;
13156
+ if (!options.noWatcher) {
13157
+ const debounce = options.watcherDebounceMs;
13158
+ const svcOpts = {
13159
+ options,
13160
+ runtimeContext,
13161
+ broadcaster
13162
+ };
13163
+ if (debounce !== void 0) svcOpts.debounceMsOverride = debounce;
13164
+ const candidate = createWatcherService(svcOpts);
13165
+ try {
13166
+ await candidate.start();
13167
+ watcherService = candidate;
13168
+ } catch (err) {
13169
+ const message = err instanceof Error ? err.message : String(err);
13170
+ log.warn(
13171
+ tx(SERVER_TEXTS.watcherBootFailed, {
13172
+ message: sanitizeForTerminal(message)
13173
+ })
13174
+ );
13175
+ try {
13176
+ await candidate.stop();
13177
+ } catch {
13178
+ }
13179
+ }
13180
+ }
12110
13181
  let closed = false;
12111
13182
  const close = async () => {
12112
13183
  if (closed) return;
12113
13184
  closed = true;
13185
+ if (watcherService) {
13186
+ try {
13187
+ await watcherService.stop();
13188
+ } catch {
13189
+ }
13190
+ }
13191
+ broadcaster.shutdown();
12114
13192
  await closeServer(server);
12115
13193
  wss.close();
12116
13194
  };
12117
- return { address, close };
13195
+ return { address, close, broadcaster };
13196
+ }
13197
+ async function assembleKindRegistry(options) {
13198
+ const pluginRuntime = options.noPlugins ? emptyPluginRuntime() : await loadPluginRuntime({ scope: options.scope });
13199
+ for (const warn of pluginRuntime.warnings) {
13200
+ log.warn(sanitizeForTerminal(warn));
13201
+ }
13202
+ const composed = composeScanExtensions({
13203
+ noBuiltIns: options.noBuiltIns,
13204
+ pluginRuntime
13205
+ });
13206
+ return buildKindRegistry(composed?.providers ?? []);
12118
13207
  }
12119
13208
  function listenAsync(fetchCallback, wss, host, port) {
12120
13209
  return new Promise((resolveListen, rejectListen) => {
@@ -12183,6 +13272,9 @@ var SERVE_TEXTS = {
12183
13272
  portOutOfRange: "sm serve: --port must be an integer in [0, 65535] (got {{value}}).\n",
12184
13273
  portInvalid: "sm serve: --port must be a non-negative integer (got {{value}}).\n",
12185
13274
  scopeInvalid: 'sm serve: --scope must be "project" or "global" (got {{value}}).\n',
13275
+ // Watcher option failures — ExitCode.Error.
13276
+ watcherRequiresPipeline: "sm serve: --no-built-ins is incompatible with the watcher (would persist empty scans on every batch). Pass --no-watcher to opt out, or drop --no-built-ins.\n",
13277
+ watcherDebounceInvalid: "sm serve: --watcher-debounce-ms must be a non-negative integer (got {{value}}).\n",
12186
13278
  // Generic operational error — surfaced when the server itself throws
12187
13279
  // before the listener binds (e.g. UI bundle missing under explicit
12188
13280
  // --ui-dist).
@@ -12257,6 +13349,13 @@ var ServeCommand = class extends SmCommand {
12257
13349
  // need it). Clipanion still exposes it on the parser; the Usage
12258
13350
  // omission is the "hidden" contract per the 14.1 brief.
12259
13351
  uiDist = Option19.String("--ui-dist", { required: false, hidden: true });
13352
+ noWatcher = Option19.Boolean("--no-watcher", false, {
13353
+ description: "Disable the chokidar-fed scan-and-broadcast loop. Use only for CI / read-only deployments."
13354
+ });
13355
+ // `--watcher-debounce-ms` is undocumented sugar for advanced users
13356
+ // who want to tighten / relax the watcher's batching window without
13357
+ // editing settings.json. Hidden flag — the Usage block omits it.
13358
+ watcherDebounceMs = Option19.String("--watcher-debounce-ms", { required: false, hidden: true });
12260
13359
  // Long-running daemon — `done in <…>` after a graceful shutdown is
12261
13360
  // noise. Mirrors `sm watch`'s opt-out.
12262
13361
  emitElapsed = false;
@@ -12297,6 +13396,15 @@ var ServeCommand = class extends SmCommand {
12297
13396
  );
12298
13397
  return ExitCode.Error;
12299
13398
  }
13399
+ const debounceResult = parseDebounce(this.watcherDebounceMs);
13400
+ if (!debounceResult.ok) {
13401
+ this.context.stderr.write(
13402
+ tx(SERVE_TEXTS.watcherDebounceInvalid, {
13403
+ value: sanitizeForTerminal(debounceResult.value)
13404
+ })
13405
+ );
13406
+ return ExitCode.Error;
13407
+ }
12300
13408
  const input = {
12301
13409
  dbPath,
12302
13410
  scope,
@@ -12304,10 +13412,12 @@ var ServeCommand = class extends SmCommand {
12304
13412
  noBuiltIns: this.noBuiltIns,
12305
13413
  noPlugins: this.noPlugins,
12306
13414
  open: this.open,
12307
- devCors: this.devCors
13415
+ devCors: this.devCors,
13416
+ noWatcher: this.noWatcher
12308
13417
  };
12309
13418
  if (portResult.port !== void 0) input.port = portResult.port;
12310
13419
  if (this.host !== void 0) input.host = this.host;
13420
+ if (debounceResult.value !== void 0) input.watcherDebounceMs = debounceResult.value;
12311
13421
  const validation = validateServerOptions(input);
12312
13422
  if (!validation.ok) {
12313
13423
  this.context.stderr.write(formatValidationError(validation.error));
@@ -12360,6 +13470,15 @@ function parsePort(raw) {
12360
13470
  }
12361
13471
  return { ok: true, port: parsed };
12362
13472
  }
13473
+ function parseDebounce(raw) {
13474
+ if (raw === void 0) return { ok: true, value: void 0 };
13475
+ const trimmed = raw.trim();
13476
+ const parsed = Number.parseInt(trimmed, 10);
13477
+ if (!Number.isInteger(parsed) || parsed < 0 || String(parsed) !== trimmed) {
13478
+ return { ok: false, value: raw };
13479
+ }
13480
+ return { ok: true, value: parsed };
13481
+ }
12363
13482
  function resolveScope(rawScope, global) {
12364
13483
  if (rawScope === void 0) return { ok: true, scope: global ? "global" : "project" };
12365
13484
  if (rawScope === "project" || rawScope === "global") {
@@ -12390,6 +13509,10 @@ function formatValidationError(err) {
12390
13509
  return tx(SERVE_TEXTS.portInvalid, { value: sanitizeForTerminal(err.value) });
12391
13510
  case "scope-invalid":
12392
13511
  return tx(SERVE_TEXTS.scopeInvalid, { value: sanitizeForTerminal(err.value) });
13512
+ case "watcher-requires-pipeline":
13513
+ return tx(SERVE_TEXTS.watcherRequiresPipeline, { value: sanitizeForTerminal(err.value) });
13514
+ case "watcher-debounce-invalid":
13515
+ return tx(SERVE_TEXTS.watcherDebounceInvalid, { value: sanitizeForTerminal(err.value) });
12393
13516
  default:
12394
13517
  return tx(SERVE_TEXTS.startupFailed, { message: sanitizeForTerminal(err.message) });
12395
13518
  }