@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
package/bin/warp-graph.js CHANGED
@@ -1,2352 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import crypto from 'node:crypto';
4
- import fs from 'node:fs';
5
- import path from 'node:path';
6
3
  import process from 'node:process';
7
- import readline from 'node:readline';
8
- import { execFileSync } from 'node:child_process';
9
- // @ts-expect-error — no type declarations for @git-stunts/plumbing
10
- import GitPlumbing, { ShellRunnerFactory } from '@git-stunts/plumbing';
11
- import WarpGraph from '../src/domain/WarpGraph.js';
12
- import GitGraphAdapter from '../src/infrastructure/adapters/GitGraphAdapter.js';
13
- import HealthCheckService from '../src/domain/services/HealthCheckService.js';
14
- import ClockAdapter from '../src/infrastructure/adapters/ClockAdapter.js';
15
- import NodeCryptoAdapter from '../src/infrastructure/adapters/NodeCryptoAdapter.js';
16
- import {
17
- REF_PREFIX,
18
- buildCheckpointRef,
19
- buildCoverageRef,
20
- buildWritersPrefix,
21
- parseWriterIdFromRef,
22
- buildCursorActiveRef,
23
- buildCursorSavedRef,
24
- buildCursorSavedPrefix,
25
- } from '../src/domain/utils/RefLayout.js';
26
- import CasSeekCacheAdapter from '../src/infrastructure/adapters/CasSeekCacheAdapter.js';
27
- import { HookInstaller, classifyExistingHook } from '../src/domain/services/HookInstaller.js';
28
- import { summarizeOps } from '../src/visualization/renderers/ascii/history.js';
29
- import { parseCursorBlob } from '../src/domain/utils/parseCursorBlob.js';
30
- import { diffStates } from '../src/domain/services/StateDiff.js';
31
- import { renderGraphView } from '../src/visualization/renderers/ascii/graph.js';
32
- import { renderSvg } from '../src/visualization/renderers/svg/index.js';
33
- import { layoutGraph, queryResultToGraphData, pathResultToGraphData } from '../src/visualization/layouts/index.js';
4
+ import { EXIT_CODES, HELP_TEXT, CliError, parseArgs, usageError } from './cli/infrastructure.js';
34
5
  import { present } from './presenters/index.js';
35
6
  import { stableStringify, compactStringify } from './presenters/json.js';
36
7
  import { renderError } from './presenters/text.js';
8
+ import { COMMANDS } from './cli/commands/registry.js';
37
9
 
38
- /**
39
- * @typedef {Object} Persistence
40
- * @property {(prefix: string) => Promise<string[]>} listRefs
41
- * @property {(ref: string) => Promise<string|null>} readRef
42
- * @property {(ref: string, oid: string) => Promise<void>} updateRef
43
- * @property {(ref: string) => Promise<void>} deleteRef
44
- * @property {(oid: string) => Promise<Buffer>} readBlob
45
- * @property {(buf: Buffer) => Promise<string>} writeBlob
46
- * @property {(sha: string) => Promise<{date?: string|null}>} getNodeInfo
47
- * @property {(sha: string, coverageSha: string) => Promise<boolean>} isAncestor
48
- * @property {() => Promise<{ok: boolean}>} ping
49
- * @property {*} plumbing
50
- */
51
-
52
- /**
53
- * @typedef {Object} WarpGraphInstance
54
- * @property {(opts?: {ceiling?: number}) => Promise<void>} materialize
55
- * @property {() => Promise<Array<{id: string}>>} getNodes
56
- * @property {() => Promise<Array<{from: string, to: string, label?: string}>>} getEdges
57
- * @property {() => Promise<string|null>} createCheckpoint
58
- * @property {() => *} query
59
- * @property {{ shortestPath: Function }} traverse
60
- * @property {(writerId: string) => Promise<Array<{patch: any, sha: string}>>} getWriterPatches
61
- * @property {() => Promise<{frontier: Record<string, any>}>} status
62
- * @property {() => Promise<Map<string, any>>} getFrontier
63
- * @property {() => {totalTombstones: number, tombstoneRatio: number}} getGCMetrics
64
- * @property {() => Promise<number>} getPropertyCount
65
- * @property {() => Promise<import('../src/domain/services/JoinReducer.js').WarpStateV5 | null>} getStateSnapshot
66
- * @property {() => Promise<{ticks: number[], maxTick: number, perWriter: Map<string, WriterTickInfo>}>} discoverTicks
67
- * @property {(sha: string) => Promise<{ops?: any[]}>} loadPatchBySha
68
- * @property {(cache: any) => void} setSeekCache
69
- * @property {*} seekCache
70
- * @property {number} [_seekCeiling]
71
- * @property {boolean} [_provenanceDegraded]
72
- */
73
-
74
- /**
75
- * @typedef {Object} WriterTickInfo
76
- * @property {number[]} ticks
77
- * @property {string|null} tipSha
78
- * @property {Record<number, string>} [tickShas]
79
- */
80
-
81
- /**
82
- * @typedef {Object} CursorBlob
83
- * @property {number} tick
84
- * @property {string} [mode]
85
- * @property {number} [nodes]
86
- * @property {number} [edges]
87
- * @property {string} [frontierHash]
88
- */
89
-
90
- /**
91
- * @typedef {Object} CliOptions
92
- * @property {string} repo
93
- * @property {boolean} json
94
- * @property {boolean} ndjson
95
- * @property {string|null} view
96
- * @property {string|null} graph
97
- * @property {string} writer
98
- * @property {boolean} help
99
- */
100
-
101
- /**
102
- * @typedef {Object} GraphInfoResult
103
- * @property {string} name
104
- * @property {{count: number, ids?: string[]}} writers
105
- * @property {{ref: string, sha: string|null, date?: string|null}} [checkpoint]
106
- * @property {{ref: string, sha: string|null}} [coverage]
107
- * @property {Record<string, number>} [writerPatches]
108
- * @property {{active: boolean, tick?: number, mode?: string}} [cursor]
109
- */
110
-
111
- /**
112
- * @typedef {Object} SeekSpec
113
- * @property {string} action
114
- * @property {string|null} tickValue
115
- * @property {string|null} name
116
- * @property {boolean} noPersistentCache
117
- * @property {boolean} diff
118
- * @property {number} diffLimit
119
- */
120
-
121
- const EXIT_CODES = {
122
- OK: 0,
123
- USAGE: 1,
124
- NOT_FOUND: 2,
125
- INTERNAL: 3,
126
- };
127
-
128
- const HELP_TEXT = `warp-graph <command> [options]
129
- (or: git warp <command> [options])
130
-
131
- Commands:
132
- info Summarize graphs in the repo
133
- query Run a logical graph query
134
- path Find a logical path between two nodes
135
- history Show writer history
136
- check Report graph health/GC status
137
- materialize Materialize and checkpoint all graphs
138
- seek Time-travel: step through graph history by Lamport tick
139
- view Interactive TUI graph browser (requires @git-stunts/git-warp-tui)
140
- install-hooks Install post-merge git hook
141
-
142
- Options:
143
- --repo <path> Path to git repo (default: cwd)
144
- --json Emit JSON output (pretty-printed, sorted keys)
145
- --ndjson Emit compact single-line JSON (for piping/scripting)
146
- --view [mode] Visual output (ascii, browser, svg:FILE, html:FILE)
147
- --graph <name> Graph name (required if repo has multiple graphs)
148
- --writer <id> Writer id (default: cli)
149
- -h, --help Show this help
150
-
151
- Install-hooks options:
152
- --force Replace existing hook (backs up original)
153
-
154
- Query options:
155
- --match <glob> Match node ids (default: *)
156
- --outgoing [label] Traverse outgoing edge (repeatable)
157
- --incoming [label] Traverse incoming edge (repeatable)
158
- --where-prop k=v Filter nodes by prop equality (repeatable)
159
- --select <fields> Fields to select (id, props)
160
-
161
- Path options:
162
- --from <id> Start node id
163
- --to <id> End node id
164
- --dir <out|in|both> Traversal direction (default: out)
165
- --label <label> Filter by edge label (repeatable, comma-separated)
166
- --max-depth <n> Maximum depth
167
-
168
- History options:
169
- --node <id> Filter patches touching node id
170
-
171
- Seek options:
172
- --tick <N|+N|-N> Jump to tick N, or step forward/backward
173
- --latest Clear cursor, return to present
174
- --save <name> Save current position as named cursor
175
- --load <name> Restore a saved cursor
176
- --list List all saved cursors
177
- --drop <name> Delete a saved cursor
178
- --diff Show structural diff (added/removed nodes, edges, props)
179
- --diff-limit <N> Max diff entries (default 2000)
180
- `;
181
-
182
- /**
183
- * Structured CLI error with exit code and error code.
184
- */
185
- class CliError extends Error {
186
- /**
187
- * @param {string} message - Human-readable error message
188
- * @param {Object} [options]
189
- * @param {string} [options.code='E_CLI'] - Machine-readable error code
190
- * @param {number} [options.exitCode=3] - Process exit code
191
- * @param {Error} [options.cause] - Underlying cause
192
- */
193
- constructor(message, { code = 'E_CLI', exitCode = EXIT_CODES.INTERNAL, cause } = {}) {
194
- super(message);
195
- this.code = code;
196
- this.exitCode = exitCode;
197
- this.cause = cause;
198
- }
199
- }
200
-
201
- /** @param {string} message */
202
- function usageError(message) {
203
- return new CliError(message, { code: 'E_USAGE', exitCode: EXIT_CODES.USAGE });
204
- }
205
-
206
- /** @param {string} message */
207
- function notFoundError(message) {
208
- return new CliError(message, { code: 'E_NOT_FOUND', exitCode: EXIT_CODES.NOT_FOUND });
209
- }
210
-
211
- /** @param {string[]} argv */
212
- function parseArgs(argv) {
213
- const options = createDefaultOptions();
214
- /** @type {string[]} */
215
- const positionals = [];
216
- const optionDefs = [
217
- { flag: '--repo', shortFlag: '-r', key: 'repo' },
218
- { flag: '--graph', key: 'graph' },
219
- { flag: '--writer', key: 'writer' },
220
- ];
221
-
222
- for (let i = 0; i < argv.length; i += 1) {
223
- const result = consumeBaseArg({ argv, index: i, options, optionDefs, positionals });
224
- if (result.done) {
225
- break;
226
- }
227
- i += result.consumed;
228
- }
229
-
230
- options.repo = path.resolve(options.repo);
231
- return { options, positionals };
232
- }
233
-
234
- function createDefaultOptions() {
235
- return {
236
- repo: process.cwd(),
237
- json: false,
238
- ndjson: false,
239
- view: null,
240
- graph: null,
241
- writer: 'cli',
242
- help: false,
243
- };
244
- }
245
-
246
- /**
247
- * @param {Object} params
248
- * @param {string[]} params.argv
249
- * @param {number} params.index
250
- * @param {Record<string, *>} params.options
251
- * @param {Array<{flag: string, shortFlag?: string, key: string}>} params.optionDefs
252
- * @param {string[]} params.positionals
253
- */
254
- function consumeBaseArg({ argv, index, options, optionDefs, positionals }) {
255
- const arg = argv[index];
256
-
257
- if (arg === '--') {
258
- positionals.push(...argv.slice(index + 1));
259
- return { consumed: argv.length - index - 1, done: true };
260
- }
261
-
262
- if (arg === '--json') {
263
- options.json = true;
264
- return { consumed: 0 };
265
- }
266
-
267
- if (arg === '--ndjson') {
268
- options.ndjson = true;
269
- return { consumed: 0 };
270
- }
271
-
272
- if (arg === '--view') {
273
- // Valid view modes: ascii, browser, svg:FILE, html:FILE
274
- // Don't consume known commands as modes
275
- const KNOWN_COMMANDS = ['info', 'query', 'path', 'history', 'check', 'materialize', 'seek', 'install-hooks'];
276
- const nextArg = argv[index + 1];
277
- const isViewMode = nextArg &&
278
- !nextArg.startsWith('-') &&
279
- !KNOWN_COMMANDS.includes(nextArg);
280
- if (isViewMode) {
281
- // Validate the view mode value
282
- const validModes = ['ascii', 'browser'];
283
- const validPrefixes = ['svg:', 'html:'];
284
- const isValid = validModes.includes(nextArg) ||
285
- validPrefixes.some((prefix) => nextArg.startsWith(prefix));
286
- if (!isValid) {
287
- throw usageError(`Invalid view mode: ${nextArg}. Valid modes: ascii, browser, svg:FILE, html:FILE`);
288
- }
289
- options.view = nextArg;
290
- return { consumed: 1 };
291
- }
292
- options.view = 'ascii'; // default mode
293
- return { consumed: 0 };
294
- }
295
-
296
- if (arg === '-h' || arg === '--help') {
297
- options.help = true;
298
- return { consumed: 0 };
299
- }
300
-
301
- const matched = matchOptionDef(arg, optionDefs);
302
- if (matched) {
303
- const result = readOptionValue({
304
- args: argv,
305
- index,
306
- flag: matched.flag,
307
- shortFlag: matched.shortFlag,
308
- allowEmpty: false,
309
- });
310
- if (result) {
311
- options[matched.key] = result.value;
312
- return { consumed: result.consumed };
313
- }
314
- }
315
-
316
- if (arg.startsWith('-')) {
317
- throw usageError(`Unknown option: ${arg}`);
318
- }
319
-
320
- positionals.push(arg, ...argv.slice(index + 1));
321
- return { consumed: argv.length - index - 1, done: true };
322
- }
323
-
324
- /**
325
- * @param {string} arg
326
- * @param {Array<{flag: string, shortFlag?: string, key: string}>} optionDefs
327
- */
328
- function matchOptionDef(arg, optionDefs) {
329
- return optionDefs.find((def) =>
330
- arg === def.flag ||
331
- arg === def.shortFlag ||
332
- arg.startsWith(`${def.flag}=`)
333
- );
334
- }
335
-
336
- /** @param {string} repoPath @returns {Promise<{persistence: Persistence}>} */
337
- async function createPersistence(repoPath) {
338
- const runner = ShellRunnerFactory.create();
339
- const plumbing = new GitPlumbing({ cwd: repoPath, runner });
340
- const persistence = new GitGraphAdapter({ plumbing });
341
- const ping = await persistence.ping();
342
- if (!ping.ok) {
343
- throw usageError(`Repository not accessible: ${repoPath}`);
344
- }
345
- return { persistence };
346
- }
347
-
348
- /** @param {Persistence} persistence @returns {Promise<string[]>} */
349
- async function listGraphNames(persistence) {
350
- if (typeof persistence.listRefs !== 'function') {
351
- return [];
352
- }
353
- const refs = await persistence.listRefs(REF_PREFIX);
354
- const prefix = `${REF_PREFIX}/`;
355
- const names = new Set();
356
-
357
- for (const ref of refs) {
358
- if (!ref.startsWith(prefix)) {
359
- continue;
360
- }
361
- const rest = ref.slice(prefix.length);
362
- const [graphName] = rest.split('/');
363
- if (graphName) {
364
- names.add(graphName);
365
- }
366
- }
367
-
368
- return [...names].sort();
369
- }
370
-
371
- /**
372
- * @param {Persistence} persistence
373
- * @param {string|null} explicitGraph
374
- * @returns {Promise<string>}
375
- */
376
- async function resolveGraphName(persistence, explicitGraph) {
377
- if (explicitGraph) {
378
- return explicitGraph;
379
- }
380
- const graphNames = await listGraphNames(persistence);
381
- if (graphNames.length === 1) {
382
- return graphNames[0];
383
- }
384
- if (graphNames.length === 0) {
385
- throw notFoundError('No graphs found in repo; specify --graph');
386
- }
387
- throw usageError('Multiple graphs found; specify --graph');
388
- }
389
-
390
- /**
391
- * Collects metadata about a single graph (writer count, refs, patches, checkpoint).
392
- * @param {Persistence} persistence - GraphPersistencePort adapter
393
- * @param {string} graphName - Name of the graph to inspect
394
- * @param {Object} [options]
395
- * @param {boolean} [options.includeWriterIds=false] - Include writer ID list
396
- * @param {boolean} [options.includeRefs=false] - Include checkpoint/coverage refs
397
- * @param {boolean} [options.includeWriterPatches=false] - Include per-writer patch counts
398
- * @param {boolean} [options.includeCheckpointDate=false] - Include checkpoint date
399
- * @returns {Promise<GraphInfoResult>} Graph info object
400
- */
401
- async function getGraphInfo(persistence, graphName, {
402
- includeWriterIds = false,
403
- includeRefs = false,
404
- includeWriterPatches = false,
405
- includeCheckpointDate = false,
406
- } = {}) {
407
- const writersPrefix = buildWritersPrefix(graphName);
408
- const writerRefs = typeof persistence.listRefs === 'function'
409
- ? await persistence.listRefs(writersPrefix)
410
- : [];
411
- const writerIds = /** @type {string[]} */ (writerRefs
412
- .map((ref) => parseWriterIdFromRef(ref))
413
- .filter(Boolean)
414
- .sort());
415
-
416
- /** @type {GraphInfoResult} */
417
- const info = {
418
- name: graphName,
419
- writers: {
420
- count: writerIds.length,
421
- },
422
- };
423
-
424
- if (includeWriterIds) {
425
- info.writers.ids = writerIds;
426
- }
427
-
428
- if (includeRefs || includeCheckpointDate) {
429
- const checkpointRef = buildCheckpointRef(graphName);
430
- const checkpointSha = await persistence.readRef(checkpointRef);
431
-
432
- /** @type {{ref: string, sha: string|null, date?: string|null}} */
433
- const checkpoint = { ref: checkpointRef, sha: checkpointSha || null };
434
-
435
- if (includeCheckpointDate && checkpointSha) {
436
- const checkpointDate = await readCheckpointDate(persistence, checkpointSha);
437
- checkpoint.date = checkpointDate;
438
- }
439
-
440
- info.checkpoint = checkpoint;
441
-
442
- if (includeRefs) {
443
- const coverageRef = buildCoverageRef(graphName);
444
- const coverageSha = await persistence.readRef(coverageRef);
445
- info.coverage = { ref: coverageRef, sha: coverageSha || null };
446
- }
447
- }
448
-
449
- if (includeWriterPatches && writerIds.length > 0) {
450
- const graph = await WarpGraph.open({
451
- persistence,
452
- graphName,
453
- writerId: 'cli',
454
- crypto: new NodeCryptoAdapter(),
455
- });
456
- /** @type {Record<string, number>} */
457
- const writerPatches = {};
458
- for (const writerId of writerIds) {
459
- const patches = await graph.getWriterPatches(writerId);
460
- writerPatches[/** @type {string} */ (writerId)] = patches.length;
461
- }
462
- info.writerPatches = writerPatches;
463
- }
464
-
465
- return info;
466
- }
467
-
468
- /**
469
- * Opens a WarpGraph for the given CLI options.
470
- * @param {CliOptions} options - Parsed CLI options
471
- * @returns {Promise<{graph: WarpGraphInstance, graphName: string, persistence: Persistence}>}
472
- * @throws {CliError} If the specified graph is not found
473
- */
474
- async function openGraph(options) {
475
- const { persistence } = await createPersistence(options.repo);
476
- const graphName = await resolveGraphName(persistence, options.graph);
477
- if (options.graph) {
478
- const graphNames = await listGraphNames(persistence);
479
- if (!graphNames.includes(options.graph)) {
480
- throw notFoundError(`Graph not found: ${options.graph}`);
481
- }
482
- }
483
- const graph = /** @type {WarpGraphInstance} */ (/** @type {*} */ (await WarpGraph.open({ // TODO(ts-cleanup): narrow port type
484
- persistence,
485
- graphName,
486
- writerId: options.writer,
487
- crypto: new NodeCryptoAdapter(),
488
- })));
489
- return { graph, graphName, persistence };
490
- }
491
-
492
- /** @param {string[]} args */
493
- function parseQueryArgs(args) {
494
- const spec = {
495
- match: null,
496
- select: null,
497
- steps: [],
498
- };
499
-
500
- for (let i = 0; i < args.length; i += 1) {
501
- const result = consumeQueryArg(args, i, spec);
502
- if (!result) {
503
- throw usageError(`Unknown query option: ${args[i]}`);
504
- }
505
- i += result.consumed;
506
- }
507
-
508
- return spec;
509
- }
510
-
511
- /**
512
- * @param {string[]} args
513
- * @param {number} index
514
- * @param {{match: string|null, select: string[]|null, steps: Array<{type: string, label?: string, key?: string, value?: string}>}} spec
515
- */
516
- function consumeQueryArg(args, index, spec) {
517
- const stepResult = readTraversalStep(args, index);
518
- if (stepResult) {
519
- spec.steps.push(stepResult.step);
520
- return stepResult;
521
- }
522
-
523
- const matchResult = readOptionValue({
524
- args,
525
- index,
526
- flag: '--match',
527
- allowEmpty: true,
528
- });
529
- if (matchResult) {
530
- spec.match = matchResult.value;
531
- return matchResult;
532
- }
533
-
534
- const whereResult = readOptionValue({
535
- args,
536
- index,
537
- flag: '--where-prop',
538
- allowEmpty: false,
539
- });
540
- if (whereResult) {
541
- spec.steps.push(parseWhereProp(whereResult.value));
542
- return whereResult;
543
- }
544
-
545
- const selectResult = readOptionValue({
546
- args,
547
- index,
548
- flag: '--select',
549
- allowEmpty: true,
550
- });
551
- if (selectResult) {
552
- spec.select = parseSelectFields(selectResult.value);
553
- return selectResult;
554
- }
555
-
556
- return null;
557
- }
558
-
559
- /** @param {string} value */
560
- function parseWhereProp(value) {
561
- const [key, ...rest] = value.split('=');
562
- if (!key || rest.length === 0) {
563
- throw usageError('Expected --where-prop key=value');
564
- }
565
- return { type: 'where-prop', key, value: rest.join('=') };
566
- }
567
-
568
- /** @param {string} value */
569
- function parseSelectFields(value) {
570
- if (value === '') {
571
- return [];
572
- }
573
- return value.split(',').map((field) => field.trim()).filter(Boolean);
574
- }
575
-
576
- /**
577
- * @param {string[]} args
578
- * @param {number} index
579
- */
580
- function readTraversalStep(args, index) {
581
- const arg = args[index];
582
- if (arg !== '--outgoing' && arg !== '--incoming') {
583
- return null;
584
- }
585
- const next = args[index + 1];
586
- const label = next && !next.startsWith('-') ? next : undefined;
587
- const consumed = label ? 1 : 0;
588
- return { step: { type: arg.slice(2), label }, consumed };
589
- }
590
-
591
- /**
592
- * @param {{args: string[], index: number, flag: string, shortFlag?: string, allowEmpty?: boolean}} params
593
- */
594
- function readOptionValue({ args, index, flag, shortFlag, allowEmpty = false }) {
595
- const arg = args[index];
596
- if (matchesOptionFlag(arg, flag, shortFlag)) {
597
- return readNextOptionValue({ args, index, flag, allowEmpty });
598
- }
599
-
600
- if (arg.startsWith(`${flag}=`)) {
601
- return readInlineOptionValue({ arg, flag, allowEmpty });
602
- }
603
-
604
- return null;
605
- }
606
-
607
- /**
608
- * @param {string} arg
609
- * @param {string} flag
610
- * @param {string} [shortFlag]
611
- */
612
- function matchesOptionFlag(arg, flag, shortFlag) {
613
- return arg === flag || (shortFlag && arg === shortFlag);
614
- }
615
-
616
- /** @param {{args: string[], index: number, flag: string, allowEmpty?: boolean}} params */
617
- function readNextOptionValue({ args, index, flag, allowEmpty }) {
618
- const value = args[index + 1];
619
- if (value === undefined || (!allowEmpty && value === '')) {
620
- throw usageError(`Missing value for ${flag}`);
621
- }
622
- return { value, consumed: 1 };
623
- }
624
-
625
- /** @param {{arg: string, flag: string, allowEmpty?: boolean}} params */
626
- function readInlineOptionValue({ arg, flag, allowEmpty }) {
627
- const value = arg.slice(flag.length + 1);
628
- if (!allowEmpty && value === '') {
629
- throw usageError(`Missing value for ${flag}`);
630
- }
631
- return { value, consumed: 0 };
632
- }
633
-
634
- /** @param {string[]} args */
635
- function parsePathArgs(args) {
636
- const options = createPathOptions();
637
- /** @type {string[]} */
638
- const labels = [];
639
- /** @type {string[]} */
640
- const positionals = [];
641
-
642
- for (let i = 0; i < args.length; i += 1) {
643
- const result = consumePathArg({ args, index: i, options, labels, positionals });
644
- i += result.consumed;
645
- }
646
-
647
- finalizePathOptions(options, labels, positionals);
648
- return options;
649
- }
650
-
651
- /** @returns {{from: string|null, to: string|null, dir: string|undefined, labelFilter: string|string[]|undefined, maxDepth: number|undefined}} */
652
- function createPathOptions() {
653
- return {
654
- from: null,
655
- to: null,
656
- dir: undefined,
657
- labelFilter: undefined,
658
- maxDepth: undefined,
659
- };
660
- }
661
-
662
- /**
663
- * @param {{args: string[], index: number, options: ReturnType<typeof createPathOptions>, labels: string[], positionals: string[]}} params
664
- */
665
- function consumePathArg({ args, index, options, labels, positionals }) {
666
- const arg = args[index];
667
- /** @type {Array<{flag: string, apply: (value: string) => void}>} */
668
- const handlers = [
669
- { flag: '--from', apply: (value) => { options.from = value; } },
670
- { flag: '--to', apply: (value) => { options.to = value; } },
671
- { flag: '--dir', apply: (value) => { options.dir = value; } },
672
- { flag: '--label', apply: (value) => { labels.push(...parseLabels(value)); } },
673
- { flag: '--max-depth', apply: (value) => { options.maxDepth = parseMaxDepth(value); } },
674
- ];
675
-
676
- for (const handler of handlers) {
677
- const result = readOptionValue({ args, index, flag: handler.flag });
678
- if (result) {
679
- handler.apply(result.value);
680
- return result;
681
- }
682
- }
683
-
684
- if (arg.startsWith('-')) {
685
- throw usageError(`Unknown path option: ${arg}`);
686
- }
687
-
688
- positionals.push(arg);
689
- return { consumed: 0 };
690
- }
691
-
692
- /**
693
- * @param {ReturnType<typeof createPathOptions>} options
694
- * @param {string[]} labels
695
- * @param {string[]} positionals
696
- */
697
- function finalizePathOptions(options, labels, positionals) {
698
- if (!options.from) {
699
- options.from = positionals[0] || null;
700
- }
701
-
702
- if (!options.to) {
703
- options.to = positionals[1] || null;
704
- }
705
-
706
- if (!options.from || !options.to) {
707
- throw usageError('Path requires --from and --to (or two positional ids)');
708
- }
709
-
710
- if (labels.length === 1) {
711
- options.labelFilter = labels[0];
712
- } else if (labels.length > 1) {
713
- options.labelFilter = labels;
714
- }
715
- }
716
-
717
- /** @param {string} value */
718
- function parseLabels(value) {
719
- return value.split(',').map((label) => label.trim()).filter(Boolean);
720
- }
721
-
722
- /** @param {string} value */
723
- function parseMaxDepth(value) {
724
- const parsed = Number.parseInt(value, 10);
725
- if (Number.isNaN(parsed)) {
726
- throw usageError('Invalid value for --max-depth');
727
- }
728
- return parsed;
729
- }
730
-
731
- /** @param {string[]} args */
732
- function parseHistoryArgs(args) {
733
- /** @type {{node: string|null}} */
734
- const options = { node: null };
735
-
736
- for (let i = 0; i < args.length; i += 1) {
737
- const arg = args[i];
738
-
739
- if (arg === '--node') {
740
- const value = args[i + 1];
741
- if (!value) {
742
- throw usageError('Missing value for --node');
743
- }
744
- options.node = value;
745
- i += 1;
746
- continue;
747
- }
748
-
749
- if (arg.startsWith('--node=')) {
750
- options.node = arg.slice('--node='.length);
751
- continue;
752
- }
753
-
754
- if (arg.startsWith('-')) {
755
- throw usageError(`Unknown history option: ${arg}`);
756
- }
757
-
758
- throw usageError(`Unexpected history argument: ${arg}`);
759
- }
760
-
761
- return options;
762
- }
763
-
764
- /**
765
- * @param {*} patch
766
- * @param {string} nodeId
767
- */
768
- function patchTouchesNode(patch, nodeId) {
769
- const ops = Array.isArray(patch?.ops) ? patch.ops : [];
770
- for (const op of ops) {
771
- if (op.node === nodeId) {
772
- return true;
773
- }
774
- if (op.from === nodeId || op.to === nodeId) {
775
- return true;
776
- }
777
- }
778
- return false;
779
- }
780
-
781
- /**
782
- * Handles the `info` command: summarizes graphs in the repository.
783
- * @param {{options: CliOptions}} params
784
- * @returns {Promise<{repo: string, graphs: GraphInfoResult[]}>} Info payload
785
- * @throws {CliError} If the specified graph is not found
786
- */
787
- async function handleInfo({ options }) {
788
- const { persistence } = await createPersistence(options.repo);
789
- const graphNames = await listGraphNames(persistence);
790
-
791
- if (options.graph && !graphNames.includes(options.graph)) {
792
- throw notFoundError(`Graph not found: ${options.graph}`);
793
- }
794
-
795
- const detailGraphs = new Set();
796
- if (options.graph) {
797
- detailGraphs.add(options.graph);
798
- } else if (graphNames.length === 1) {
799
- detailGraphs.add(graphNames[0]);
800
- }
801
-
802
- // In view mode, include extra data for visualization
803
- const isViewMode = Boolean(options.view);
804
-
805
- const graphs = [];
806
- for (const name of graphNames) {
807
- const includeDetails = detailGraphs.has(name);
808
- const info = await getGraphInfo(persistence, name, {
809
- includeWriterIds: includeDetails || isViewMode,
810
- includeRefs: includeDetails || isViewMode,
811
- includeWriterPatches: isViewMode,
812
- includeCheckpointDate: isViewMode,
813
- });
814
- const activeCursor = await readActiveCursor(persistence, name);
815
- if (activeCursor) {
816
- info.cursor = { active: true, tick: activeCursor.tick, mode: activeCursor.mode };
817
- } else {
818
- info.cursor = { active: false };
819
- }
820
- graphs.push(info);
821
- }
822
-
823
- return {
824
- repo: options.repo,
825
- graphs,
826
- };
827
- }
828
-
829
- /**
830
- * Handles the `query` command: runs a logical graph query.
831
- * @param {{options: CliOptions, args: string[]}} params
832
- * @returns {Promise<{payload: *, exitCode: number}>} Query result payload
833
- * @throws {CliError} On invalid query options or query execution errors
834
- */
835
- async function handleQuery({ options, args }) {
836
- const querySpec = parseQueryArgs(args);
837
- const { graph, graphName, persistence } = await openGraph(options);
838
- const cursorInfo = await applyCursorCeiling(graph, persistence, graphName);
839
- emitCursorWarning(cursorInfo, null);
840
- let builder = graph.query();
841
-
842
- if (querySpec.match !== null) {
843
- builder = builder.match(querySpec.match);
844
- }
845
-
846
- builder = applyQuerySteps(builder, querySpec.steps);
847
-
848
- if (querySpec.select !== null) {
849
- builder = builder.select(querySpec.select);
850
- }
851
-
852
- try {
853
- const result = await builder.run();
854
- const payload = buildQueryPayload(graphName, result);
855
-
856
- if (options.view) {
857
- const edges = await graph.getEdges();
858
- const graphData = queryResultToGraphData(payload, edges);
859
- const positioned = await layoutGraph(graphData, { type: 'query' });
860
- if (typeof options.view === 'string' && (options.view.startsWith('svg:') || options.view.startsWith('html:'))) {
861
- payload._renderedSvg = renderSvg(positioned, { title: `${graphName} query` });
862
- } else {
863
- payload._renderedAscii = renderGraphView(positioned, { title: `QUERY: ${graphName}` });
864
- }
865
- }
866
-
867
- return {
868
- payload,
869
- exitCode: EXIT_CODES.OK,
870
- };
871
- } catch (error) {
872
- throw mapQueryError(error);
873
- }
874
- }
875
-
876
- /**
877
- * @param {*} builder
878
- * @param {Array<{type: string, label?: string, key?: string, value?: string}>} steps
879
- */
880
- function applyQuerySteps(builder, steps) {
881
- let current = builder;
882
- for (const step of steps) {
883
- current = applyQueryStep(current, step);
884
- }
885
- return current;
886
- }
887
-
888
- /**
889
- * @param {*} builder
890
- * @param {{type: string, label?: string, key?: string, value?: string}} step
891
- */
892
- function applyQueryStep(builder, step) {
893
- if (step.type === 'outgoing') {
894
- return builder.outgoing(step.label);
895
- }
896
- if (step.type === 'incoming') {
897
- return builder.incoming(step.label);
898
- }
899
- if (step.type === 'where-prop') {
900
- return builder.where((/** @type {*} */ node) => matchesPropFilter(node, /** @type {string} */ (step.key), /** @type {string} */ (step.value))); // TODO(ts-cleanup): type CLI payload
901
- }
902
- return builder;
903
- }
904
-
905
- /**
906
- * @param {*} node
907
- * @param {string} key
908
- * @param {string} value
909
- */
910
- function matchesPropFilter(node, key, value) {
911
- const props = node.props || {};
912
- if (!Object.prototype.hasOwnProperty.call(props, key)) {
913
- return false;
914
- }
915
- return String(props[key]) === value;
916
- }
917
-
918
- /**
919
- * @param {string} graphName
920
- * @param {*} result
921
- * @returns {{graph: string, stateHash: *, nodes: *, _renderedSvg?: string, _renderedAscii?: string}}
922
- */
923
- function buildQueryPayload(graphName, result) {
924
- return {
925
- graph: graphName,
926
- stateHash: result.stateHash,
927
- nodes: result.nodes,
928
- };
929
- }
930
-
931
- /** @param {*} error */
932
- function mapQueryError(error) {
933
- if (error && error.code && String(error.code).startsWith('E_QUERY')) {
934
- throw usageError(error.message);
935
- }
936
- throw error;
937
- }
938
-
939
- /**
940
- * Handles the `path` command: finds a shortest path between two nodes.
941
- * @param {{options: CliOptions, args: string[]}} params
942
- * @returns {Promise<{payload: *, exitCode: number}>} Path result payload
943
- * @throws {CliError} If --from/--to are missing or a node is not found
944
- */
945
- async function handlePath({ options, args }) {
946
- const pathOptions = parsePathArgs(args);
947
- const { graph, graphName, persistence } = await openGraph(options);
948
- const cursorInfo = await applyCursorCeiling(graph, persistence, graphName);
949
- emitCursorWarning(cursorInfo, null);
950
-
951
- try {
952
- const result = await graph.traverse.shortestPath(
953
- pathOptions.from,
954
- pathOptions.to,
955
- {
956
- dir: pathOptions.dir,
957
- labelFilter: pathOptions.labelFilter,
958
- maxDepth: pathOptions.maxDepth,
959
- }
960
- );
961
-
962
- const payload = {
963
- graph: graphName,
964
- from: pathOptions.from,
965
- to: pathOptions.to,
966
- ...result,
967
- };
968
-
969
- if (options.view && result.found && typeof options.view === 'string' && (options.view.startsWith('svg:') || options.view.startsWith('html:'))) {
970
- const graphData = pathResultToGraphData(payload);
971
- const positioned = await layoutGraph(graphData, { type: 'path' });
972
- payload._renderedSvg = renderSvg(positioned, { title: `${graphName} path` });
973
- }
974
-
975
- return {
976
- payload,
977
- exitCode: result.found ? EXIT_CODES.OK : EXIT_CODES.NOT_FOUND,
978
- };
979
- } catch (/** @type {*} */ error) { // TODO(ts-cleanup): type error
980
- if (error && error.code === 'NODE_NOT_FOUND') {
981
- throw notFoundError(error.message);
982
- }
983
- throw error;
984
- }
985
- }
986
-
987
- /**
988
- * Handles the `check` command: reports graph health, GC, and hook status.
989
- * @param {{options: CliOptions}} params
990
- * @returns {Promise<{payload: *, exitCode: number}>} Health check payload
991
- */
992
- async function handleCheck({ options }) {
993
- const { graph, graphName, persistence } = await openGraph(options);
994
- const cursorInfo = await applyCursorCeiling(graph, persistence, graphName);
995
- emitCursorWarning(cursorInfo, null);
996
- const health = await getHealth(persistence);
997
- const gcMetrics = await getGcMetrics(graph);
998
- const status = await graph.status();
999
- const writerHeads = await collectWriterHeads(graph);
1000
- const checkpoint = await loadCheckpointInfo(persistence, graphName);
1001
- const coverage = await loadCoverageInfo(persistence, graphName, writerHeads);
1002
- const hook = getHookStatusForCheck(options.repo);
1003
-
1004
- return {
1005
- payload: buildCheckPayload({
1006
- repo: options.repo,
1007
- graphName,
1008
- health,
1009
- checkpoint,
1010
- writerHeads,
1011
- coverage,
1012
- gcMetrics,
1013
- hook,
1014
- status,
1015
- }),
1016
- exitCode: EXIT_CODES.OK,
1017
- };
1018
- }
1019
-
1020
- /** @param {Persistence} persistence */
1021
- async function getHealth(persistence) {
1022
- const clock = ClockAdapter.node();
1023
- const healthService = new HealthCheckService({ persistence: /** @type {*} */ (persistence), clock }); // TODO(ts-cleanup): narrow port type
1024
- return await healthService.getHealth();
1025
- }
1026
-
1027
- /** @param {WarpGraphInstance} graph */
1028
- async function getGcMetrics(graph) {
1029
- await graph.materialize();
1030
- return graph.getGCMetrics();
1031
- }
1032
-
1033
- /** @param {WarpGraphInstance} graph */
1034
- async function collectWriterHeads(graph) {
1035
- const frontier = await graph.getFrontier();
1036
- return [...frontier.entries()]
1037
- .sort(([a], [b]) => a.localeCompare(b))
1038
- .map(([writerId, sha]) => ({ writerId, sha }));
1039
- }
1040
-
1041
- /**
1042
- * @param {Persistence} persistence
1043
- * @param {string} graphName
1044
- */
1045
- async function loadCheckpointInfo(persistence, graphName) {
1046
- const checkpointRef = buildCheckpointRef(graphName);
1047
- const checkpointSha = await persistence.readRef(checkpointRef);
1048
- const checkpointDate = await readCheckpointDate(persistence, checkpointSha);
1049
- const checkpointAgeSeconds = computeAgeSeconds(checkpointDate);
1050
-
1051
- return {
1052
- ref: checkpointRef,
1053
- sha: checkpointSha || null,
1054
- date: checkpointDate,
1055
- ageSeconds: checkpointAgeSeconds,
1056
- };
1057
- }
1058
-
1059
- /**
1060
- * @param {Persistence} persistence
1061
- * @param {string|null} checkpointSha
1062
- */
1063
- async function readCheckpointDate(persistence, checkpointSha) {
1064
- if (!checkpointSha) {
1065
- return null;
1066
- }
1067
- const info = await persistence.getNodeInfo(checkpointSha);
1068
- return info.date || null;
1069
- }
1070
-
1071
- /** @param {string|null} checkpointDate */
1072
- function computeAgeSeconds(checkpointDate) {
1073
- if (!checkpointDate) {
1074
- return null;
1075
- }
1076
- const parsed = Date.parse(checkpointDate);
1077
- if (Number.isNaN(parsed)) {
1078
- return null;
1079
- }
1080
- return Math.max(0, Math.floor((Date.now() - parsed) / 1000));
1081
- }
1082
-
1083
- /**
1084
- * @param {Persistence} persistence
1085
- * @param {string} graphName
1086
- * @param {Array<{writerId: string, sha: string}>} writerHeads
1087
- */
1088
- async function loadCoverageInfo(persistence, graphName, writerHeads) {
1089
- const coverageRef = buildCoverageRef(graphName);
1090
- const coverageSha = await persistence.readRef(coverageRef);
1091
- const missingWriters = coverageSha
1092
- ? await findMissingWriters(persistence, writerHeads, coverageSha)
1093
- : [];
1094
-
1095
- return {
1096
- ref: coverageRef,
1097
- sha: coverageSha || null,
1098
- missingWriters: missingWriters.sort(),
1099
- };
1100
- }
1101
-
1102
- /**
1103
- * @param {Persistence} persistence
1104
- * @param {Array<{writerId: string, sha: string}>} writerHeads
1105
- * @param {string} coverageSha
1106
- */
1107
- async function findMissingWriters(persistence, writerHeads, coverageSha) {
1108
- const missing = [];
1109
- for (const head of writerHeads) {
1110
- const reachable = await persistence.isAncestor(head.sha, coverageSha);
1111
- if (!reachable) {
1112
- missing.push(head.writerId);
1113
- }
1114
- }
1115
- return missing;
1116
- }
1117
-
1118
- /**
1119
- * @param {{repo: string, graphName: string, health: *, checkpoint: *, writerHeads: Array<{writerId: string, sha: string}>, coverage: *, gcMetrics: *, hook: *|null, status: *|null}} params
1120
- */
1121
- function buildCheckPayload({
1122
- repo,
1123
- graphName,
1124
- health,
1125
- checkpoint,
1126
- writerHeads,
1127
- coverage,
1128
- gcMetrics,
1129
- hook,
1130
- status,
1131
- }) {
1132
- return {
1133
- repo,
1134
- graph: graphName,
1135
- health,
1136
- checkpoint,
1137
- writers: {
1138
- count: writerHeads.length,
1139
- heads: writerHeads,
1140
- },
1141
- coverage,
1142
- gc: gcMetrics,
1143
- hook: hook || null,
1144
- status: status || null,
1145
- };
1146
- }
1147
-
1148
- /**
1149
- * Handles the `history` command: shows patch history for a writer.
1150
- * @param {{options: CliOptions, args: string[]}} params
1151
- * @returns {Promise<{payload: *, exitCode: number}>} History payload
1152
- * @throws {CliError} If no patches are found for the writer
1153
- */
1154
- async function handleHistory({ options, args }) {
1155
- const historyOptions = parseHistoryArgs(args);
1156
- const { graph, graphName, persistence } = await openGraph(options);
1157
- const cursorInfo = await applyCursorCeiling(graph, persistence, graphName);
1158
- emitCursorWarning(cursorInfo, null);
1159
-
1160
- const writerId = options.writer;
1161
- let patches = await graph.getWriterPatches(writerId);
1162
- if (cursorInfo.active) {
1163
- patches = patches.filter((/** @type {*} */ { patch }) => patch.lamport <= /** @type {number} */ (cursorInfo.tick)); // TODO(ts-cleanup): type CLI payload
1164
- }
1165
- if (patches.length === 0) {
1166
- throw notFoundError(`No patches found for writer: ${writerId}`);
1167
- }
1168
-
1169
- const entries = patches
1170
- .filter((/** @type {*} */ { patch }) => !historyOptions.node || patchTouchesNode(patch, historyOptions.node)) // TODO(ts-cleanup): type CLI payload
1171
- .map((/** @type {*} */ { patch, sha }) => ({ // TODO(ts-cleanup): type CLI payload
1172
- sha,
1173
- schema: patch.schema,
1174
- lamport: patch.lamport,
1175
- opCount: Array.isArray(patch.ops) ? patch.ops.length : 0,
1176
- opSummary: Array.isArray(patch.ops) ? summarizeOps(patch.ops) : undefined,
1177
- }));
1178
-
1179
- const payload = {
1180
- graph: graphName,
1181
- writer: writerId,
1182
- nodeFilter: historyOptions.node,
1183
- entries,
1184
- };
1185
-
1186
- return { payload, exitCode: EXIT_CODES.OK };
1187
- }
1188
-
1189
- /**
1190
- * Materializes a single graph, creates a checkpoint, and returns summary stats.
1191
- * When a ceiling tick is provided (seek cursor active), the checkpoint step is
1192
- * skipped because the user is exploring historical state, not persisting it.
1193
- * @param {{persistence: Persistence, graphName: string, writerId: string, ceiling?: number}} params
1194
- * @returns {Promise<{graph: string, nodes: number, edges: number, properties: number, checkpoint: string|null, writers: Record<string, number>, patchCount: number}>}
1195
- */
1196
- async function materializeOneGraph({ persistence, graphName, writerId, ceiling }) {
1197
- const graph = await WarpGraph.open({ persistence, graphName, writerId, crypto: new NodeCryptoAdapter() });
1198
- await graph.materialize(ceiling !== undefined ? { ceiling } : undefined);
1199
- const nodes = await graph.getNodes();
1200
- const edges = await graph.getEdges();
1201
- const checkpoint = ceiling !== undefined ? null : await graph.createCheckpoint();
1202
- const status = await graph.status();
1203
-
1204
- // Build per-writer patch counts for the view renderer
1205
- /** @type {Record<string, number>} */
1206
- const writers = {};
1207
- let totalPatchCount = 0;
1208
- for (const wId of Object.keys(status.frontier)) {
1209
- const patches = await graph.getWriterPatches(wId);
1210
- writers[wId] = patches.length;
1211
- totalPatchCount += patches.length;
1212
- }
1213
-
1214
- const properties = await graph.getPropertyCount();
1215
-
1216
- return {
1217
- graph: graphName,
1218
- nodes: nodes.length,
1219
- edges: edges.length,
1220
- properties,
1221
- checkpoint,
1222
- writers,
1223
- patchCount: totalPatchCount,
1224
- };
1225
- }
1226
-
1227
- /**
1228
- * Handles the `materialize` command: materializes and checkpoints all graphs.
1229
- * @param {{options: CliOptions}} params
1230
- * @returns {Promise<{payload: *, exitCode: number}>} Materialize result payload
1231
- * @throws {CliError} If the specified graph is not found
1232
- */
1233
- async function handleMaterialize({ options }) {
1234
- const { persistence } = await createPersistence(options.repo);
1235
- const graphNames = await listGraphNames(persistence);
1236
-
1237
- if (graphNames.length === 0) {
1238
- return {
1239
- payload: { graphs: [] },
1240
- exitCode: EXIT_CODES.OK,
1241
- };
1242
- }
1243
-
1244
- const targets = options.graph
1245
- ? [options.graph]
1246
- : graphNames;
1247
-
1248
- if (options.graph && !graphNames.includes(options.graph)) {
1249
- throw notFoundError(`Graph not found: ${options.graph}`);
1250
- }
1251
-
1252
- const results = [];
1253
- let cursorWarningEmitted = false;
1254
- for (const name of targets) {
1255
- try {
1256
- const cursor = await readActiveCursor(persistence, name);
1257
- const ceiling = cursor ? cursor.tick : undefined;
1258
- if (cursor && !cursorWarningEmitted) {
1259
- emitCursorWarning({ active: true, tick: cursor.tick, maxTick: null }, null);
1260
- cursorWarningEmitted = true;
1261
- }
1262
- const result = await materializeOneGraph({
1263
- persistence,
1264
- graphName: name,
1265
- writerId: options.writer,
1266
- ceiling,
1267
- });
1268
- results.push(result);
1269
- } catch (error) {
1270
- results.push({
1271
- graph: name,
1272
- error: error instanceof Error ? error.message : String(error),
1273
- });
1274
- }
1275
- }
1276
-
1277
- const allFailed = results.every((r) => /** @type {*} */ (r).error); // TODO(ts-cleanup): type CLI payload
1278
- return {
1279
- payload: { graphs: results },
1280
- exitCode: allFailed ? EXIT_CODES.INTERNAL : EXIT_CODES.OK,
1281
- };
1282
- }
1283
-
1284
- function createHookInstaller() {
1285
- const __filename = new URL(import.meta.url).pathname;
1286
- const __dirname = path.dirname(__filename);
1287
- const templateDir = path.resolve(__dirname, '..', 'hooks');
1288
- const { version } = JSON.parse(fs.readFileSync(path.resolve(__dirname, '..', 'package.json'), 'utf8'));
1289
- return new HookInstaller({
1290
- fs: /** @type {*} */ (fs), // TODO(ts-cleanup): narrow port type
1291
- execGitConfig: execGitConfigValue,
1292
- version,
1293
- templateDir,
1294
- path,
1295
- });
1296
- }
1297
-
1298
- /**
1299
- * @param {string} repoPath
1300
- * @param {string} key
1301
- * @returns {string|null}
1302
- */
1303
- function execGitConfigValue(repoPath, key) {
1304
- try {
1305
- if (key === '--git-dir') {
1306
- return execFileSync('git', ['-C', repoPath, 'rev-parse', '--git-dir'], {
1307
- encoding: 'utf8',
1308
- }).trim();
1309
- }
1310
- return execFileSync('git', ['-C', repoPath, 'config', key], {
1311
- encoding: 'utf8',
1312
- }).trim();
1313
- } catch {
1314
- return null;
1315
- }
1316
- }
1317
-
1318
- function isInteractive() {
1319
- return Boolean(process.stderr.isTTY);
1320
- }
1321
-
1322
- /** @param {string} question @returns {Promise<string>} */
1323
- function promptUser(question) {
1324
- const rl = readline.createInterface({
1325
- input: process.stdin,
1326
- output: process.stderr,
1327
- });
1328
- return new Promise((resolve) => {
1329
- rl.question(question, (answer) => {
1330
- rl.close();
1331
- resolve(answer.trim());
1332
- });
1333
- });
1334
- }
1335
-
1336
- /** @param {string[]} args */
1337
- function parseInstallHooksArgs(args) {
1338
- const options = { force: false };
1339
- for (const arg of args) {
1340
- if (arg === '--force') {
1341
- options.force = true;
1342
- } else if (arg.startsWith('-')) {
1343
- throw usageError(`Unknown install-hooks option: ${arg}`);
1344
- }
1345
- }
1346
- return options;
1347
- }
1348
-
1349
- /**
1350
- * @param {*} classification
1351
- * @param {{force: boolean}} hookOptions
1352
- */
1353
- async function resolveStrategy(classification, hookOptions) {
1354
- if (hookOptions.force) {
1355
- return 'replace';
1356
- }
1357
-
1358
- if (classification.kind === 'none') {
1359
- return 'install';
1360
- }
1361
-
1362
- if (classification.kind === 'ours') {
1363
- return await promptForOursStrategy(classification);
1364
- }
1365
-
1366
- return await promptForForeignStrategy();
1367
- }
1368
-
1369
- /** @param {*} classification */
1370
- async function promptForOursStrategy(classification) {
1371
- const installer = createHookInstaller();
1372
- if (classification.version === installer._version) {
1373
- return 'up-to-date';
1374
- }
1375
-
1376
- if (!isInteractive()) {
1377
- throw usageError('Existing hook found. Use --force or run interactively.');
1378
- }
1379
-
1380
- const answer = await promptUser(
1381
- `Upgrade hook from v${classification.version} to v${installer._version}? [Y/n] `,
1382
- );
1383
- if (answer === '' || answer.toLowerCase() === 'y') {
1384
- return 'upgrade';
1385
- }
1386
- return 'skip';
1387
- }
1388
-
1389
- async function promptForForeignStrategy() {
1390
- if (!isInteractive()) {
1391
- throw usageError('Existing hook found. Use --force or run interactively.');
1392
- }
1393
-
1394
- process.stderr.write('Existing post-merge hook found.\n');
1395
- process.stderr.write(' 1) Append (keep existing hook, add warp section)\n');
1396
- process.stderr.write(' 2) Replace (back up existing, install fresh)\n');
1397
- process.stderr.write(' 3) Skip\n');
1398
- const answer = await promptUser('Choose [1-3]: ');
1399
-
1400
- if (answer === '1') {
1401
- return 'append';
1402
- }
1403
- if (answer === '2') {
1404
- return 'replace';
1405
- }
1406
- return 'skip';
1407
- }
1408
-
1409
- /**
1410
- * Handles the `install-hooks` command: installs or upgrades the post-merge git hook.
1411
- * @param {{options: CliOptions, args: string[]}} params
1412
- * @returns {Promise<{payload: *, exitCode: number}>} Install result payload
1413
- * @throws {CliError} If an existing hook is found and the session is not interactive
1414
- */
1415
- async function handleInstallHooks({ options, args }) {
1416
- const hookOptions = parseInstallHooksArgs(args);
1417
- const installer = createHookInstaller();
1418
- const status = installer.getHookStatus(options.repo);
1419
- const content = readHookContent(status.hookPath);
1420
- const classification = classifyExistingHook(content);
1421
- const strategy = await resolveStrategy(classification, hookOptions);
1422
-
1423
- if (strategy === 'up-to-date') {
1424
- return {
1425
- payload: {
1426
- action: 'up-to-date',
1427
- hookPath: status.hookPath,
1428
- version: installer._version,
1429
- },
1430
- exitCode: EXIT_CODES.OK,
1431
- };
1432
- }
1433
-
1434
- if (strategy === 'skip') {
1435
- return {
1436
- payload: { action: 'skipped' },
1437
- exitCode: EXIT_CODES.OK,
1438
- };
1439
- }
1440
-
1441
- const result = installer.install(options.repo, { strategy });
1442
- return {
1443
- payload: result,
1444
- exitCode: EXIT_CODES.OK,
1445
- };
1446
- }
1447
-
1448
- /** @param {string} hookPath */
1449
- function readHookContent(hookPath) {
1450
- try {
1451
- return fs.readFileSync(hookPath, 'utf8');
1452
- } catch {
1453
- return null;
1454
- }
1455
- }
1456
-
1457
- /** @param {string} repoPath */
1458
- function getHookStatusForCheck(repoPath) {
1459
- try {
1460
- const installer = createHookInstaller();
1461
- return installer.getHookStatus(repoPath);
1462
- } catch {
1463
- return null;
1464
- }
1465
- }
1466
-
1467
- // ============================================================================
1468
- // Cursor I/O Helpers
1469
- // ============================================================================
1470
-
1471
- /**
1472
- * Reads the active seek cursor for a graph from Git ref storage.
1473
- *
1474
- * @param {Persistence} persistence - GraphPersistencePort adapter
1475
- * @param {string} graphName - Name of the WARP graph
1476
- * @returns {Promise<CursorBlob|null>} Cursor object, or null if no active cursor
1477
- * @throws {Error} If the stored blob is corrupted or not valid JSON
1478
- */
1479
- async function readActiveCursor(persistence, graphName) {
1480
- const ref = buildCursorActiveRef(graphName);
1481
- const oid = await persistence.readRef(ref);
1482
- if (!oid) {
1483
- return null;
1484
- }
1485
- const buf = await persistence.readBlob(oid);
1486
- return parseCursorBlob(buf, 'active cursor');
1487
- }
1488
-
1489
- /**
1490
- * Writes (creates or overwrites) the active seek cursor for a graph.
1491
- *
1492
- * Serializes the cursor as JSON, stores it as a Git blob, and points
1493
- * the active cursor ref at that blob.
1494
- *
1495
- * @param {Persistence} persistence - GraphPersistencePort adapter
1496
- * @param {string} graphName - Name of the WARP graph
1497
- * @param {CursorBlob} cursor - Cursor state to persist
1498
- * @returns {Promise<void>}
1499
- */
1500
- async function writeActiveCursor(persistence, graphName, cursor) {
1501
- const ref = buildCursorActiveRef(graphName);
1502
- const json = JSON.stringify(cursor);
1503
- const oid = await persistence.writeBlob(Buffer.from(json, 'utf8'));
1504
- await persistence.updateRef(ref, oid);
1505
- }
1506
-
1507
- /**
1508
- * Removes the active seek cursor for a graph, returning to present state.
1509
- *
1510
- * No-op if no active cursor exists.
1511
- *
1512
- * @param {Persistence} persistence - GraphPersistencePort adapter
1513
- * @param {string} graphName - Name of the WARP graph
1514
- * @returns {Promise<void>}
1515
- */
1516
- async function clearActiveCursor(persistence, graphName) {
1517
- const ref = buildCursorActiveRef(graphName);
1518
- const exists = await persistence.readRef(ref);
1519
- if (exists) {
1520
- await persistence.deleteRef(ref);
1521
- }
1522
- }
1523
-
1524
- /**
1525
- * Reads a named saved cursor from Git ref storage.
1526
- *
1527
- * @param {Persistence} persistence - GraphPersistencePort adapter
1528
- * @param {string} graphName - Name of the WARP graph
1529
- * @param {string} name - Saved cursor name
1530
- * @returns {Promise<CursorBlob|null>} Cursor object, or null if not found
1531
- * @throws {Error} If the stored blob is corrupted or not valid JSON
1532
- */
1533
- async function readSavedCursor(persistence, graphName, name) {
1534
- const ref = buildCursorSavedRef(graphName, name);
1535
- const oid = await persistence.readRef(ref);
1536
- if (!oid) {
1537
- return null;
1538
- }
1539
- const buf = await persistence.readBlob(oid);
1540
- return parseCursorBlob(buf, `saved cursor '${name}'`);
1541
- }
1542
-
1543
- /**
1544
- * Persists a cursor under a named saved-cursor ref.
1545
- *
1546
- * Serializes the cursor as JSON, stores it as a Git blob, and points
1547
- * the named saved-cursor ref at that blob.
1548
- *
1549
- * @param {Persistence} persistence - GraphPersistencePort adapter
1550
- * @param {string} graphName - Name of the WARP graph
1551
- * @param {string} name - Saved cursor name
1552
- * @param {CursorBlob} cursor - Cursor state to persist
1553
- * @returns {Promise<void>}
1554
- */
1555
- async function writeSavedCursor(persistence, graphName, name, cursor) {
1556
- const ref = buildCursorSavedRef(graphName, name);
1557
- const json = JSON.stringify(cursor);
1558
- const oid = await persistence.writeBlob(Buffer.from(json, 'utf8'));
1559
- await persistence.updateRef(ref, oid);
1560
- }
1561
-
1562
- /**
1563
- * Deletes a named saved cursor from Git ref storage.
1564
- *
1565
- * No-op if the named cursor does not exist.
1566
- *
1567
- * @param {Persistence} persistence - GraphPersistencePort adapter
1568
- * @param {string} graphName - Name of the WARP graph
1569
- * @param {string} name - Saved cursor name to delete
1570
- * @returns {Promise<void>}
1571
- */
1572
- async function deleteSavedCursor(persistence, graphName, name) {
1573
- const ref = buildCursorSavedRef(graphName, name);
1574
- const exists = await persistence.readRef(ref);
1575
- if (exists) {
1576
- await persistence.deleteRef(ref);
1577
- }
1578
- }
1579
-
1580
- /**
1581
- * Lists all saved cursors for a graph, reading each blob to include full cursor state.
1582
- *
1583
- * @param {Persistence} persistence - GraphPersistencePort adapter
1584
- * @param {string} graphName - Name of the WARP graph
1585
- * @returns {Promise<Array<{name: string, tick: number, mode?: string}>>} Array of saved cursors with their names
1586
- * @throws {Error} If any stored blob is corrupted or not valid JSON
1587
- */
1588
- async function listSavedCursors(persistence, graphName) {
1589
- const prefix = buildCursorSavedPrefix(graphName);
1590
- const refs = await persistence.listRefs(prefix);
1591
- const cursors = [];
1592
- for (const ref of refs) {
1593
- const name = ref.slice(prefix.length);
1594
- if (name) {
1595
- const oid = await persistence.readRef(ref);
1596
- if (oid) {
1597
- const buf = await persistence.readBlob(oid);
1598
- const cursor = parseCursorBlob(buf, `saved cursor '${name}'`);
1599
- cursors.push({ name, ...cursor });
1600
- }
1601
- }
1602
- }
1603
- return cursors;
1604
- }
1605
-
1606
- // ============================================================================
1607
- // Seek Arg Parser
1608
- // ============================================================================
1609
-
1610
- /**
1611
- * @param {string} arg
1612
- * @param {SeekSpec} spec
1613
- */
1614
- function handleSeekBooleanFlag(arg, spec) {
1615
- if (arg === '--clear-cache') {
1616
- if (spec.action !== 'status') {
1617
- throw usageError('--clear-cache cannot be combined with other seek flags');
1618
- }
1619
- spec.action = 'clear-cache';
1620
- } else if (arg === '--no-persistent-cache') {
1621
- spec.noPersistentCache = true;
1622
- } else if (arg === '--diff') {
1623
- spec.diff = true;
1624
- }
1625
- }
1626
-
1627
- /**
1628
- * Parses --diff-limit / --diff-limit=N into the seek spec.
1629
- * @param {string} arg
1630
- * @param {string[]} args
1631
- * @param {number} i
1632
- * @param {SeekSpec} spec
1633
- */
1634
- function handleDiffLimitFlag(arg, args, i, spec) {
1635
- let raw;
1636
- if (arg.startsWith('--diff-limit=')) {
1637
- raw = arg.slice('--diff-limit='.length);
1638
- } else {
1639
- raw = args[i + 1];
1640
- if (raw === undefined) {
1641
- throw usageError('Missing value for --diff-limit');
1642
- }
1643
- }
1644
- const n = Number(raw);
1645
- if (!Number.isFinite(n) || !Number.isInteger(n) || n < 1) {
1646
- throw usageError(`Invalid --diff-limit value: ${raw}. Must be a positive integer.`);
1647
- }
1648
- spec.diffLimit = n;
1649
- }
1650
-
1651
- /**
1652
- * Parses a named action flag (--save, --load, --drop) with its value.
1653
- * @param {string} flagName - e.g. 'save'
1654
- * @param {string} arg - Current arg token
1655
- * @param {string[]} args - All args
1656
- * @param {number} i - Current index
1657
- * @param {SeekSpec} spec
1658
- * @returns {number} Number of extra args consumed (0 or 1)
1659
- */
1660
- function parseSeekNamedAction(flagName, arg, args, i, spec) {
1661
- if (spec.action !== 'status') {
1662
- throw usageError(`--${flagName} cannot be combined with other seek flags`);
1663
- }
1664
- spec.action = flagName;
1665
- if (arg === `--${flagName}`) {
1666
- const val = args[i + 1];
1667
- if (val === undefined || val.startsWith('-')) {
1668
- throw usageError(`Missing name for --${flagName}`);
1669
- }
1670
- spec.name = val;
1671
- return 1;
1672
- }
1673
- spec.name = arg.slice(`--${flagName}=`.length);
1674
- if (!spec.name) {
1675
- throw usageError(`Missing name for --${flagName}`);
1676
- }
1677
- return 0;
1678
- }
1679
-
1680
- /**
1681
- * Parses CLI arguments for the `seek` command into a structured spec.
1682
- * @param {string[]} args - Raw CLI arguments following the `seek` subcommand
1683
- * @returns {SeekSpec} Parsed spec
1684
- */
1685
- function parseSeekArgs(args) {
1686
- /** @type {SeekSpec} */
1687
- const spec = {
1688
- action: 'status', // status, tick, latest, save, load, list, drop, clear-cache
1689
- tickValue: null,
1690
- name: null,
1691
- noPersistentCache: false,
1692
- diff: false,
1693
- diffLimit: 2000,
1694
- };
1695
- let diffLimitProvided = false;
1696
-
1697
- for (let i = 0; i < args.length; i++) {
1698
- const arg = args[i];
1699
-
1700
- if (arg === '--tick') {
1701
- if (spec.action !== 'status') {
1702
- throw usageError('--tick cannot be combined with other seek flags');
1703
- }
1704
- spec.action = 'tick';
1705
- const val = args[i + 1];
1706
- if (val === undefined) {
1707
- throw usageError('Missing value for --tick');
1708
- }
1709
- spec.tickValue = val;
1710
- i += 1;
1711
- } else if (arg.startsWith('--tick=')) {
1712
- if (spec.action !== 'status') {
1713
- throw usageError('--tick cannot be combined with other seek flags');
1714
- }
1715
- spec.action = 'tick';
1716
- spec.tickValue = arg.slice('--tick='.length);
1717
- } else if (arg === '--latest') {
1718
- if (spec.action !== 'status') {
1719
- throw usageError('--latest cannot be combined with other seek flags');
1720
- }
1721
- spec.action = 'latest';
1722
- } else if (arg === '--save' || arg.startsWith('--save=')) {
1723
- i += parseSeekNamedAction('save', arg, args, i, spec);
1724
- } else if (arg === '--load' || arg.startsWith('--load=')) {
1725
- i += parseSeekNamedAction('load', arg, args, i, spec);
1726
- } else if (arg === '--list') {
1727
- if (spec.action !== 'status') {
1728
- throw usageError('--list cannot be combined with other seek flags');
1729
- }
1730
- spec.action = 'list';
1731
- } else if (arg === '--drop' || arg.startsWith('--drop=')) {
1732
- i += parseSeekNamedAction('drop', arg, args, i, spec);
1733
- } else if (arg === '--clear-cache' || arg === '--no-persistent-cache' || arg === '--diff') {
1734
- handleSeekBooleanFlag(arg, spec);
1735
- } else if (arg === '--diff-limit' || arg.startsWith('--diff-limit=')) {
1736
- handleDiffLimitFlag(arg, args, i, spec);
1737
- diffLimitProvided = true;
1738
- if (arg === '--diff-limit') {
1739
- i += 1;
1740
- }
1741
- } else if (arg.startsWith('-')) {
1742
- throw usageError(`Unknown seek option: ${arg}`);
1743
- }
1744
- }
1745
-
1746
- // --diff is only meaningful for actions that navigate to a tick
1747
- const DIFF_ACTIONS = new Set(['tick', 'latest', 'load']);
1748
- if (spec.diff && !DIFF_ACTIONS.has(spec.action)) {
1749
- throw usageError(`--diff cannot be used with --${spec.action}`);
1750
- }
1751
- if (diffLimitProvided && !spec.diff) {
1752
- throw usageError('--diff-limit requires --diff');
1753
- }
1754
-
1755
- return spec;
1756
- }
1757
-
1758
- /**
1759
- * Resolves a tick value (absolute or relative +N/-N) against available ticks.
1760
- *
1761
- * For relative values, steps through the sorted tick array (with 0 prepended
1762
- * as a virtual "empty state" position) by the given delta from the current
1763
- * position. For absolute values, clamps to maxTick.
1764
- *
1765
- * @private
1766
- * @param {string} tickValue - Raw tick string from CLI args (e.g. "5", "+1", "-2")
1767
- * @param {number|null} currentTick - Current cursor tick, or null if no active cursor
1768
- * @param {number[]} ticks - Sorted ascending array of available Lamport ticks
1769
- * @param {number} maxTick - Maximum tick across all writers
1770
- * @returns {number} Resolved tick value (clamped to valid range)
1771
- * @throws {CliError} If tickValue is not a valid integer or relative delta
1772
- */
1773
- function resolveTickValue(tickValue, currentTick, ticks, maxTick) {
1774
- // Relative: +N or -N
1775
- if (tickValue.startsWith('+') || tickValue.startsWith('-')) {
1776
- const delta = parseInt(tickValue, 10);
1777
- if (!Number.isInteger(delta)) {
1778
- throw usageError(`Invalid tick delta: ${tickValue}`);
1779
- }
1780
- const base = currentTick ?? 0;
1781
-
1782
- // Find the current position in sorted ticks, then step by delta
1783
- // Include tick 0 as a virtual "empty state" position (avoid duplicating if already present)
1784
- const allPoints = (ticks.length > 0 && ticks[0] === 0) ? [...ticks] : [0, ...ticks];
1785
- const currentIdx = allPoints.indexOf(base);
1786
- const startIdx = currentIdx === -1 ? 0 : currentIdx;
1787
- const targetIdx = Math.max(0, Math.min(allPoints.length - 1, startIdx + delta));
1788
- return allPoints[targetIdx];
1789
- }
1790
-
1791
- // Absolute
1792
- const n = parseInt(tickValue, 10);
1793
- if (!Number.isInteger(n) || n < 0) {
1794
- throw usageError(`Invalid tick value: ${tickValue}. Must be a non-negative integer, or +N/-N for relative.`);
1795
- }
1796
-
1797
- // Clamp to maxTick
1798
- return Math.min(n, maxTick);
1799
- }
1800
-
1801
- // ============================================================================
1802
- // Seek Handler
1803
- // ============================================================================
1804
-
1805
- /**
1806
- * @param {WarpGraphInstance} graph
1807
- * @param {Persistence} persistence
1808
- * @param {string} graphName
1809
- * @param {SeekSpec} seekSpec
1810
- */
1811
- function wireSeekCache(graph, persistence, graphName, seekSpec) {
1812
- if (seekSpec.noPersistentCache) {
1813
- return;
1814
- }
1815
- graph.setSeekCache(new CasSeekCacheAdapter({
1816
- persistence,
1817
- plumbing: persistence.plumbing,
1818
- graphName,
1819
- }));
1820
- }
1821
-
1822
- /**
1823
- * Handles the `git warp seek` command across all sub-actions.
1824
- * @param {{options: CliOptions, args: string[]}} params
1825
- * @returns {Promise<{payload: *, exitCode: number}>}
1826
- */
1827
- async function handleSeek({ options, args }) {
1828
- const seekSpec = parseSeekArgs(args);
1829
- const { graph, graphName, persistence } = await openGraph(options);
1830
- void wireSeekCache(graph, persistence, graphName, seekSpec);
1831
-
1832
- // Handle --clear-cache before discovering ticks (no materialization needed)
1833
- if (seekSpec.action === 'clear-cache') {
1834
- if (graph.seekCache) {
1835
- await graph.seekCache.clear();
1836
- }
1837
- return {
1838
- payload: { graph: graphName, action: 'clear-cache', message: 'Seek cache cleared.' },
1839
- exitCode: EXIT_CODES.OK,
1840
- };
1841
- }
1842
-
1843
- const activeCursor = await readActiveCursor(persistence, graphName);
1844
- const { ticks, maxTick, perWriter } = await graph.discoverTicks();
1845
- const frontierHash = computeFrontierHash(perWriter);
1846
- if (seekSpec.action === 'list') {
1847
- const saved = await listSavedCursors(persistence, graphName);
1848
- return {
1849
- payload: {
1850
- graph: graphName,
1851
- action: 'list',
1852
- cursors: saved,
1853
- activeTick: activeCursor ? activeCursor.tick : null,
1854
- maxTick,
1855
- },
1856
- exitCode: EXIT_CODES.OK,
1857
- };
1858
- }
1859
- if (seekSpec.action === 'drop') {
1860
- const dropName = /** @type {string} */ (seekSpec.name);
1861
- const existing = await readSavedCursor(persistence, graphName, dropName);
1862
- if (!existing) {
1863
- throw notFoundError(`Saved cursor not found: ${dropName}`);
1864
- }
1865
- await deleteSavedCursor(persistence, graphName, dropName);
1866
- return {
1867
- payload: {
1868
- graph: graphName,
1869
- action: 'drop',
1870
- name: seekSpec.name,
1871
- tick: existing.tick,
1872
- },
1873
- exitCode: EXIT_CODES.OK,
1874
- };
1875
- }
1876
- if (seekSpec.action === 'latest') {
1877
- const prevTick = activeCursor ? activeCursor.tick : null;
1878
- let sdResult = null;
1879
- if (seekSpec.diff) {
1880
- sdResult = await computeStructuralDiff({ graph, prevTick, currentTick: maxTick, diffLimit: seekSpec.diffLimit });
1881
- }
1882
- await clearActiveCursor(persistence, graphName);
1883
- // When --diff already materialized at maxTick, skip redundant re-materialize
1884
- if (!sdResult) {
1885
- await graph.materialize({ ceiling: maxTick });
1886
- }
1887
- const nodes = await graph.getNodes();
1888
- const edges = await graph.getEdges();
1889
- const diff = computeSeekStateDiff(activeCursor, { nodes: nodes.length, edges: edges.length }, frontierHash);
1890
- const tickReceipt = await buildTickReceipt({ tick: maxTick, perWriter, graph });
1891
- return {
1892
- payload: {
1893
- graph: graphName,
1894
- action: 'latest',
1895
- tick: maxTick,
1896
- maxTick,
1897
- ticks,
1898
- nodes: nodes.length,
1899
- edges: edges.length,
1900
- perWriter: serializePerWriter(perWriter),
1901
- patchCount: countPatchesAtTick(maxTick, perWriter),
1902
- diff,
1903
- tickReceipt,
1904
- cursor: { active: false },
1905
- ...sdResult,
1906
- },
1907
- exitCode: EXIT_CODES.OK,
1908
- };
1909
- }
1910
- if (seekSpec.action === 'save') {
1911
- if (!activeCursor) {
1912
- throw usageError('No active cursor to save. Use --tick first.');
1913
- }
1914
- await writeSavedCursor(persistence, graphName, /** @type {string} */ (seekSpec.name), activeCursor);
1915
- return {
1916
- payload: {
1917
- graph: graphName,
1918
- action: 'save',
1919
- name: seekSpec.name,
1920
- tick: activeCursor.tick,
1921
- },
1922
- exitCode: EXIT_CODES.OK,
1923
- };
1924
- }
1925
- if (seekSpec.action === 'load') {
1926
- const loadName = /** @type {string} */ (seekSpec.name);
1927
- const saved = await readSavedCursor(persistence, graphName, loadName);
1928
- if (!saved) {
1929
- throw notFoundError(`Saved cursor not found: ${loadName}`);
1930
- }
1931
- const prevTick = activeCursor ? activeCursor.tick : null;
1932
- let sdResult = null;
1933
- if (seekSpec.diff) {
1934
- sdResult = await computeStructuralDiff({ graph, prevTick, currentTick: saved.tick, diffLimit: seekSpec.diffLimit });
1935
- }
1936
- // When --diff already materialized at saved.tick, skip redundant call
1937
- if (!sdResult) {
1938
- await graph.materialize({ ceiling: saved.tick });
1939
- }
1940
- const nodes = await graph.getNodes();
1941
- const edges = await graph.getEdges();
1942
- await writeActiveCursor(persistence, graphName, { tick: saved.tick, mode: saved.mode ?? 'lamport', nodes: nodes.length, edges: edges.length, frontierHash });
1943
- const diff = computeSeekStateDiff(activeCursor, { nodes: nodes.length, edges: edges.length }, frontierHash);
1944
- const tickReceipt = await buildTickReceipt({ tick: saved.tick, perWriter, graph });
1945
- return {
1946
- payload: {
1947
- graph: graphName,
1948
- action: 'load',
1949
- name: seekSpec.name,
1950
- tick: saved.tick,
1951
- maxTick,
1952
- ticks,
1953
- nodes: nodes.length,
1954
- edges: edges.length,
1955
- perWriter: serializePerWriter(perWriter),
1956
- patchCount: countPatchesAtTick(saved.tick, perWriter),
1957
- diff,
1958
- tickReceipt,
1959
- cursor: { active: true, mode: saved.mode, tick: saved.tick, maxTick, name: seekSpec.name },
1960
- ...sdResult,
1961
- },
1962
- exitCode: EXIT_CODES.OK,
1963
- };
1964
- }
1965
- if (seekSpec.action === 'tick') {
1966
- const currentTick = activeCursor ? activeCursor.tick : null;
1967
- const resolvedTick = resolveTickValue(/** @type {string} */ (seekSpec.tickValue), currentTick, ticks, maxTick);
1968
- let sdResult = null;
1969
- if (seekSpec.diff) {
1970
- sdResult = await computeStructuralDiff({ graph, prevTick: currentTick, currentTick: resolvedTick, diffLimit: seekSpec.diffLimit });
1971
- }
1972
- // When --diff already materialized at resolvedTick, skip redundant call
1973
- if (!sdResult) {
1974
- await graph.materialize({ ceiling: resolvedTick });
1975
- }
1976
- const nodes = await graph.getNodes();
1977
- const edges = await graph.getEdges();
1978
- await writeActiveCursor(persistence, graphName, { tick: resolvedTick, mode: 'lamport', nodes: nodes.length, edges: edges.length, frontierHash });
1979
- const diff = computeSeekStateDiff(activeCursor, { nodes: nodes.length, edges: edges.length }, frontierHash);
1980
- const tickReceipt = await buildTickReceipt({ tick: resolvedTick, perWriter, graph });
1981
- return {
1982
- payload: {
1983
- graph: graphName,
1984
- action: 'tick',
1985
- tick: resolvedTick,
1986
- maxTick,
1987
- ticks,
1988
- nodes: nodes.length,
1989
- edges: edges.length,
1990
- perWriter: serializePerWriter(perWriter),
1991
- patchCount: countPatchesAtTick(resolvedTick, perWriter),
1992
- diff,
1993
- tickReceipt,
1994
- cursor: { active: true, mode: 'lamport', tick: resolvedTick, maxTick, name: 'active' },
1995
- ...sdResult,
1996
- },
1997
- exitCode: EXIT_CODES.OK,
1998
- };
1999
- }
2000
-
2001
- // status (bare seek)
2002
- return await handleSeekStatus({ graph, graphName, persistence, activeCursor, ticks, maxTick, perWriter, frontierHash });
2003
- }
2004
-
2005
- /**
2006
- * Handles the `status` sub-action of `seek` (bare seek with no action flag).
2007
- * @param {{graph: WarpGraphInstance, graphName: string, persistence: Persistence, activeCursor: CursorBlob|null, ticks: number[], maxTick: number, perWriter: Map<string, WriterTickInfo>, frontierHash: string}} params
2008
- * @returns {Promise<{payload: *, exitCode: number}>}
2009
- */
2010
- async function handleSeekStatus({ graph, graphName, persistence, activeCursor, ticks, maxTick, perWriter, frontierHash }) {
2011
- if (activeCursor) {
2012
- await graph.materialize({ ceiling: activeCursor.tick });
2013
- const nodes = await graph.getNodes();
2014
- const edges = await graph.getEdges();
2015
- const prevCounts = readSeekCounts(activeCursor);
2016
- const prevFrontierHash = typeof activeCursor.frontierHash === 'string' ? activeCursor.frontierHash : null;
2017
- if (prevCounts.nodes === null || prevCounts.edges === null || prevCounts.nodes !== nodes.length || prevCounts.edges !== edges.length || prevFrontierHash !== frontierHash) {
2018
- await writeActiveCursor(persistence, graphName, { tick: activeCursor.tick, mode: activeCursor.mode ?? 'lamport', nodes: nodes.length, edges: edges.length, frontierHash });
2019
- }
2020
- const diff = computeSeekStateDiff(activeCursor, { nodes: nodes.length, edges: edges.length }, frontierHash);
2021
- const tickReceipt = await buildTickReceipt({ tick: activeCursor.tick, perWriter, graph });
2022
- return {
2023
- payload: {
2024
- graph: graphName,
2025
- action: 'status',
2026
- tick: activeCursor.tick,
2027
- maxTick,
2028
- ticks,
2029
- nodes: nodes.length,
2030
- edges: edges.length,
2031
- perWriter: serializePerWriter(perWriter),
2032
- patchCount: countPatchesAtTick(activeCursor.tick, perWriter),
2033
- diff,
2034
- tickReceipt,
2035
- cursor: { active: true, mode: activeCursor.mode, tick: activeCursor.tick, maxTick, name: 'active' },
2036
- },
2037
- exitCode: EXIT_CODES.OK,
2038
- };
2039
- }
2040
- await graph.materialize();
2041
- const nodes = await graph.getNodes();
2042
- const edges = await graph.getEdges();
2043
- const tickReceipt = await buildTickReceipt({ tick: maxTick, perWriter, graph });
2044
- return {
2045
- payload: {
2046
- graph: graphName,
2047
- action: 'status',
2048
- tick: maxTick,
2049
- maxTick,
2050
- ticks,
2051
- nodes: nodes.length,
2052
- edges: edges.length,
2053
- perWriter: serializePerWriter(perWriter),
2054
- patchCount: countPatchesAtTick(maxTick, perWriter),
2055
- diff: null,
2056
- tickReceipt,
2057
- cursor: { active: false },
2058
- },
2059
- exitCode: EXIT_CODES.OK,
2060
- };
2061
- }
2062
-
2063
- /**
2064
- * Converts the per-writer Map from discoverTicks() into a plain object for JSON output.
2065
- *
2066
- * @param {Map<string, WriterTickInfo>} perWriter - Per-writer tick data
2067
- * @returns {Record<string, WriterTickInfo>} Plain object keyed by writer ID
2068
- */
2069
- function serializePerWriter(perWriter) {
2070
- /** @type {Record<string, WriterTickInfo>} */
2071
- const result = {};
2072
- for (const [writerId, info] of perWriter) {
2073
- result[writerId] = { ticks: info.ticks, tipSha: info.tipSha, tickShas: info.tickShas };
2074
- }
2075
- return result;
2076
- }
2077
-
2078
- /**
2079
- * Counts the total number of patches across all writers at or before the given tick.
2080
- *
2081
- * @param {number} tick - Lamport tick ceiling (inclusive)
2082
- * @param {Map<string, WriterTickInfo>} perWriter - Per-writer tick data
2083
- * @returns {number} Total patch count at or before the given tick
2084
- */
2085
- function countPatchesAtTick(tick, perWriter) {
2086
- let count = 0;
2087
- for (const [, info] of perWriter) {
2088
- for (const t of info.ticks) {
2089
- if (t <= tick) {
2090
- count++;
2091
- }
2092
- }
2093
- }
2094
- return count;
2095
- }
2096
-
2097
- /**
2098
- * Computes a stable fingerprint of the current graph frontier (writer tips).
2099
- *
2100
- * Used to suppress seek diffs when graph history may have changed since the
2101
- * previous cursor snapshot (e.g. new writers/patches, rewritten refs).
2102
- *
2103
- * @param {Map<string, WriterTickInfo>} perWriter - Per-writer metadata from discoverTicks()
2104
- * @returns {string} Hex digest of the frontier fingerprint
2105
- */
2106
- function computeFrontierHash(perWriter) {
2107
- /** @type {Record<string, string|null>} */
2108
- const tips = {};
2109
- for (const [writerId, info] of perWriter) {
2110
- tips[writerId] = info?.tipSha || null;
2111
- }
2112
- return crypto.createHash('sha256').update(stableStringify(tips)).digest('hex');
2113
- }
2114
-
2115
- /**
2116
- * Reads cached seek state counts from a cursor blob.
2117
- *
2118
- * Counts may be missing for older cursors (pre-diff support). In that case
2119
- * callers should treat the counts as unknown and suppress diffs.
2120
- *
2121
- * @param {CursorBlob|null} cursor - Parsed cursor blob object
2122
- * @returns {{nodes: number|null, edges: number|null}} Parsed counts
2123
- */
2124
- function readSeekCounts(cursor) {
2125
- if (!cursor || typeof cursor !== 'object') {
2126
- return { nodes: null, edges: null };
2127
- }
2128
-
2129
- const nodes = typeof cursor.nodes === 'number' && Number.isFinite(cursor.nodes) ? cursor.nodes : null;
2130
- const edges = typeof cursor.edges === 'number' && Number.isFinite(cursor.edges) ? cursor.edges : null;
2131
- return { nodes, edges };
2132
- }
2133
-
2134
- /**
2135
- * Computes node/edge deltas between the current seek position and the previous cursor.
2136
- *
2137
- * Returns null if the previous cursor is missing cached counts.
2138
- *
2139
- * @param {CursorBlob|null} prevCursor - Cursor object read before updating the position
2140
- * @param {{nodes: number, edges: number}} next - Current materialized counts
2141
- * @param {string} frontierHash - Frontier fingerprint of the current graph
2142
- * @returns {{nodes: number, edges: number}|null} Diff object or null when unknown
2143
- */
2144
- function computeSeekStateDiff(prevCursor, next, frontierHash) {
2145
- const prev = readSeekCounts(prevCursor);
2146
- if (prev.nodes === null || prev.edges === null) {
2147
- return null;
2148
- }
2149
- const prevFrontierHash = typeof prevCursor?.frontierHash === 'string' ? prevCursor.frontierHash : null;
2150
- if (!prevFrontierHash || prevFrontierHash !== frontierHash) {
2151
- return null;
2152
- }
2153
- return {
2154
- nodes: next.nodes - prev.nodes,
2155
- edges: next.edges - prev.edges,
2156
- };
2157
- }
2158
-
2159
- /**
2160
- * Builds a per-writer operation summary for patches at an exact tick.
2161
- *
2162
- * Uses discoverTicks() tickShas mapping to locate patch SHAs, then loads and
2163
- * summarizes patch ops. Typically only a handful of writers have a patch at any
2164
- * single Lamport tick.
2165
- *
2166
- * @param {{tick: number, perWriter: Map<string, WriterTickInfo>, graph: WarpGraphInstance}} params
2167
- * @returns {Promise<Record<string, {sha: string, opSummary: *}>|null>} Map of writerId to { sha, opSummary }, or null if empty
2168
- */
2169
- async function buildTickReceipt({ tick, perWriter, graph }) {
2170
- if (!Number.isInteger(tick) || tick <= 0) {
2171
- return null;
2172
- }
2173
-
2174
- /** @type {Record<string, {sha: string, opSummary: *}>} */
2175
- const receipt = {};
2176
-
2177
- for (const [writerId, info] of perWriter) {
2178
- const sha = /** @type {*} */ (info?.tickShas)?.[tick]; // TODO(ts-cleanup): type CLI payload
2179
- if (!sha) {
2180
- continue;
2181
- }
2182
-
2183
- const patch = await graph.loadPatchBySha(sha);
2184
- const ops = Array.isArray(patch?.ops) ? patch.ops : [];
2185
- receipt[writerId] = { sha, opSummary: summarizeOps(ops) };
2186
- }
2187
-
2188
- return Object.keys(receipt).length > 0 ? receipt : null;
2189
- }
2190
-
2191
- /**
2192
- * Computes a structural diff between the state at a previous tick and
2193
- * the state at the current tick.
2194
- *
2195
- * Materializes the baseline tick first, snapshots the state, then
2196
- * materializes the target tick and calls diffStates() between the two.
2197
- * Applies diffLimit truncation when the total change count exceeds the cap.
2198
- *
2199
- * @param {{graph: WarpGraphInstance, prevTick: number|null, currentTick: number, diffLimit: number}} params
2200
- * @returns {Promise<{structuralDiff: *, diffBaseline: string, baselineTick: number|null, truncated: boolean, totalChanges: number, shownChanges: number}>}
2201
- */
2202
- async function computeStructuralDiff({ graph, prevTick, currentTick, diffLimit }) {
2203
- let beforeState = null;
2204
- let diffBaseline = 'empty';
2205
- let baselineTick = null;
2206
-
2207
- // Short-circuit: same tick produces an empty diff
2208
- if (prevTick !== null && prevTick === currentTick) {
2209
- const empty = { nodes: { added: [], removed: [] }, edges: { added: [], removed: [] }, props: { set: [], removed: [] } };
2210
- return { structuralDiff: empty, diffBaseline: 'tick', baselineTick: prevTick, truncated: false, totalChanges: 0, shownChanges: 0 };
2211
- }
2212
-
2213
- if (prevTick !== null && prevTick > 0) {
2214
- await graph.materialize({ ceiling: prevTick });
2215
- beforeState = await graph.getStateSnapshot();
2216
- diffBaseline = 'tick';
2217
- baselineTick = prevTick;
2218
- }
2219
-
2220
- await graph.materialize({ ceiling: currentTick });
2221
- const afterState = /** @type {*} */ (await graph.getStateSnapshot()); // TODO(ts-cleanup): narrow WarpStateV5
2222
- const diff = diffStates(beforeState, afterState);
2223
-
2224
- return applyDiffLimit(diff, diffBaseline, baselineTick, diffLimit);
2225
- }
2226
-
2227
- /**
2228
- * Applies truncation limits to a structural diff result.
2229
- *
2230
- * @param {*} diff
2231
- * @param {string} diffBaseline
2232
- * @param {number|null} baselineTick
2233
- * @param {number} diffLimit
2234
- * @returns {{structuralDiff: *, diffBaseline: string, baselineTick: number|null, truncated: boolean, totalChanges: number, shownChanges: number}}
2235
- */
2236
- function applyDiffLimit(diff, diffBaseline, baselineTick, diffLimit) {
2237
- const totalChanges =
2238
- diff.nodes.added.length + diff.nodes.removed.length +
2239
- diff.edges.added.length + diff.edges.removed.length +
2240
- diff.props.set.length + diff.props.removed.length;
2241
-
2242
- if (totalChanges <= diffLimit) {
2243
- return { structuralDiff: diff, diffBaseline, baselineTick, truncated: false, totalChanges, shownChanges: totalChanges };
2244
- }
2245
-
2246
- // Truncate sequentially (nodes → edges → props), keeping sort order within each category
2247
- let remaining = diffLimit;
2248
- const cap = (/** @type {any[]} */ arr) => {
2249
- const take = Math.min(arr.length, remaining);
2250
- remaining -= take;
2251
- return arr.slice(0, take);
2252
- };
2253
-
2254
- const capped = {
2255
- nodes: { added: cap(diff.nodes.added), removed: cap(diff.nodes.removed) },
2256
- edges: { added: cap(diff.edges.added), removed: cap(diff.edges.removed) },
2257
- props: { set: cap(diff.props.set), removed: cap(diff.props.removed) },
2258
- };
2259
-
2260
- const shownChanges = diffLimit - remaining;
2261
- return { structuralDiff: capped, diffBaseline, baselineTick, truncated: true, totalChanges, shownChanges };
2262
- }
2263
-
2264
- /**
2265
- * Reads the active cursor and sets `_seekCeiling` on the graph instance
2266
- * so that subsequent materialize calls respect the time-travel boundary.
2267
- *
2268
- * Called by non-seek commands (query, path, check, etc.) that should
2269
- * honour an active seek cursor.
2270
- *
2271
- * @param {WarpGraphInstance} graph - WarpGraph instance
2272
- * @param {Persistence} persistence - GraphPersistencePort adapter
2273
- * @param {string} graphName - Name of the WARP graph
2274
- * @returns {Promise<{active: boolean, tick: number|null, maxTick: number|null}>} Cursor info — maxTick is always null; non-seek commands intentionally skip discoverTicks() for performance
2275
- */
2276
- async function applyCursorCeiling(graph, persistence, graphName) {
2277
- const cursor = await readActiveCursor(persistence, graphName);
2278
- if (cursor) {
2279
- graph._seekCeiling = cursor.tick;
2280
- return { active: true, tick: cursor.tick, maxTick: null };
2281
- }
2282
- return { active: false, tick: null, maxTick: null };
2283
- }
2284
-
2285
- /**
2286
- * Prints a seek cursor warning banner to stderr when a cursor is active.
2287
- *
2288
- * No-op if the cursor is not active.
2289
- *
2290
- * Non-seek commands (query, path, check, history, materialize) pass null for
2291
- * maxTick to avoid the cost of discoverTicks(); the banner then omits the
2292
- * "of {maxTick}" suffix. Only the seek handler itself populates maxTick.
2293
- *
2294
- * @param {{active: boolean, tick: number|null, maxTick: number|null}} cursorInfo - Result from applyCursorCeiling
2295
- * @param {number|null} maxTick - Maximum Lamport tick (from discoverTicks), or null if unknown
2296
- * @returns {void}
2297
- */
2298
- function emitCursorWarning(cursorInfo, maxTick) {
2299
- if (cursorInfo.active) {
2300
- const maxLabel = maxTick !== null && maxTick !== undefined ? ` of ${maxTick}` : '';
2301
- process.stderr.write(`\u26A0 seek active (tick ${cursorInfo.tick}${maxLabel}) \u2014 run "git warp seek --latest" to return to present\n`);
2302
- }
2303
- }
2304
-
2305
- /**
2306
- * @param {{options: CliOptions, args: string[]}} params
2307
- * @returns {Promise<{payload: *, exitCode: number}>}
2308
- */
2309
- async function handleView({ options, args }) {
2310
- if (!process.stdin.isTTY || !process.stdout.isTTY) {
2311
- throw usageError('view command requires an interactive terminal (TTY)');
2312
- }
2313
-
2314
- const viewMode = (args[0] === '--list' || args[0] === 'list') ? 'list'
2315
- : (args[0] === '--log' || args[0] === 'log') ? 'log'
2316
- : 'list';
2317
-
2318
- try {
2319
- // @ts-expect-error — optional peer dependency, may not be installed
2320
- const { startTui } = await import('@git-stunts/git-warp-tui');
2321
- await startTui({
2322
- repo: options.repo || '.',
2323
- graph: options.graph || 'default',
2324
- mode: viewMode,
2325
- });
2326
- } catch (/** @type {*} */ err) { // TODO(ts-cleanup): type error
2327
- if (err.code === 'ERR_MODULE_NOT_FOUND' || (err.message && err.message.includes('Cannot find module'))) {
2328
- throw usageError(
2329
- 'Interactive TUI requires @git-stunts/git-warp-tui.\n' +
2330
- ' Install with: npm install -g @git-stunts/git-warp-tui',
2331
- );
2332
- }
2333
- throw err;
2334
- }
2335
- return { payload: undefined, exitCode: 0 };
2336
- }
2337
-
2338
- /** @type {Map<string, Function>} */
2339
- const COMMANDS = new Map(/** @type {[string, Function][]} */ ([
2340
- ['info', handleInfo],
2341
- ['query', handleQuery],
2342
- ['path', handlePath],
2343
- ['history', handleHistory],
2344
- ['check', handleCheck],
2345
- ['materialize', handleMaterialize],
2346
- ['seek', handleSeek],
2347
- ['view', handleView],
2348
- ['install-hooks', handleInstallHooks],
2349
- ]));
10
+ const VIEW_SUPPORTED_COMMANDS = ['info', 'check', 'history', 'path', 'materialize', 'query', 'seek'];
2350
11
 
2351
12
  /**
2352
13
  * CLI entry point. Parses arguments, dispatches to the appropriate command handler,
@@ -2354,7 +15,7 @@ const COMMANDS = new Map(/** @type {[string, Function][]} */ ([
2354
15
  * @returns {Promise<void>}
2355
16
  */
2356
17
  async function main() {
2357
- const { options, positionals } = parseArgs(process.argv.slice(2));
18
+ const { options, command, commandArgs } = parseArgs(process.argv.slice(2));
2358
19
 
2359
20
  if (options.help) {
2360
21
  process.stdout.write(HELP_TEXT);
@@ -2372,7 +33,6 @@ async function main() {
2372
33
  throw usageError('--json and --ndjson are mutually exclusive');
2373
34
  }
2374
35
 
2375
- const command = positionals[0];
2376
36
  if (!command) {
2377
37
  process.stderr.write(HELP_TEXT);
2378
38
  process.exitCode = EXIT_CODES.USAGE;
@@ -2384,14 +44,13 @@ async function main() {
2384
44
  throw usageError(`Unknown command: ${command}`);
2385
45
  }
2386
46
 
2387
- const VIEW_SUPPORTED_COMMANDS = ['info', 'check', 'history', 'path', 'materialize', 'query', 'seek'];
2388
47
  if (options.view && !VIEW_SUPPORTED_COMMANDS.includes(command)) {
2389
48
  throw usageError(`--view is not supported for '${command}'. Supported commands: ${VIEW_SUPPORTED_COMMANDS.join(', ')}`);
2390
49
  }
2391
50
 
2392
51
  const result = await /** @type {Function} */ (handler)({
2393
52
  command,
2394
- args: positionals.slice(1),
53
+ args: commandArgs,
2395
54
  options,
2396
55
  });
2397
56