@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.
- package/README.md +53 -32
- package/SECURITY.md +64 -0
- package/bin/cli/commands/check.js +168 -0
- package/bin/cli/commands/doctor/checks.js +422 -0
- package/bin/cli/commands/doctor/codes.js +46 -0
- package/bin/cli/commands/doctor/index.js +239 -0
- package/bin/cli/commands/doctor/types.js +89 -0
- package/bin/cli/commands/history.js +73 -0
- package/bin/cli/commands/info.js +139 -0
- package/bin/cli/commands/install-hooks.js +128 -0
- package/bin/cli/commands/materialize.js +99 -0
- package/bin/cli/commands/path.js +88 -0
- package/bin/cli/commands/query.js +194 -0
- package/bin/cli/commands/registry.js +28 -0
- package/bin/cli/commands/seek.js +592 -0
- package/bin/cli/commands/trust.js +154 -0
- package/bin/cli/commands/verify-audit.js +113 -0
- package/bin/cli/commands/view.js +45 -0
- package/bin/cli/infrastructure.js +336 -0
- package/bin/cli/schemas.js +177 -0
- package/bin/cli/shared.js +244 -0
- package/bin/cli/types.js +85 -0
- package/bin/presenters/index.js +214 -0
- package/bin/presenters/json.js +66 -0
- package/bin/presenters/text.js +543 -0
- package/bin/warp-graph.js +19 -2824
- package/index.d.ts +32 -2
- package/index.js +2 -0
- package/package.json +9 -7
- package/src/domain/WarpGraph.js +106 -3252
- package/src/domain/errors/QueryError.js +2 -2
- package/src/domain/errors/TrustError.js +29 -0
- package/src/domain/errors/index.js +1 -0
- package/src/domain/services/AuditMessageCodec.js +137 -0
- package/src/domain/services/AuditReceiptService.js +471 -0
- package/src/domain/services/AuditVerifierService.js +693 -0
- package/src/domain/services/HttpSyncServer.js +36 -22
- package/src/domain/services/MessageCodecInternal.js +3 -0
- package/src/domain/services/MessageSchemaDetector.js +2 -2
- package/src/domain/services/SyncAuthService.js +69 -3
- package/src/domain/services/WarpMessageCodec.js +4 -1
- package/src/domain/trust/TrustCanonical.js +42 -0
- package/src/domain/trust/TrustCrypto.js +111 -0
- package/src/domain/trust/TrustEvaluator.js +180 -0
- package/src/domain/trust/TrustRecordService.js +274 -0
- package/src/domain/trust/TrustStateBuilder.js +209 -0
- package/src/domain/trust/canonical.js +68 -0
- package/src/domain/trust/reasonCodes.js +64 -0
- package/src/domain/trust/schemas.js +160 -0
- package/src/domain/trust/verdict.js +42 -0
- package/src/domain/types/git-cas.d.ts +20 -0
- package/src/domain/utils/RefLayout.js +59 -0
- package/src/domain/warp/PatchSession.js +18 -0
- package/src/domain/warp/Writer.js +18 -3
- package/src/domain/warp/_internal.js +26 -0
- package/src/domain/warp/_wire.js +58 -0
- package/src/domain/warp/_wiredMethods.d.ts +100 -0
- package/src/domain/warp/checkpoint.methods.js +397 -0
- package/src/domain/warp/fork.methods.js +323 -0
- package/src/domain/warp/materialize.methods.js +188 -0
- package/src/domain/warp/materializeAdvanced.methods.js +339 -0
- package/src/domain/warp/patch.methods.js +529 -0
- package/src/domain/warp/provenance.methods.js +284 -0
- package/src/domain/warp/query.methods.js +279 -0
- package/src/domain/warp/subscribe.methods.js +272 -0
- package/src/domain/warp/sync.methods.js +549 -0
- package/src/infrastructure/adapters/GitGraphAdapter.js +67 -1
- package/src/infrastructure/adapters/InMemoryGraphAdapter.js +36 -0
- package/src/ports/CommitPort.js +10 -0
- package/src/ports/RefPort.js +17 -0
- 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
|
+
|