@git-stunts/git-warp 10.8.0 → 11.2.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.
Files changed (70) hide show
  1. package/README.md +53 -32
  2. package/SECURITY.md +64 -0
  3. package/bin/cli/commands/check.js +168 -0
  4. package/bin/cli/commands/doctor/checks.js +422 -0
  5. package/bin/cli/commands/doctor/codes.js +46 -0
  6. package/bin/cli/commands/doctor/index.js +239 -0
  7. package/bin/cli/commands/doctor/types.js +89 -0
  8. package/bin/cli/commands/history.js +73 -0
  9. package/bin/cli/commands/info.js +139 -0
  10. package/bin/cli/commands/install-hooks.js +128 -0
  11. package/bin/cli/commands/materialize.js +99 -0
  12. package/bin/cli/commands/path.js +88 -0
  13. package/bin/cli/commands/query.js +194 -0
  14. package/bin/cli/commands/registry.js +28 -0
  15. package/bin/cli/commands/seek.js +592 -0
  16. package/bin/cli/commands/trust.js +154 -0
  17. package/bin/cli/commands/verify-audit.js +113 -0
  18. package/bin/cli/commands/view.js +45 -0
  19. package/bin/cli/infrastructure.js +336 -0
  20. package/bin/cli/schemas.js +177 -0
  21. package/bin/cli/shared.js +244 -0
  22. package/bin/cli/types.js +85 -0
  23. package/bin/presenters/index.js +6 -0
  24. package/bin/presenters/text.js +136 -0
  25. package/bin/warp-graph.js +5 -2346
  26. package/index.d.ts +32 -2
  27. package/index.js +2 -0
  28. package/package.json +8 -7
  29. package/src/domain/WarpGraph.js +106 -3252
  30. package/src/domain/errors/QueryError.js +2 -2
  31. package/src/domain/errors/TrustError.js +29 -0
  32. package/src/domain/errors/index.js +1 -0
  33. package/src/domain/services/AuditMessageCodec.js +137 -0
  34. package/src/domain/services/AuditReceiptService.js +471 -0
  35. package/src/domain/services/AuditVerifierService.js +693 -0
  36. package/src/domain/services/HttpSyncServer.js +36 -22
  37. package/src/domain/services/MessageCodecInternal.js +3 -0
  38. package/src/domain/services/MessageSchemaDetector.js +2 -2
  39. package/src/domain/services/SyncAuthService.js +69 -3
  40. package/src/domain/services/WarpMessageCodec.js +4 -1
  41. package/src/domain/trust/TrustCanonical.js +42 -0
  42. package/src/domain/trust/TrustCrypto.js +111 -0
  43. package/src/domain/trust/TrustEvaluator.js +180 -0
  44. package/src/domain/trust/TrustRecordService.js +274 -0
  45. package/src/domain/trust/TrustStateBuilder.js +209 -0
  46. package/src/domain/trust/canonical.js +68 -0
  47. package/src/domain/trust/reasonCodes.js +64 -0
  48. package/src/domain/trust/schemas.js +160 -0
  49. package/src/domain/trust/verdict.js +42 -0
  50. package/src/domain/types/git-cas.d.ts +20 -0
  51. package/src/domain/utils/RefLayout.js +59 -0
  52. package/src/domain/warp/PatchSession.js +18 -0
  53. package/src/domain/warp/Writer.js +18 -3
  54. package/src/domain/warp/_internal.js +26 -0
  55. package/src/domain/warp/_wire.js +58 -0
  56. package/src/domain/warp/_wiredMethods.d.ts +100 -0
  57. package/src/domain/warp/checkpoint.methods.js +397 -0
  58. package/src/domain/warp/fork.methods.js +323 -0
  59. package/src/domain/warp/materialize.methods.js +188 -0
  60. package/src/domain/warp/materializeAdvanced.methods.js +339 -0
  61. package/src/domain/warp/patch.methods.js +529 -0
  62. package/src/domain/warp/provenance.methods.js +284 -0
  63. package/src/domain/warp/query.methods.js +279 -0
  64. package/src/domain/warp/subscribe.methods.js +272 -0
  65. package/src/domain/warp/sync.methods.js +549 -0
  66. package/src/infrastructure/adapters/GitGraphAdapter.js +67 -1
  67. package/src/infrastructure/adapters/InMemoryGraphAdapter.js +36 -0
  68. package/src/ports/CommitPort.js +10 -0
  69. package/src/ports/RefPort.js +17 -0
  70. package/src/hooks/post-merge.sh +0 -60
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Type definitions for the `doctor` command.
3
+ *
4
+ * @module cli/commands/doctor/types
5
+ */
6
+
7
+ // ── JSON-safe recursive value type ──────────────────────────────────────────
8
+
9
+ /** @typedef {null | boolean | number | string | Array<*> | {[k:string]: *}} JsonValue */ // TODO(ts-cleanup): recursive type
10
+
11
+ /** @typedef {{[k:string]: JsonValue}} FindingEvidence */
12
+
13
+ // ── Finding ─────────────────────────────────────────────────────────────────
14
+
15
+ /**
16
+ * @typedef {Object} DoctorFinding
17
+ * @property {string} id - Check identifier (e.g. 'repo-accessible')
18
+ * @property {'ok'|'warn'|'fail'} status
19
+ * @property {string} code - Machine-readable code from CODES registry
20
+ * @property {'data_integrity'|'security'|'operability'|'hygiene'} impact
21
+ * @property {string} message - Human-readable summary
22
+ * @property {string} [fix] - Suggested remediation command or instruction
23
+ * @property {string} [helpUrl] - Stable documentation anchor
24
+ * @property {FindingEvidence} [evidence] - JSON-safe supporting data
25
+ * @property {number} [durationMs] - Time spent on this check
26
+ */
27
+
28
+ // ── Policy ──────────────────────────────────────────────────────────────────
29
+
30
+ /**
31
+ * @typedef {Object} DoctorPolicy
32
+ * @property {boolean} strict
33
+ * @property {number} clockSkewMs
34
+ * @property {number} checkpointMaxAgeHours
35
+ * @property {number} globalDeadlineMs
36
+ * @property {{[checkId:string]: number}} checkTimeouts
37
+ */
38
+
39
+ // ── Payload ─────────────────────────────────────────────────────────────────
40
+
41
+ /**
42
+ * @typedef {Object} DoctorPayload
43
+ * @property {1} doctorVersion
44
+ * @property {string} repo
45
+ * @property {string} graph
46
+ * @property {string} checkedAt - ISO 8601 timestamp
47
+ * @property {'ok'|'degraded'|'failed'} health
48
+ * @property {DoctorPolicy} policy
49
+ * @property {DoctorSummary} summary
50
+ * @property {DoctorFinding[]} findings
51
+ * @property {number} durationMs
52
+ */
53
+
54
+ /**
55
+ * @typedef {Object} DoctorSummary
56
+ * @property {number} checksRun
57
+ * @property {number} findingsTotal
58
+ * @property {number} ok
59
+ * @property {number} warn
60
+ * @property {number} fail
61
+ * @property {string[]} priorityActions
62
+ */
63
+
64
+ // ── Context passed to each check ────────────────────────────────────────────
65
+
66
+ /**
67
+ * @typedef {Object} DoctorContext
68
+ * @property {import('../../types.js').Persistence} persistence
69
+ * @property {string} graphName
70
+ * @property {Array<{writerId: string, sha: string|null, ref: string}>} writerHeads
71
+ * @property {DoctorPolicy} policy
72
+ * @property {string} repoPath
73
+ */
74
+
75
+ /**
76
+ * @callback DoctorCheck
77
+ * @param {DoctorContext} ctx
78
+ * @returns {Promise<DoctorFinding|DoctorFinding[]|null>}
79
+ */
80
+
81
+ // ── Exit codes ──────────────────────────────────────────────────────────────
82
+
83
+ export const DOCTOR_EXIT_CODES = {
84
+ OK: 0,
85
+ FINDINGS: 3,
86
+ STRICT_FINDINGS: 4,
87
+ };
88
+
89
+ export {};
@@ -0,0 +1,73 @@
1
+ import { summarizeOps } from '../../../src/visualization/renderers/ascii/history.js';
2
+ import { EXIT_CODES, notFoundError, parseCommandArgs } from '../infrastructure.js';
3
+ import { historySchema } from '../schemas.js';
4
+ import { openGraph, applyCursorCeiling, emitCursorWarning } from '../shared.js';
5
+
6
+ /** @typedef {import('../types.js').CliOptions} CliOptions */
7
+
8
+ const HISTORY_OPTIONS = {
9
+ node: { type: 'string' },
10
+ };
11
+
12
+ /** @param {string[]} args */
13
+ function parseHistoryArgs(args) {
14
+ const { values } = parseCommandArgs(args, HISTORY_OPTIONS, historySchema);
15
+ return { node: values.node ?? null };
16
+ }
17
+
18
+ /**
19
+ * @param {*} patch
20
+ * @param {string} nodeId
21
+ */
22
+ function patchTouchesNode(patch, nodeId) {
23
+ const ops = Array.isArray(patch?.ops) ? patch.ops : [];
24
+ for (const op of ops) {
25
+ if (op.node === nodeId) {
26
+ return true;
27
+ }
28
+ if (op.from === nodeId || op.to === nodeId) {
29
+ return true;
30
+ }
31
+ }
32
+ return false;
33
+ }
34
+
35
+ /**
36
+ * Handles the `history` command: shows patch history for a writer.
37
+ * @param {{options: CliOptions, args: string[]}} params
38
+ * @returns {Promise<{payload: *, exitCode: number}>}
39
+ */
40
+ export default async function handleHistory({ options, args }) {
41
+ const historyOptions = parseHistoryArgs(args);
42
+ const { graph, graphName, persistence } = await openGraph(options);
43
+ const cursorInfo = await applyCursorCeiling(graph, persistence, graphName);
44
+ emitCursorWarning(cursorInfo, null);
45
+
46
+ const writerId = options.writer;
47
+ let patches = await graph.getWriterPatches(writerId);
48
+ if (cursorInfo.active) {
49
+ patches = patches.filter((/** @type {*} */ { patch }) => patch.lamport <= /** @type {number} */ (cursorInfo.tick)); // TODO(ts-cleanup): type CLI payload
50
+ }
51
+ if (patches.length === 0) {
52
+ throw notFoundError(`No patches found for writer: ${writerId}`);
53
+ }
54
+
55
+ const entries = patches
56
+ .filter((/** @type {*} */ { patch }) => !historyOptions.node || patchTouchesNode(patch, historyOptions.node)) // TODO(ts-cleanup): type CLI payload
57
+ .map((/** @type {*} */ { patch, sha }) => ({ // TODO(ts-cleanup): type CLI payload
58
+ sha,
59
+ schema: patch.schema,
60
+ lamport: patch.lamport,
61
+ opCount: Array.isArray(patch.ops) ? patch.ops.length : 0,
62
+ opSummary: Array.isArray(patch.ops) ? summarizeOps(patch.ops) : undefined,
63
+ }));
64
+
65
+ const payload = {
66
+ graph: graphName,
67
+ writer: writerId,
68
+ nodeFilter: historyOptions.node,
69
+ entries,
70
+ };
71
+
72
+ return { payload, exitCode: EXIT_CODES.OK };
73
+ }
@@ -0,0 +1,139 @@
1
+ import WebCryptoAdapter from '../../../src/infrastructure/adapters/WebCryptoAdapter.js';
2
+ import WarpGraph from '../../../src/domain/WarpGraph.js';
3
+ import {
4
+ buildCheckpointRef,
5
+ buildCoverageRef,
6
+ buildWritersPrefix,
7
+ parseWriterIdFromRef,
8
+ } from '../../../src/domain/utils/RefLayout.js';
9
+ import { notFoundError } from '../infrastructure.js';
10
+ import { createPersistence, listGraphNames, readActiveCursor, readCheckpointDate } from '../shared.js';
11
+
12
+ /** @typedef {import('../types.js').CliOptions} CliOptions */
13
+ /** @typedef {import('../types.js').Persistence} Persistence */
14
+ /** @typedef {import('../types.js').GraphInfoResult} GraphInfoResult */
15
+
16
+ /**
17
+ * Collects metadata about a single graph (writer count, refs, patches, checkpoint).
18
+ * @param {Persistence} persistence
19
+ * @param {string} graphName
20
+ * @param {Object} [options]
21
+ * @param {boolean} [options.includeWriterIds=false]
22
+ * @param {boolean} [options.includeRefs=false]
23
+ * @param {boolean} [options.includeWriterPatches=false]
24
+ * @param {boolean} [options.includeCheckpointDate=false]
25
+ * @returns {Promise<GraphInfoResult>}
26
+ */
27
+ async function getGraphInfo(persistence, graphName, {
28
+ includeWriterIds = false,
29
+ includeRefs = false,
30
+ includeWriterPatches = false,
31
+ includeCheckpointDate = false,
32
+ } = {}) {
33
+ const writersPrefix = buildWritersPrefix(graphName);
34
+ const writerRefs = typeof persistence.listRefs === 'function'
35
+ ? await persistence.listRefs(writersPrefix)
36
+ : [];
37
+ const writerIds = /** @type {string[]} */ (writerRefs
38
+ .map((ref) => parseWriterIdFromRef(ref))
39
+ .filter(Boolean)
40
+ .sort());
41
+
42
+ /** @type {GraphInfoResult} */
43
+ const info = {
44
+ name: graphName,
45
+ writers: {
46
+ count: writerIds.length,
47
+ },
48
+ };
49
+
50
+ if (includeWriterIds) {
51
+ info.writers.ids = writerIds;
52
+ }
53
+
54
+ if (includeRefs || includeCheckpointDate) {
55
+ const checkpointRef = buildCheckpointRef(graphName);
56
+ const checkpointSha = await persistence.readRef(checkpointRef);
57
+
58
+ /** @type {{ref: string, sha: string|null, date?: string|null}} */
59
+ const checkpoint = { ref: checkpointRef, sha: checkpointSha || null };
60
+
61
+ if (includeCheckpointDate && checkpointSha) {
62
+ const checkpointDate = await readCheckpointDate(persistence, checkpointSha);
63
+ checkpoint.date = checkpointDate;
64
+ }
65
+
66
+ info.checkpoint = checkpoint;
67
+
68
+ if (includeRefs) {
69
+ const coverageRef = buildCoverageRef(graphName);
70
+ const coverageSha = await persistence.readRef(coverageRef);
71
+ info.coverage = { ref: coverageRef, sha: coverageSha || null };
72
+ }
73
+ }
74
+
75
+ if (includeWriterPatches && writerIds.length > 0) {
76
+ const graph = await WarpGraph.open({
77
+ persistence,
78
+ graphName,
79
+ writerId: 'cli',
80
+ crypto: new WebCryptoAdapter(),
81
+ });
82
+ /** @type {Record<string, number>} */
83
+ const writerPatches = {};
84
+ for (const writerId of writerIds) {
85
+ const patches = await graph.getWriterPatches(writerId);
86
+ writerPatches[/** @type {string} */ (writerId)] = patches.length;
87
+ }
88
+ info.writerPatches = writerPatches;
89
+ }
90
+
91
+ return info;
92
+ }
93
+
94
+ /**
95
+ * Handles the `info` command: summarizes graphs in the repository.
96
+ * @param {{options: CliOptions}} params
97
+ * @returns {Promise<{repo: string, graphs: GraphInfoResult[]}>}
98
+ */
99
+ export default async function handleInfo({ options }) {
100
+ const { persistence } = await createPersistence(options.repo);
101
+ const graphNames = await listGraphNames(persistence);
102
+
103
+ if (options.graph && !graphNames.includes(options.graph)) {
104
+ throw notFoundError(`Graph not found: ${options.graph}`);
105
+ }
106
+
107
+ const detailGraphs = new Set();
108
+ if (options.graph) {
109
+ detailGraphs.add(options.graph);
110
+ } else if (graphNames.length === 1) {
111
+ detailGraphs.add(graphNames[0]);
112
+ }
113
+
114
+ // In view mode, include extra data for visualization
115
+ const isViewMode = Boolean(options.view);
116
+
117
+ const graphs = [];
118
+ for (const name of graphNames) {
119
+ const includeDetails = detailGraphs.has(name);
120
+ const info = await getGraphInfo(persistence, name, {
121
+ includeWriterIds: includeDetails || isViewMode,
122
+ includeRefs: includeDetails || isViewMode,
123
+ includeWriterPatches: isViewMode,
124
+ includeCheckpointDate: isViewMode,
125
+ });
126
+ const activeCursor = await readActiveCursor(persistence, name);
127
+ if (activeCursor) {
128
+ info.cursor = { active: true, tick: activeCursor.tick, mode: activeCursor.mode };
129
+ } else {
130
+ info.cursor = { active: false };
131
+ }
132
+ graphs.push(info);
133
+ }
134
+
135
+ return {
136
+ repo: options.repo,
137
+ graphs,
138
+ };
139
+ }
@@ -0,0 +1,128 @@
1
+ import fs from 'node:fs';
2
+ import process from 'node:process';
3
+ import { classifyExistingHook } from '../../../src/domain/services/HookInstaller.js';
4
+ import { EXIT_CODES, usageError, parseCommandArgs } from '../infrastructure.js';
5
+ import { installHooksSchema } from '../schemas.js';
6
+ import { createHookInstaller, isInteractive, promptUser } from '../shared.js';
7
+
8
+ /** @typedef {import('../types.js').CliOptions} CliOptions */
9
+
10
+ const INSTALL_HOOKS_OPTIONS = {
11
+ force: { type: 'boolean', default: false },
12
+ };
13
+
14
+ /** @param {string[]} args */
15
+ function parseInstallHooksArgs(args) {
16
+ const { values } = parseCommandArgs(args, INSTALL_HOOKS_OPTIONS, installHooksSchema);
17
+ return values;
18
+ }
19
+
20
+ /**
21
+ * @param {*} classification
22
+ * @param {{force: boolean}} hookOptions
23
+ */
24
+ async function resolveStrategy(classification, hookOptions) {
25
+ if (hookOptions.force) {
26
+ return 'replace';
27
+ }
28
+
29
+ if (classification.kind === 'none') {
30
+ return 'install';
31
+ }
32
+
33
+ if (classification.kind === 'ours') {
34
+ return await promptForOursStrategy(classification);
35
+ }
36
+
37
+ return await promptForForeignStrategy();
38
+ }
39
+
40
+ /** @param {*} classification */
41
+ async function promptForOursStrategy(classification) {
42
+ const installer = createHookInstaller();
43
+ if (classification.version === installer._version) {
44
+ return 'up-to-date';
45
+ }
46
+
47
+ if (!isInteractive()) {
48
+ throw usageError('Existing hook found. Use --force or run interactively.');
49
+ }
50
+
51
+ const answer = await promptUser(
52
+ `Upgrade hook from v${classification.version} to v${installer._version}? [Y/n] `,
53
+ );
54
+ if (answer === '' || answer.toLowerCase() === 'y') {
55
+ return 'upgrade';
56
+ }
57
+ return 'skip';
58
+ }
59
+
60
+ async function promptForForeignStrategy() {
61
+ if (!isInteractive()) {
62
+ throw usageError('Existing hook found. Use --force or run interactively.');
63
+ }
64
+
65
+ process.stderr.write('Existing post-merge hook found.\n');
66
+ process.stderr.write(' 1) Append (keep existing hook, add warp section)\n');
67
+ process.stderr.write(' 2) Replace (back up existing, install fresh)\n');
68
+ process.stderr.write(' 3) Skip\n');
69
+ const answer = await promptUser('Choose [1-3]: ');
70
+
71
+ if (answer === '1') {
72
+ return 'append';
73
+ }
74
+ if (answer === '2') {
75
+ return 'replace';
76
+ }
77
+ return 'skip';
78
+ }
79
+
80
+ /** @param {string} hookPath */
81
+ function readHookContent(hookPath) {
82
+ try {
83
+ return fs.readFileSync(hookPath, 'utf8');
84
+ } catch (/** @type {*} */ err) { // TODO(ts-cleanup): type fs error
85
+ if (err.code === 'ENOENT') {
86
+ return null;
87
+ }
88
+ throw err;
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Handles the `install-hooks` command.
94
+ * @param {{options: CliOptions, args: string[]}} params
95
+ * @returns {Promise<{payload: *, exitCode: number}>}
96
+ */
97
+ export default async function handleInstallHooks({ options, args }) {
98
+ const hookOptions = parseInstallHooksArgs(args);
99
+ const installer = createHookInstaller();
100
+ const status = installer.getHookStatus(options.repo);
101
+ const content = readHookContent(status.hookPath);
102
+ const classification = classifyExistingHook(content);
103
+ const strategy = await resolveStrategy(classification, hookOptions);
104
+
105
+ if (strategy === 'up-to-date') {
106
+ return {
107
+ payload: {
108
+ action: 'up-to-date',
109
+ hookPath: status.hookPath,
110
+ version: installer._version,
111
+ },
112
+ exitCode: EXIT_CODES.OK,
113
+ };
114
+ }
115
+
116
+ if (strategy === 'skip') {
117
+ return {
118
+ payload: { action: 'skipped' },
119
+ exitCode: EXIT_CODES.OK,
120
+ };
121
+ }
122
+
123
+ const result = installer.install(options.repo, { strategy });
124
+ return {
125
+ payload: result,
126
+ exitCode: EXIT_CODES.OK,
127
+ };
128
+ }
@@ -0,0 +1,99 @@
1
+ import WebCryptoAdapter from '../../../src/infrastructure/adapters/WebCryptoAdapter.js';
2
+ import WarpGraph from '../../../src/domain/WarpGraph.js';
3
+ import { EXIT_CODES, notFoundError } from '../infrastructure.js';
4
+ import { createPersistence, listGraphNames, readActiveCursor, emitCursorWarning } from '../shared.js';
5
+
6
+ /** @typedef {import('../types.js').CliOptions} CliOptions */
7
+ /** @typedef {import('../types.js').Persistence} Persistence */
8
+
9
+ /**
10
+ * Materializes a single graph, creates a checkpoint, and returns summary stats.
11
+ * @param {{persistence: Persistence, graphName: string, writerId: string, ceiling?: number}} params
12
+ * @returns {Promise<{graph: string, nodes: number, edges: number, properties: number, checkpoint: string|null, writers: Record<string, number>, patchCount: number}>}
13
+ */
14
+ async function materializeOneGraph({ persistence, graphName, writerId, ceiling }) {
15
+ const graph = await WarpGraph.open({ persistence, graphName, writerId, crypto: new WebCryptoAdapter() });
16
+ await graph.materialize(ceiling !== undefined ? { ceiling } : undefined);
17
+ const nodes = await graph.getNodes();
18
+ const edges = await graph.getEdges();
19
+ const checkpoint = ceiling !== undefined ? null : await graph.createCheckpoint();
20
+ const status = await graph.status();
21
+
22
+ // Build per-writer patch counts for the view renderer
23
+ /** @type {Record<string, number>} */
24
+ const writers = {};
25
+ let totalPatchCount = 0;
26
+ for (const wId of Object.keys(status.frontier)) {
27
+ const patches = await graph.getWriterPatches(wId);
28
+ writers[wId] = patches.length;
29
+ totalPatchCount += patches.length;
30
+ }
31
+
32
+ const properties = await graph.getPropertyCount();
33
+
34
+ return {
35
+ graph: graphName,
36
+ nodes: nodes.length,
37
+ edges: edges.length,
38
+ properties,
39
+ checkpoint,
40
+ writers,
41
+ patchCount: totalPatchCount,
42
+ };
43
+ }
44
+
45
+ /**
46
+ * Handles the `materialize` command: materializes and checkpoints all graphs.
47
+ * @param {{options: CliOptions}} params
48
+ * @returns {Promise<{payload: *, exitCode: number}>}
49
+ */
50
+ export default async function handleMaterialize({ options }) {
51
+ const { persistence } = await createPersistence(options.repo);
52
+ const graphNames = await listGraphNames(persistence);
53
+
54
+ if (graphNames.length === 0) {
55
+ return {
56
+ payload: { graphs: [] },
57
+ exitCode: EXIT_CODES.OK,
58
+ };
59
+ }
60
+
61
+ const targets = options.graph
62
+ ? [options.graph]
63
+ : graphNames;
64
+
65
+ if (options.graph && !graphNames.includes(options.graph)) {
66
+ throw notFoundError(`Graph not found: ${options.graph}`);
67
+ }
68
+
69
+ const results = [];
70
+ let cursorWarningEmitted = false;
71
+ for (const name of targets) {
72
+ try {
73
+ const cursor = await readActiveCursor(persistence, name);
74
+ const ceiling = cursor ? cursor.tick : undefined;
75
+ if (cursor && !cursorWarningEmitted) {
76
+ emitCursorWarning({ active: true, tick: cursor.tick, maxTick: null }, null);
77
+ cursorWarningEmitted = true;
78
+ }
79
+ const result = await materializeOneGraph({
80
+ persistence,
81
+ graphName: name,
82
+ writerId: options.writer,
83
+ ceiling,
84
+ });
85
+ results.push(result);
86
+ } catch (error) {
87
+ results.push({
88
+ graph: name,
89
+ error: error instanceof Error ? error.message : String(error),
90
+ });
91
+ }
92
+ }
93
+
94
+ const allFailed = results.every((r) => /** @type {*} */ (r).error); // TODO(ts-cleanup): type CLI payload
95
+ return {
96
+ payload: { graphs: results },
97
+ exitCode: allFailed ? EXIT_CODES.INTERNAL : EXIT_CODES.OK,
98
+ };
99
+ }
@@ -0,0 +1,88 @@
1
+ import { renderSvg } from '../../../src/visualization/renderers/svg/index.js';
2
+ import { layoutGraph, pathResultToGraphData } from '../../../src/visualization/layouts/index.js';
3
+ import { EXIT_CODES, usageError, notFoundError, parseCommandArgs } from '../infrastructure.js';
4
+ import { openGraph, applyCursorCeiling, emitCursorWarning } from '../shared.js';
5
+ import { pathSchema } from '../schemas.js';
6
+
7
+ /** @typedef {import('../types.js').CliOptions} CliOptions */
8
+
9
+ const PATH_OPTIONS = {
10
+ from: { type: 'string' },
11
+ to: { type: 'string' },
12
+ dir: { type: 'string' },
13
+ label: { type: 'string', multiple: true },
14
+ 'max-depth': { type: 'string' },
15
+ };
16
+
17
+ /** @param {string[]} args */
18
+ function parsePathArgs(args) {
19
+ const { values, positionals } = parseCommandArgs(args, PATH_OPTIONS, pathSchema, { allowPositionals: true });
20
+
21
+ // Positionals can supply from/to when flags are omitted
22
+ const from = values.from || positionals[0] || null;
23
+ const to = values.to || positionals[1] || null;
24
+
25
+ if (!from || !to) {
26
+ throw usageError('Path requires --from and --to (or two positional ids)');
27
+ }
28
+
29
+ // Expand comma-separated labels
30
+ const labels = values.labels.flatMap((/** @type {string} */ l) => l.split(',').map((/** @type {string} */ s) => s.trim()).filter(Boolean));
31
+
32
+ /** @type {string|string[]|undefined} */
33
+ let labelFilter;
34
+ if (labels.length === 1) {
35
+ labelFilter = labels[0];
36
+ } else if (labels.length > 1) {
37
+ labelFilter = labels;
38
+ }
39
+
40
+ return { from, to, dir: values.dir, labelFilter, maxDepth: values.maxDepth };
41
+ }
42
+
43
+ /**
44
+ * Handles the `path` command: finds a shortest path between two nodes.
45
+ * @param {{options: CliOptions, args: string[]}} params
46
+ * @returns {Promise<{payload: *, exitCode: number}>}
47
+ */
48
+ export default async function handlePath({ options, args }) {
49
+ const pathOptions = parsePathArgs(args);
50
+ const { graph, graphName, persistence } = await openGraph(options);
51
+ const cursorInfo = await applyCursorCeiling(graph, persistence, graphName);
52
+ emitCursorWarning(cursorInfo, null);
53
+
54
+ try {
55
+ const result = await graph.traverse.shortestPath(
56
+ pathOptions.from,
57
+ pathOptions.to,
58
+ {
59
+ dir: pathOptions.dir,
60
+ labelFilter: pathOptions.labelFilter,
61
+ maxDepth: pathOptions.maxDepth,
62
+ }
63
+ );
64
+
65
+ const payload = {
66
+ graph: graphName,
67
+ from: pathOptions.from,
68
+ to: pathOptions.to,
69
+ ...result,
70
+ };
71
+
72
+ if (options.view && result.found && typeof options.view === 'string' && (options.view.startsWith('svg:') || options.view.startsWith('html:'))) {
73
+ const graphData = pathResultToGraphData(payload);
74
+ const positioned = await layoutGraph(graphData, { type: 'path' });
75
+ payload._renderedSvg = renderSvg(positioned, { title: `${graphName} path` });
76
+ }
77
+
78
+ return {
79
+ payload,
80
+ exitCode: result.found ? EXIT_CODES.OK : EXIT_CODES.NOT_FOUND,
81
+ };
82
+ } catch (/** @type {*} */ error) { // TODO(ts-cleanup): type error
83
+ if (error && error.code === 'NODE_NOT_FOUND') {
84
+ throw notFoundError(error.message);
85
+ }
86
+ throw error;
87
+ }
88
+ }