@git-stunts/git-warp 10.7.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 (71) 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 +214 -0
  24. package/bin/presenters/json.js +66 -0
  25. package/bin/presenters/text.js +543 -0
  26. package/bin/warp-graph.js +19 -2824
  27. package/index.d.ts +32 -2
  28. package/index.js +2 -0
  29. package/package.json +9 -7
  30. package/src/domain/WarpGraph.js +106 -3252
  31. package/src/domain/errors/QueryError.js +2 -2
  32. package/src/domain/errors/TrustError.js +29 -0
  33. package/src/domain/errors/index.js +1 -0
  34. package/src/domain/services/AuditMessageCodec.js +137 -0
  35. package/src/domain/services/AuditReceiptService.js +471 -0
  36. package/src/domain/services/AuditVerifierService.js +693 -0
  37. package/src/domain/services/HttpSyncServer.js +36 -22
  38. package/src/domain/services/MessageCodecInternal.js +3 -0
  39. package/src/domain/services/MessageSchemaDetector.js +2 -2
  40. package/src/domain/services/SyncAuthService.js +69 -3
  41. package/src/domain/services/WarpMessageCodec.js +4 -1
  42. package/src/domain/trust/TrustCanonical.js +42 -0
  43. package/src/domain/trust/TrustCrypto.js +111 -0
  44. package/src/domain/trust/TrustEvaluator.js +180 -0
  45. package/src/domain/trust/TrustRecordService.js +274 -0
  46. package/src/domain/trust/TrustStateBuilder.js +209 -0
  47. package/src/domain/trust/canonical.js +68 -0
  48. package/src/domain/trust/reasonCodes.js +64 -0
  49. package/src/domain/trust/schemas.js +160 -0
  50. package/src/domain/trust/verdict.js +42 -0
  51. package/src/domain/types/git-cas.d.ts +20 -0
  52. package/src/domain/utils/RefLayout.js +59 -0
  53. package/src/domain/warp/PatchSession.js +18 -0
  54. package/src/domain/warp/Writer.js +18 -3
  55. package/src/domain/warp/_internal.js +26 -0
  56. package/src/domain/warp/_wire.js +58 -0
  57. package/src/domain/warp/_wiredMethods.d.ts +100 -0
  58. package/src/domain/warp/checkpoint.methods.js +397 -0
  59. package/src/domain/warp/fork.methods.js +323 -0
  60. package/src/domain/warp/materialize.methods.js +188 -0
  61. package/src/domain/warp/materializeAdvanced.methods.js +339 -0
  62. package/src/domain/warp/patch.methods.js +529 -0
  63. package/src/domain/warp/provenance.methods.js +284 -0
  64. package/src/domain/warp/query.methods.js +279 -0
  65. package/src/domain/warp/subscribe.methods.js +272 -0
  66. package/src/domain/warp/sync.methods.js +549 -0
  67. package/src/infrastructure/adapters/GitGraphAdapter.js +67 -1
  68. package/src/infrastructure/adapters/InMemoryGraphAdapter.js +36 -0
  69. package/src/ports/CommitPort.js +10 -0
  70. package/src/ports/RefPort.js +17 -0
  71. package/src/hooks/post-merge.sh +0 -60
@@ -0,0 +1,154 @@
1
+ /**
2
+ * CLI handler for `git warp trust`.
3
+ *
4
+ * Evaluates writer trust status against signed evidence in the trust
5
+ * record chain. Returns a TrustAssessment payload.
6
+ *
7
+ * @module cli/commands/trust
8
+ */
9
+
10
+ import { EXIT_CODES, parseCommandArgs, getEnvVar } from '../infrastructure.js';
11
+ import { trustSchema } from '../schemas.js';
12
+ import { createPersistence, resolveGraphName } from '../shared.js';
13
+ import defaultCodec from '../../../src/domain/utils/defaultCodec.js';
14
+ import { TrustRecordService } from '../../../src/domain/trust/TrustRecordService.js';
15
+ import { buildState } from '../../../src/domain/trust/TrustStateBuilder.js';
16
+ import { evaluateWriters } from '../../../src/domain/trust/TrustEvaluator.js';
17
+
18
+ /** @typedef {import('../types.js').CliOptions} CliOptions */
19
+
20
+ const TRUST_OPTIONS = {
21
+ mode: { type: 'string' },
22
+ 'trust-pin': { type: 'string' },
23
+ };
24
+
25
+ /**
26
+ * @param {string[]} args
27
+ * @returns {{ mode: string|null, trustPin: string|null }}
28
+ */
29
+ export function parseTrustArgs(args) {
30
+ const { values } = parseCommandArgs(args, TRUST_OPTIONS, trustSchema);
31
+ return values;
32
+ }
33
+
34
+ /**
35
+ * Resolves the trust pin from CLI flag → env → live ref.
36
+ * @param {string|null} cliPin
37
+ * @returns {{pin: string|null, source: string, sourceDetail: string|null, status: string}}
38
+ */
39
+ function resolveTrustPin(cliPin) {
40
+ if (cliPin) {
41
+ return { pin: cliPin, source: 'cli_pin', sourceDetail: cliPin, status: 'pinned' };
42
+ }
43
+ const envPin = getEnvVar('WARP_TRUST_PIN');
44
+ if (envPin) {
45
+ return { pin: envPin, source: 'env_pin', sourceDetail: envPin, status: 'pinned' };
46
+ }
47
+ return { pin: null, source: 'ref', sourceDetail: null, status: 'configured' };
48
+ }
49
+
50
+ /**
51
+ * Discovers all writer IDs from the writers prefix refs.
52
+ * @param {*} persistence
53
+ * @param {string} graphName
54
+ * @returns {Promise<string[]>}
55
+ */
56
+ async function discoverWriterIds(persistence, graphName) {
57
+ const prefix = `refs/warp/${graphName}/writers/`;
58
+ const refs = await persistence.listRefs(prefix);
59
+ return refs
60
+ .map((/** @type {string} */ ref) => ref.slice(prefix.length))
61
+ .filter((/** @type {string} */ id) => id.length > 0)
62
+ .sort();
63
+ }
64
+
65
+ /**
66
+ * Builds a not_configured assessment when no trust records exist.
67
+ * @param {string} graphName
68
+ * @returns {{payload: *, exitCode: number}}
69
+ */
70
+ function buildNotConfiguredResult(graphName) {
71
+ return {
72
+ payload: {
73
+ graph: graphName,
74
+ trustSchemaVersion: 1,
75
+ mode: 'signed_evidence_v1',
76
+ trustVerdict: 'not_configured',
77
+ trust: {
78
+ status: 'not_configured',
79
+ source: 'none',
80
+ sourceDetail: null,
81
+ evaluatedWriters: [],
82
+ untrustedWriters: [],
83
+ explanations: [],
84
+ evidenceSummary: {
85
+ recordsScanned: 0,
86
+ activeKeys: 0,
87
+ revokedKeys: 0,
88
+ activeBindings: 0,
89
+ revokedBindings: 0,
90
+ },
91
+ },
92
+ },
93
+ exitCode: EXIT_CODES.OK,
94
+ };
95
+ }
96
+
97
+ /**
98
+ * @param {{options: CliOptions, args: string[]}} params
99
+ * @returns {Promise<{payload: *, exitCode: number}>}
100
+ */
101
+ export default async function handleTrust({ options, args }) {
102
+ const { mode, trustPin } = parseTrustArgs(args);
103
+ const { persistence } = await createPersistence(options.repo);
104
+ const graphName = await resolveGraphName(persistence, options.graph);
105
+
106
+ const recordService = new TrustRecordService({
107
+ persistence: /** @type {*} TODO(ts-cleanup) */ (persistence),
108
+ codec: defaultCodec,
109
+ });
110
+
111
+ // Resolve pin (determines source + status)
112
+ const { pin, source, sourceDetail, status } = resolveTrustPin(trustPin);
113
+
114
+ // Read trust records
115
+ const records = await recordService.readRecords(graphName, pin ? { tip: pin } : {});
116
+
117
+ if (records.length === 0) {
118
+ return buildNotConfiguredResult(graphName);
119
+ }
120
+
121
+ // Build trust state
122
+ const trustState = buildState(records);
123
+
124
+ // Discover writers
125
+ const writerIds = await discoverWriterIds(persistence, graphName);
126
+
127
+ // Build policy
128
+ const policy = {
129
+ schemaVersion: 1,
130
+ mode: mode ?? 'warn',
131
+ writerPolicy: 'all_writers_must_be_trusted',
132
+ };
133
+
134
+ // Evaluate
135
+ const assessment = evaluateWriters(writerIds, trustState, policy);
136
+
137
+ // Override source/status from pin resolution (evaluator sets defaults)
138
+ const payload = {
139
+ graph: graphName,
140
+ ...assessment,
141
+ trust: {
142
+ ...assessment.trust,
143
+ status,
144
+ source,
145
+ sourceDetail,
146
+ },
147
+ };
148
+
149
+ const exitCode = assessment.trustVerdict === 'fail' && (mode === 'enforce')
150
+ ? EXIT_CODES.TRUST_FAIL
151
+ : EXIT_CODES.OK;
152
+
153
+ return { payload, exitCode };
154
+ }
@@ -0,0 +1,113 @@
1
+ import { AuditVerifierService } from '../../../src/domain/services/AuditVerifierService.js';
2
+ import defaultCodec from '../../../src/domain/utils/defaultCodec.js';
3
+ import { EXIT_CODES, parseCommandArgs, getEnvVar } from '../infrastructure.js';
4
+ import { verifyAuditSchema } from '../schemas.js';
5
+ import { createPersistence, resolveGraphName } from '../shared.js';
6
+
7
+ /** @typedef {import('../types.js').CliOptions} CliOptions */
8
+
9
+ /**
10
+ * Detects trust configuration from environment and returns a structured warning.
11
+ * Domain services never read process.env — detection happens at the CLI boundary.
12
+ * @returns {{ code: string, message: string, sources: string[] } | null}
13
+ */
14
+ function detectTrustWarning() {
15
+ const sources = [];
16
+ if (getEnvVar('WARP_TRUSTED_ROOT')) {
17
+ sources.push('env');
18
+ }
19
+ if (sources.length === 0) {
20
+ return null;
21
+ }
22
+ return {
23
+ code: 'TRUST_CONFIG_PRESENT_UNENFORCED',
24
+ message: 'Trust root configured but signature verification is not implemented in v1',
25
+ sources,
26
+ };
27
+ }
28
+
29
+ const VERIFY_AUDIT_OPTIONS = {
30
+ since: { type: 'string' },
31
+ writer: { type: 'string' },
32
+ 'trust-mode': { type: 'string' },
33
+ 'trust-pin': { type: 'string' },
34
+ };
35
+
36
+ /** @param {string[]} args */
37
+ export function parseVerifyAuditArgs(args) {
38
+ const { values } = parseCommandArgs(args, VERIFY_AUDIT_OPTIONS, verifyAuditSchema);
39
+ return {
40
+ since: values.since,
41
+ writerFilter: values.writer,
42
+ trustMode: values['trust-mode'],
43
+ trustPin: values['trust-pin'],
44
+ };
45
+ }
46
+
47
+ /**
48
+ * @param {{options: CliOptions, args: string[]}} params
49
+ * @returns {Promise<{payload: *, exitCode: number}>}
50
+ */
51
+ export default async function handleVerifyAudit({ options, args }) {
52
+ const { since, writerFilter, trustMode, trustPin } = parseVerifyAuditArgs(args);
53
+ const { persistence } = await createPersistence(options.repo);
54
+ const graphName = await resolveGraphName(persistence, options.graph);
55
+ const verifier = new AuditVerifierService({
56
+ persistence: /** @type {*} */ (persistence), // TODO(ts-cleanup): narrow port type
57
+ codec: defaultCodec,
58
+ });
59
+
60
+ const trustWarning = detectTrustWarning();
61
+
62
+ /** @type {*} */ // TODO(ts-cleanup): type verify-audit payload
63
+ let payload;
64
+ if (writerFilter !== undefined) {
65
+ const chain = await verifier.verifyChain(graphName, writerFilter, { since });
66
+ const invalid = chain.status !== 'VALID' && chain.status !== 'PARTIAL' ? 1 : 0;
67
+ payload = {
68
+ graph: graphName,
69
+ verifiedAt: new Date().toISOString(),
70
+ summary: {
71
+ total: 1,
72
+ valid: chain.status === 'VALID' ? 1 : 0,
73
+ partial: chain.status === 'PARTIAL' ? 1 : 0,
74
+ invalid,
75
+ },
76
+ chains: [chain],
77
+ trustWarning,
78
+ };
79
+ } else {
80
+ payload = await verifier.verifyAll(graphName, { since, trustWarning });
81
+ }
82
+
83
+ // Attach trust assessment only when explicitly requested via --trust-mode
84
+ if (trustMode) {
85
+ try {
86
+ const trustAssessment = await verifier.evaluateTrust(graphName, {
87
+ pin: trustPin,
88
+ mode: trustMode,
89
+ });
90
+ payload.trustAssessment = trustAssessment;
91
+ } catch (/** @type {*} */ err) { // TODO(ts-cleanup): type catch
92
+ if (trustMode === 'enforce') {
93
+ throw err;
94
+ }
95
+ payload.trustAssessment = {
96
+ trustSchemaVersion: 1,
97
+ mode: 'signed_evidence_v1',
98
+ trustVerdict: 'error',
99
+ error: err?.message ?? 'Trust evaluation failed',
100
+ };
101
+ }
102
+ }
103
+
104
+ const hasInvalid = payload.summary.invalid > 0;
105
+ const trustFailed = trustMode === 'enforce' &&
106
+ payload.trustAssessment?.trustVerdict === 'fail';
107
+ return {
108
+ payload,
109
+ exitCode: trustFailed ? EXIT_CODES.TRUST_FAIL
110
+ : hasInvalid ? EXIT_CODES.INTERNAL
111
+ : EXIT_CODES.OK,
112
+ };
113
+ }
@@ -0,0 +1,45 @@
1
+ import process from 'node:process';
2
+ import { parseCommandArgs, usageError } from '../infrastructure.js';
3
+ import { viewSchema } from '../schemas.js';
4
+
5
+ /** @typedef {import('../types.js').CliOptions} CliOptions */
6
+
7
+ const VIEW_OPTIONS = {
8
+ list: { type: 'boolean', default: false },
9
+ log: { type: 'boolean', default: false },
10
+ };
11
+
12
+ /**
13
+ * @param {{options: CliOptions, args: string[]}} params
14
+ * @returns {Promise<{payload: *, exitCode: number}>}
15
+ */
16
+ export default async function handleView({ options, args }) {
17
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
18
+ throw usageError('view command requires an interactive terminal (TTY)');
19
+ }
20
+
21
+ const { values, positionals } = parseCommandArgs(args, VIEW_OPTIONS, viewSchema, { allowPositionals: true });
22
+ const viewMode = values.log || positionals[0] === 'log' ? 'log' : 'list';
23
+
24
+ try {
25
+ // @ts-expect-error — optional peer dependency, may not be installed
26
+ const { startTui } = await import('@git-stunts/git-warp-tui');
27
+ await startTui({
28
+ repo: options.repo || '.',
29
+ graph: options.graph || 'default',
30
+ mode: viewMode,
31
+ });
32
+ } catch (/** @type {*} */ err) { // TODO(ts-cleanup): type error
33
+ const isMissing = err.code === 'ERR_MODULE_NOT_FOUND' || (err.message && err.message.includes('Cannot find module'));
34
+ const isTui = err.specifier?.includes('git-warp-tui') ||
35
+ /cannot find (?:package|module) ['"]@git-stunts\/git-warp-tui/i.test(err.message);
36
+ if (isMissing && isTui) {
37
+ throw usageError(
38
+ 'Interactive TUI requires @git-stunts/git-warp-tui.\n' +
39
+ ' Install with: npm install -g @git-stunts/git-warp-tui',
40
+ );
41
+ }
42
+ throw err;
43
+ }
44
+ return { payload: undefined, exitCode: 0 };
45
+ }
@@ -0,0 +1,336 @@
1
+ import path from 'node:path';
2
+ import process from 'node:process';
3
+ import { parseArgs as nodeParseArgs } from 'node:util';
4
+
5
+ /** @typedef {import('./types.js').CliOptions} CliOptions */
6
+
7
+ export const EXIT_CODES = {
8
+ OK: 0,
9
+ USAGE: 1,
10
+ NOT_FOUND: 2,
11
+ INTERNAL: 3,
12
+ /** Trust policy denial (enforce mode). */
13
+ TRUST_FAIL: 4,
14
+ };
15
+
16
+ /**
17
+ * Reads an environment variable across Node, Bun, and Deno runtimes.
18
+ * @param {string} name
19
+ * @returns {string|undefined}
20
+ */
21
+ export function getEnvVar(name) {
22
+ if (typeof process !== 'undefined' && process.env) {
23
+ return process.env[name];
24
+ }
25
+ // @ts-expect-error — Deno global is only present in Deno runtime
26
+ if (typeof Deno !== 'undefined') {
27
+ // @ts-expect-error — Deno global is only present in Deno runtime
28
+ // eslint-disable-next-line no-undef
29
+ try { return Deno.env.get(name); } catch { return undefined; }
30
+ }
31
+ return undefined;
32
+ }
33
+
34
+ export const HELP_TEXT = `warp-graph <command> [options]
35
+ (or: git warp <command> [options])
36
+
37
+ Commands:
38
+ info Summarize graphs in the repo
39
+ query Run a logical graph query
40
+ path Find a logical path between two nodes
41
+ history Show writer history
42
+ check Report graph health/GC status
43
+ doctor Diagnose structural issues and suggest fixes
44
+ verify-audit Verify audit receipt chain integrity
45
+ trust Evaluate writer trust from signed evidence
46
+ materialize Materialize and checkpoint all graphs
47
+ seek Time-travel: step through graph history by Lamport tick
48
+ view Interactive TUI graph browser (requires @git-stunts/git-warp-tui)
49
+ install-hooks Install post-merge git hook
50
+
51
+ Options:
52
+ --repo <path> Path to git repo (default: cwd)
53
+ --json Emit JSON output (pretty-printed, sorted keys)
54
+ --ndjson Emit compact single-line JSON (for piping/scripting)
55
+ --view [mode] Visual output (ascii, browser, svg:FILE, html:FILE)
56
+ --graph <name> Graph name (required if repo has multiple graphs)
57
+ --writer <id> Writer id (default: cli)
58
+ -h, --help Show this help
59
+
60
+ Install-hooks options:
61
+ --force Replace existing hook (backs up original)
62
+
63
+ Query options:
64
+ --match <glob> Match node ids (default: *)
65
+ --outgoing [label] Traverse outgoing edge (repeatable)
66
+ --incoming [label] Traverse incoming edge (repeatable)
67
+ --where-prop k=v Filter nodes by prop equality (repeatable)
68
+ --select <fields> Fields to select (id, props)
69
+
70
+ Path options:
71
+ --from <id> Start node id
72
+ --to <id> End node id
73
+ --dir <out|in|both> Traversal direction (default: out)
74
+ --label <label> Filter by edge label (repeatable, comma-separated)
75
+ --max-depth <n> Maximum depth
76
+
77
+ History options:
78
+ --node <id> Filter patches touching node id
79
+
80
+ Doctor options:
81
+ --strict Treat warnings as failures (exit 4)
82
+
83
+ Verify-audit options:
84
+ --writer <id> Verify a single writer's chain (default: all)
85
+ --since <commit> Verify from tip down to this commit (inclusive)
86
+ --trust-mode <mode> Trust evaluation mode (warn, enforce)
87
+ --trust-pin <sha> Pin trust evaluation to a specific record chain commit
88
+
89
+ Trust options:
90
+ --mode <warn|enforce> Override trust evaluation mode
91
+ --trust-pin <sha> Pin trust evaluation to a specific record chain commit
92
+
93
+ Seek options:
94
+ --tick <N|+N|-N> Jump to tick N, or step forward/backward
95
+ --latest Clear cursor, return to present
96
+ --save <name> Save current position as named cursor
97
+ --load <name> Restore a saved cursor
98
+ --list List all saved cursors
99
+ --drop <name> Delete a saved cursor
100
+ --diff Show structural diff (added/removed nodes, edges, props)
101
+ --diff-limit <N> Max diff entries (default 2000)
102
+ `;
103
+
104
+ /**
105
+ * Structured CLI error with exit code and error code.
106
+ */
107
+ export class CliError extends Error {
108
+ /**
109
+ * @param {string} message - Human-readable error message
110
+ * @param {Object} [options]
111
+ * @param {string} [options.code='E_CLI'] - Machine-readable error code
112
+ * @param {number} [options.exitCode=3] - Process exit code
113
+ * @param {Error} [options.cause] - Underlying cause
114
+ */
115
+ constructor(message, { code = 'E_CLI', exitCode = EXIT_CODES.INTERNAL, cause } = {}) {
116
+ super(message);
117
+ this.code = code;
118
+ this.exitCode = exitCode;
119
+ this.cause = cause;
120
+ }
121
+ }
122
+
123
+ /** @param {string} message */
124
+ export function usageError(message) {
125
+ return new CliError(message, { code: 'E_USAGE', exitCode: EXIT_CODES.USAGE });
126
+ }
127
+
128
+ /** @param {string} message */
129
+ export function notFoundError(message) {
130
+ return new CliError(message, { code: 'E_NOT_FOUND', exitCode: EXIT_CODES.NOT_FOUND });
131
+ }
132
+
133
+ export const KNOWN_COMMANDS = ['info', 'query', 'path', 'history', 'check', 'doctor', 'materialize', 'seek', 'verify-audit', 'trust', 'install-hooks', 'view'];
134
+
135
+ const BASE_OPTIONS = {
136
+ repo: { type: 'string', short: 'r' },
137
+ json: { type: 'boolean', default: false },
138
+ ndjson: { type: 'boolean', default: false },
139
+ view: { type: 'string' },
140
+ graph: { type: 'string' },
141
+ writer: { type: 'string', default: 'cli' },
142
+ help: { type: 'boolean', short: 'h', default: false },
143
+ };
144
+
145
+ /**
146
+ * Pre-processes argv to handle --view's optional-value semantics.
147
+ * If --view is followed by a command name or flag (or is last), injects 'ascii'.
148
+ * Validates the view mode value.
149
+ * @param {string[]} argv
150
+ * @returns {string[]}
151
+ */
152
+ function preprocessView(argv) {
153
+ const idx = argv.indexOf('--view');
154
+ if (idx === -1) {
155
+ return argv;
156
+ }
157
+ const next = argv[idx + 1];
158
+ const needsDefault = !next || next.startsWith('-') || KNOWN_COMMANDS.includes(next);
159
+ if (needsDefault) {
160
+ return [...argv.slice(0, idx + 1), 'ascii', ...argv.slice(idx + 1)];
161
+ }
162
+ const validModes = ['ascii', 'browser'];
163
+ const validPrefixes = ['svg:', 'html:'];
164
+ const isValid = validModes.includes(next) ||
165
+ validPrefixes.some((prefix) => next.startsWith(prefix));
166
+ if (!isValid) {
167
+ throw usageError(`Invalid view mode: ${next}. Valid modes: ascii, browser, svg:FILE, html:FILE`);
168
+ }
169
+ return argv;
170
+ }
171
+
172
+ /** String flags that always consume a value argument */
173
+ const BASE_STRING_FLAGS = new Set(['--repo', '-r', '--graph', '--writer']);
174
+ /** Boolean flags (no value) */
175
+ const BASE_BOOL_FLAGS = new Set(['--json', '--ndjson', '--help', '-h']);
176
+
177
+ /**
178
+ * Checks if a value looks like it belongs to --view (not a flag or command).
179
+ * @param {string|undefined} next
180
+ * @returns {boolean}
181
+ */
182
+ function isViewValue(next) {
183
+ if (!next || next.startsWith('-') || KNOWN_COMMANDS.includes(next)) {
184
+ return false;
185
+ }
186
+ return true;
187
+ }
188
+
189
+ /**
190
+ * Extracts base flags from anywhere in argv, leaving command + commandArgs.
191
+ *
192
+ * Base flags (--repo, --graph, --writer, --view, --json, --ndjson, --help)
193
+ * can appear before or after the command. Everything else (unknown flags,
194
+ * positionals after the command) becomes commandArgs.
195
+ *
196
+ * @param {string[]} argv
197
+ * @returns {{baseArgs: string[], command: string|undefined, commandArgs: string[]}}
198
+ */
199
+ function extractBaseArgs(argv) {
200
+ /** @type {string[]} */
201
+ const baseArgs = [];
202
+ /** @type {string[]} */
203
+ const rest = [];
204
+ /** @type {string|undefined} */
205
+ let command;
206
+ let pastCommand = false;
207
+
208
+ for (let i = 0; i < argv.length; i++) {
209
+ const arg = argv[i];
210
+
211
+ if (arg === '--') {
212
+ rest.push(...argv.slice(i + 1));
213
+ break;
214
+ }
215
+
216
+ if (BASE_STRING_FLAGS.has(arg)) {
217
+ baseArgs.push(arg);
218
+ if (i + 1 < argv.length) {
219
+ baseArgs.push(argv[++i]);
220
+ }
221
+ continue;
222
+ }
223
+
224
+ // Handle --flag=value form for string flags
225
+ if (arg.startsWith('--') && BASE_STRING_FLAGS.has(arg.split('=')[0])) {
226
+ baseArgs.push(arg);
227
+ continue;
228
+ }
229
+
230
+ // --view has optional-value semantics: consume next only if it looks like a view mode
231
+ if (arg === '--view') {
232
+ baseArgs.push(arg);
233
+ if (isViewValue(argv[i + 1])) {
234
+ baseArgs.push(argv[++i]);
235
+ }
236
+ continue;
237
+ }
238
+
239
+ if (arg.startsWith('--view=')) {
240
+ baseArgs.push(arg);
241
+ continue;
242
+ }
243
+
244
+ if (BASE_BOOL_FLAGS.has(arg)) {
245
+ baseArgs.push(arg);
246
+ continue;
247
+ }
248
+
249
+ if (!pastCommand && !arg.startsWith('-')) {
250
+ command = arg;
251
+ pastCommand = true;
252
+ continue;
253
+ }
254
+
255
+ rest.push(arg);
256
+ }
257
+
258
+ return { baseArgs, command, commandArgs: rest };
259
+ }
260
+
261
+ /**
262
+ * Two-pass arg parser using node:util.parseArgs.
263
+ *
264
+ * Pass 1: extract base flags from anywhere in argv.
265
+ * Pass 2: pre-process --view (optional-value semantics) on base args.
266
+ * Pass 3: parseArgs with strict:true on base args only.
267
+ *
268
+ * @param {string[]} argv
269
+ * @returns {{options: CliOptions, command: string|undefined, commandArgs: string[]}}
270
+ */
271
+ export function parseArgs(argv) {
272
+ const { baseArgs, command, commandArgs } = extractBaseArgs(argv);
273
+ const processed = preprocessView(baseArgs);
274
+
275
+ /** @type {*} */ // TODO(ts-cleanup): type parseArgs return
276
+ let parsed;
277
+ try {
278
+ parsed = nodeParseArgs({
279
+ args: processed,
280
+ options: /** @type {*} */ (BASE_OPTIONS), // TODO(ts-cleanup): type parseArgs config
281
+ strict: true,
282
+ allowPositionals: false,
283
+ });
284
+ } catch (/** @type {*} */ err) { // TODO(ts-cleanup): type parseArgs error
285
+ throw usageError(err.message);
286
+ }
287
+
288
+ const { values } = parsed;
289
+
290
+ /** @type {CliOptions} */
291
+ const options = {
292
+ repo: path.resolve(typeof values.repo === 'string' ? values.repo : process.cwd()),
293
+ json: Boolean(values.json),
294
+ ndjson: Boolean(values.ndjson),
295
+ view: typeof values.view === 'string' ? values.view : null,
296
+ graph: typeof values.graph === 'string' ? values.graph : null,
297
+ writer: typeof values.writer === 'string' ? values.writer : 'cli',
298
+ help: Boolean(values.help),
299
+ };
300
+
301
+ return { options, command, commandArgs };
302
+ }
303
+
304
+ /**
305
+ * Parses command-level args using node:util.parseArgs + Zod validation.
306
+ *
307
+ * @param {string[]} args - Command-specific args (after command name)
308
+ * @param {Object} config - parseArgs options config
309
+ * @param {import('zod').ZodType} schema - Zod schema to validate/transform parsed values
310
+ * @param {Object} [opts]
311
+ * @param {boolean} [opts.allowPositionals=false] - Whether to allow positional arguments
312
+ * @returns {{values: *, positionals: string[]}}
313
+ */
314
+ export function parseCommandArgs(args, config, schema, { allowPositionals = false } = {}) {
315
+ /** @type {*} */ // TODO(ts-cleanup): type parseArgs return
316
+ let parsed;
317
+ try {
318
+ parsed = nodeParseArgs({
319
+ args,
320
+ options: /** @type {*} */ (config), // TODO(ts-cleanup): type parseArgs config
321
+ strict: true,
322
+ allowPositionals,
323
+ });
324
+ } catch (/** @type {*} */ err) { // TODO(ts-cleanup): type parseArgs error
325
+ throw usageError(err.message);
326
+ }
327
+
328
+ const result = schema.safeParse(parsed.values);
329
+ if (!result.success) {
330
+ const msg = result.error.issues.map((/** @type {*} */ issue) => issue.message).join('; '); // TODO(ts-cleanup): type Zod issue
331
+ throw usageError(msg);
332
+ }
333
+
334
+ return { values: result.data, positionals: parsed.positionals || [] };
335
+ }
336
+