@twinsearth/w2m-dsh-plugin 0.0.0-stage → 0.4.7
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/CHANGELOG.md +1309 -0
- package/LICENSE +21 -0
- package/PROTOCOL-v0.1.2.md +165 -0
- package/PROTOCOL-v0.3.0.md +454 -0
- package/PROTOCOL-v0.3.9.md +207 -0
- package/PROTOCOL-v0.4.0.md +255 -0
- package/PROTOCOL.md +339 -0
- package/README.md +665 -2
- package/bin/w2m-localside.mjs +303 -0
- package/bin/w2m-rabbit.mjs +396 -0
- package/bin/w2m-stun.mjs +231 -0
- package/cordis.patch.yml +94 -0
- package/deploy/caddy/Caddyfile.example +118 -0
- package/deploy/docker/Dockerfile +144 -0
- package/deploy/docker/README.md +338 -0
- package/deploy/docker/docker-compose.yml +179 -0
- package/deploy/nginx/w2m.conf.example +196 -0
- package/deploy/shared-server/README.md +118 -0
- package/deploy/shared-server/install.sh +255 -0
- package/deploy/systemd/w2m-rabbit.service +150 -0
- package/deploy/systemd/w2m-stun.service +69 -0
- package/deploy/windows/README.md +115 -0
- package/deploy/windows/install-service.ps1 +195 -0
- package/deploy/windows/w2m-agent-supervisor.mjs +205 -0
- package/deploy/windows/winsw/w2m-localside.xml +50 -0
- package/docs/DEPLOY.md +902 -0
- package/docs/LINT-AUDIT.md +492 -0
- package/docs/PIPELINE-DESIGN.md +95 -0
- package/docs/TROUBLESHOOTING.md +560 -0
- package/docs/UI-CARDS.md +81 -0
- package/lib/index.js +24 -0
- package/lib/tools.js +11 -0
- package/package.json +133 -4
- package/src/agent/agent.mjs +3317 -0
- package/src/agent/allowed-commands.mjs +116 -0
- package/src/agent/caps.mjs +437 -0
- package/src/agent/exec.mjs +356 -0
- package/src/agent/git.mjs +616 -0
- package/src/agent/identity.mjs +253 -0
- package/src/agent/p2p-node.mjs +1243 -0
- package/src/agent/p2p.mjs +605 -0
- package/src/agent/resolve.mjs +148 -0
- package/src/agent/spool.mjs +252 -0
- package/src/agent/stun.mjs +397 -0
- package/src/agent/url.mjs +127 -0
- package/src/plugin/auto-update.mjs +574 -0
- package/src/plugin/schedule.mjs +452 -0
- package/src/plugin/shared-config.mjs +264 -0
- package/src/plugin/tools.mjs +3891 -0
- package/src/plugin/update-install.mjs +764 -0
- package/src/plugin/update-source.mjs +686 -0
- package/src/relay/metrics.mjs +284 -0
- package/src/relay/peers.mjs +180 -0
- package/src/relay/persistence.mjs +207 -0
- package/src/relay/report.mjs +591 -0
- package/src/relay/server.mjs +1187 -0
- package/src/relay/state.mjs +2004 -0
- package/src/relay/stun-server.mjs +733 -0
- package/src/signing.mjs +359 -0
- package/src/util/cli.mjs +70 -0
|
@@ -0,0 +1,616 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git-side evidence collection: the three anchors (§6.2) plus the post-run
|
|
3
|
+
* worktree state.
|
|
4
|
+
*
|
|
5
|
+
* The worktree fingerprint is `git-temp-index-tree/v1`, which the protocol
|
|
6
|
+
* pins down as:
|
|
7
|
+
*
|
|
8
|
+
* GIT_INDEX_FILE=<tmp> git read-tree HEAD
|
|
9
|
+
* GIT_INDEX_FILE=<tmp> git add -A
|
|
10
|
+
* GIT_INDEX_FILE=<tmp> git write-tree -> tree hash
|
|
11
|
+
*
|
|
12
|
+
* Three lessons are baked into the implementation:
|
|
13
|
+
*
|
|
14
|
+
* * **One temp index per call, never shared.** `GIT_INDEX_FILE` is a
|
|
15
|
+
* process-wide environment variable, so a single well-known path makes
|
|
16
|
+
* concurrent fingerprint jobs collide on `<index>.lock` and interleave
|
|
17
|
+
* their contents. Every call here mints its own random index path.
|
|
18
|
+
* * **Never `git stash create`.** Measured: it drops untracked files, and
|
|
19
|
+
* it returns an empty string both for "clean" and for "only untracked
|
|
20
|
+
* files exist", which makes those two states indistinguishable.
|
|
21
|
+
* * **A refusal must name its reason.** A failed fingerprint returns a
|
|
22
|
+
* reason code, never `null` presented as success.
|
|
23
|
+
*
|
|
24
|
+
* Read-only mode additionally redirects new objects into a temporary object
|
|
25
|
+
* store (`GIT_OBJECT_DIRECTORY` + an alternate pointing at the real one), so
|
|
26
|
+
* the fingerprint does not drop loose objects into `.git/objects` of a project
|
|
27
|
+
* we promised not to write to. Both modes must yield the same tree hash; the
|
|
28
|
+
* test suite asserts exactly that.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { createHash } from 'node:crypto';
|
|
32
|
+
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
|
|
33
|
+
import { tmpdir } from 'node:os';
|
|
34
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
35
|
+
import { randomUUID } from 'node:crypto';
|
|
36
|
+
import { runArgv } from './exec.mjs';
|
|
37
|
+
import { findExecutable } from './caps.mjs';
|
|
38
|
+
|
|
39
|
+
export { sha256Hex } from './exec.mjs';
|
|
40
|
+
|
|
41
|
+
/** Fingerprint algorithm identifier, as required by §5.1. */
|
|
42
|
+
export const FINGERPRINT_ALGO = 'git-temp-index-tree/v1';
|
|
43
|
+
|
|
44
|
+
/** Warning code for "we could not fingerprint the worktree". */
|
|
45
|
+
export const WARN_FINGERPRINT_UNAVAILABLE = 'FINGERPRINT_UNAVAILABLE';
|
|
46
|
+
|
|
47
|
+
/** Warning code for "the worktree was already dirty when we started". */
|
|
48
|
+
export const WARN_DIRTY_WORKTREE = 'DIRTY_WORKTREE';
|
|
49
|
+
|
|
50
|
+
/** Reason codes for `fingerprint_error`. */
|
|
51
|
+
export const FINGERPRINT_ERRORS = {
|
|
52
|
+
GIT_MISSING: 'GIT_MISSING',
|
|
53
|
+
NOT_A_REPO: 'NOT_A_REPO',
|
|
54
|
+
NO_HEAD: 'NO_HEAD',
|
|
55
|
+
READ_TREE_FAILED: 'READ_TREE_FAILED',
|
|
56
|
+
ADD_FAILED: 'ADD_FAILED',
|
|
57
|
+
WRITE_TREE_FAILED: 'WRITE_TREE_FAILED',
|
|
58
|
+
GIT_TIMEOUT: 'GIT_TIMEOUT',
|
|
59
|
+
TEMP_INDEX_FAILED: 'TEMP_INDEX_FAILED',
|
|
60
|
+
BAD_TREE: 'BAD_TREE',
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const HEX40 = /^[0-9a-f]{40}$/;
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Canonical JSON (RFC 8785 / JCS) and hashing
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* RFC 8785 JSON canonicalization.
|
|
71
|
+
*
|
|
72
|
+
* Only what the envelope needs: sorted object keys (UTF-16 code-unit order,
|
|
73
|
+
* which is what `Array.prototype.sort` does by default), no insignificant
|
|
74
|
+
* whitespace, and ECMAScript number formatting. `undefined` throws instead of
|
|
75
|
+
* vanishing, because a required envelope field must be written as an explicit
|
|
76
|
+
* `null` rather than silently dropped.
|
|
77
|
+
*
|
|
78
|
+
* @param {unknown} value
|
|
79
|
+
* @returns {string}
|
|
80
|
+
*/
|
|
81
|
+
export function jcs(value) {
|
|
82
|
+
if (value === null) return 'null';
|
|
83
|
+
const type = typeof value;
|
|
84
|
+
if (type === 'string') return JSON.stringify(value);
|
|
85
|
+
if (type === 'boolean') return value ? 'true' : 'false';
|
|
86
|
+
if (type === 'number') {
|
|
87
|
+
if (!Number.isFinite(value)) throw new TypeError(`jcs: non-finite number ${value}`);
|
|
88
|
+
return JSON.stringify(value);
|
|
89
|
+
}
|
|
90
|
+
if (type === 'undefined') throw new TypeError('jcs: undefined is not representable');
|
|
91
|
+
if (Array.isArray(value)) return `[${value.map((item) => jcs(item)).join(',')}]`;
|
|
92
|
+
if (type === 'object') {
|
|
93
|
+
const keys = Object.keys(value).sort();
|
|
94
|
+
const parts = [];
|
|
95
|
+
for (const key of keys) {
|
|
96
|
+
const entry = value[key];
|
|
97
|
+
if (entry === undefined) throw new TypeError(`jcs: property ${key} is undefined`);
|
|
98
|
+
parts.push(`${JSON.stringify(key)}:${jcs(entry)}`);
|
|
99
|
+
}
|
|
100
|
+
return `{${parts.join(',')}}`;
|
|
101
|
+
}
|
|
102
|
+
throw new TypeError(`jcs: unsupported type ${type}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* SHA-256 over the canonical form of `value`.
|
|
107
|
+
*
|
|
108
|
+
* @param {unknown} value
|
|
109
|
+
* @returns {string} lowercase hex
|
|
110
|
+
*/
|
|
111
|
+
export function jcsSha256(value) {
|
|
112
|
+
return createHash('sha256').update(jcs(value), 'utf8').digest('hex');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* `command_hash` per §5.1: `sha256(JCS(argv) + "|" + shell_id + "|" + cwd_rel)`.
|
|
117
|
+
*
|
|
118
|
+
* @param {string[]} argv
|
|
119
|
+
* @param {string} shellId
|
|
120
|
+
* @param {string} cwdRel Forward-slash relative path.
|
|
121
|
+
* @returns {string}
|
|
122
|
+
*/
|
|
123
|
+
export function commandHash(argv, shellId, cwdRel) {
|
|
124
|
+
if (!Array.isArray(argv)) throw new TypeError('commandHash: argv must be an array');
|
|
125
|
+
const material = `${jcs(argv)}|${shellId}|${normalizeRelPath(cwdRel)}`;
|
|
126
|
+
return createHash('sha256').update(material, 'utf8').digest('hex');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* `command_hash` for a whole pipeline (v0.3.3).
|
|
131
|
+
*
|
|
132
|
+
* Must produce exactly what the relay's `computePipelineCommandHash` produces, or the anchor check in
|
|
133
|
+
* the relay's Step 1 sees `command_hash mismatch` and every pipeline is `unverifiable` -- which is what
|
|
134
|
+
* happened on the first end-to-end run. The two implementations are deliberately independent
|
|
135
|
+
* (neither imports the other) and the shared shape is asserted by a test that recomputes both, so a
|
|
136
|
+
* drift in either is caught rather than silently agreeing.
|
|
137
|
+
*
|
|
138
|
+
* The hash binds **every** stage: bound to stage 0 alone, two chains differing after the first stage
|
|
139
|
+
* would share a hash, and an envelope from the wrong chain would still verify.
|
|
140
|
+
*
|
|
141
|
+
* @param {Array<{command_argv: string[], cwd_rel?: string, continue_on_failure?: boolean}>} stages
|
|
142
|
+
* @param {string} shellId
|
|
143
|
+
* @returns {string}
|
|
144
|
+
*/
|
|
145
|
+
export function pipelineCommandHash(stages, shellId) {
|
|
146
|
+
if (!Array.isArray(stages)) throw new TypeError('pipelineCommandHash: stages must be an array');
|
|
147
|
+
const canonical = stages.map((s) => ({
|
|
148
|
+
command_argv: s.command_argv,
|
|
149
|
+
cwd_rel: normalizeRelPath(s.cwd_rel ?? '.'),
|
|
150
|
+
continue_on_failure: s.continue_on_failure === true,
|
|
151
|
+
}));
|
|
152
|
+
const material = `${jcs(canonical)}|${shellId}|pipeline`;
|
|
153
|
+
return createHash('sha256').update(material, 'utf8').digest('hex');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* `envelope_sha256` per §5.1: JCS hash of the envelope without that field.
|
|
158
|
+
*
|
|
159
|
+
* @param {Record<string, unknown>} envelope
|
|
160
|
+
* @returns {string}
|
|
161
|
+
*/
|
|
162
|
+
export function envelopeSha256(envelope) {
|
|
163
|
+
const copy = { ...envelope };
|
|
164
|
+
delete copy.envelope_sha256;
|
|
165
|
+
return jcsSha256(copy);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Normalize a path to the contract's forward-slash relative form.
|
|
170
|
+
*
|
|
171
|
+
* @param {string} p
|
|
172
|
+
* @returns {string} `'.'` when empty.
|
|
173
|
+
*/
|
|
174
|
+
export function normalizeRelPath(p) {
|
|
175
|
+
if (p === null || p === undefined) return '.';
|
|
176
|
+
let out = String(p).replace(/\\/g, '/');
|
|
177
|
+
while (out.startsWith('./')) out = out.slice(2);
|
|
178
|
+
out = out.replace(/\/{2,}/g, '/');
|
|
179
|
+
out = out.replace(/\/+$/, '');
|
|
180
|
+
return out === '' ? '.' : out;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
// Git plumbing
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* @typedef {object} GitRunResult
|
|
189
|
+
* @property {number|null} code
|
|
190
|
+
* @property {string} stdout
|
|
191
|
+
* @property {string} stderr
|
|
192
|
+
* @property {boolean} timed_out
|
|
193
|
+
* @property {string|null} spawn_error
|
|
194
|
+
* @property {import('./exec.mjs').ExecResult} raw
|
|
195
|
+
*/
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Run one git command with an argv array (no shell), always non-interactive.
|
|
199
|
+
*
|
|
200
|
+
* `GIT_OPTIONAL_LOCKS=0` matters for read-only mode: without it a plain
|
|
201
|
+
* `git status` refreshes and rewrites `.git/index`, i.e. writes to a project
|
|
202
|
+
* we promised not to touch.
|
|
203
|
+
*
|
|
204
|
+
* @param {string[]} args Arguments after the executable.
|
|
205
|
+
* @param {object} [options]
|
|
206
|
+
* @param {string} options.cwd
|
|
207
|
+
* @param {Record<string,string|undefined>} [options.env]
|
|
208
|
+
* @param {string} [options.gitPath]
|
|
209
|
+
* @param {number} [options.timeoutMs]
|
|
210
|
+
* @returns {Promise<GitRunResult>}
|
|
211
|
+
*/
|
|
212
|
+
export async function gitRun(args, options = {}) {
|
|
213
|
+
const gitPath = options.gitPath ?? resolveGit();
|
|
214
|
+
if (!gitPath) {
|
|
215
|
+
return {
|
|
216
|
+
code: null,
|
|
217
|
+
stdout: '',
|
|
218
|
+
stderr: 'git executable not found',
|
|
219
|
+
timed_out: false,
|
|
220
|
+
spawn_error: 'ENOENT',
|
|
221
|
+
raw: null,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
const raw = await runArgv([gitPath, '--no-pager', ...args], {
|
|
225
|
+
cwd: options.cwd,
|
|
226
|
+
timeoutMs: options.timeoutMs ?? 60000,
|
|
227
|
+
maxOutputBytes: 8 * 1024 * 1024,
|
|
228
|
+
env: {
|
|
229
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
230
|
+
GIT_OPTIONAL_LOCKS: '0',
|
|
231
|
+
GIT_PAGER: 'cat',
|
|
232
|
+
...(options.env ?? {}),
|
|
233
|
+
},
|
|
234
|
+
signal: options.signal,
|
|
235
|
+
});
|
|
236
|
+
return {
|
|
237
|
+
code: raw.exit_code,
|
|
238
|
+
stdout: raw.stdout.toString('utf8'),
|
|
239
|
+
stderr: raw.stderr.toString('utf8'),
|
|
240
|
+
timed_out: raw.timed_out,
|
|
241
|
+
spawn_error: raw.spawn_error ? raw.spawn_error.code : null,
|
|
242
|
+
raw,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** @type {string|null|undefined} */
|
|
247
|
+
let cachedGitPath;
|
|
248
|
+
|
|
249
|
+
/** Resolve the git executable once per process. */
|
|
250
|
+
export function resolveGit() {
|
|
251
|
+
if (cachedGitPath === undefined) {
|
|
252
|
+
cachedGitPath = findExecutable('git', { env: process.env });
|
|
253
|
+
}
|
|
254
|
+
return cachedGitPath;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Test seam: forget the cached git path. */
|
|
258
|
+
export function resetGitCache() {
|
|
259
|
+
cachedGitPath = undefined;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* `git --version` output, or `null`.
|
|
264
|
+
*
|
|
265
|
+
* @returns {Promise<string|null>}
|
|
266
|
+
*/
|
|
267
|
+
export async function gitVersion(options = {}) {
|
|
268
|
+
const result = await gitRun(['--version'], { cwd: options.cwd ?? process.cwd(), ...options });
|
|
269
|
+
if (result.code !== 0) return null;
|
|
270
|
+
const match = result.stdout.match(/git version\s+(\S+)/);
|
|
271
|
+
return match ? match[1] : null;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Resolve a single revision to a 40-hex object id.
|
|
276
|
+
*
|
|
277
|
+
* @param {string} rev
|
|
278
|
+
* @param {object} options
|
|
279
|
+
* @returns {Promise<string|null>}
|
|
280
|
+
*/
|
|
281
|
+
export async function revParse(rev, options) {
|
|
282
|
+
const result = await gitRun(['rev-parse', '--verify', '--quiet', rev], options);
|
|
283
|
+
if (result.code !== 0) return null;
|
|
284
|
+
const value = result.stdout.trim();
|
|
285
|
+
return HEX40.test(value) ? value : null;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Current `HEAD` commit, or `null` in an unborn/absent repository.
|
|
290
|
+
*
|
|
291
|
+
* @param {object} options
|
|
292
|
+
* @returns {Promise<string|null>}
|
|
293
|
+
*/
|
|
294
|
+
export function headCommit(options) {
|
|
295
|
+
return revParse('HEAD^{commit}', options);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Tree hash of `HEAD`, or `null`.
|
|
300
|
+
*
|
|
301
|
+
* @param {object} options
|
|
302
|
+
* @returns {Promise<string|null>}
|
|
303
|
+
*/
|
|
304
|
+
export function headTree(options) {
|
|
305
|
+
return revParse('HEAD^{tree}', options);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Whether the worktree (including untracked files) differs from HEAD.
|
|
310
|
+
*
|
|
311
|
+
* @param {object} options
|
|
312
|
+
* @returns {Promise<boolean|null>} `null` when git could not answer.
|
|
313
|
+
*/
|
|
314
|
+
export async function isDirty(options) {
|
|
315
|
+
const result = await gitRun(['status', '--porcelain=v1', '--untracked-files=normal', '-z'], options);
|
|
316
|
+
if (result.code !== 0) return null;
|
|
317
|
+
return result.stdout.length > 0;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Untracked, non-ignored files, sorted, forward-slash relative paths.
|
|
322
|
+
*
|
|
323
|
+
* @param {object} options
|
|
324
|
+
* @returns {Promise<string[]>}
|
|
325
|
+
*/
|
|
326
|
+
export async function untrackedFiles(options) {
|
|
327
|
+
const result = await gitRun(['ls-files', '--others', '--exclude-standard', '-z'], options);
|
|
328
|
+
if (result.code !== 0) return [];
|
|
329
|
+
return splitZ(result.stdout).map(normalizeRelPath).sort();
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* `git diff --numstat` against a base revision, in the envelope's shape.
|
|
334
|
+
*
|
|
335
|
+
* @param {string} base Revision to diff against.
|
|
336
|
+
* @param {object} options
|
|
337
|
+
* @returns {Promise<Array<{path: string, added: number, deleted: number, is_binary: boolean}>>}
|
|
338
|
+
*/
|
|
339
|
+
export async function diffNumstat(base, options) {
|
|
340
|
+
const result = await gitRun(['diff', '--numstat', '-z', '--no-renames', base], options);
|
|
341
|
+
if (result.code !== 0) return [];
|
|
342
|
+
const records = splitZ(result.stdout);
|
|
343
|
+
/** @type {Array<{path: string, added: number, deleted: number, is_binary: boolean}>} */
|
|
344
|
+
const out = [];
|
|
345
|
+
for (const record of records) {
|
|
346
|
+
if (record === '') continue;
|
|
347
|
+
const parts = record.split('\t');
|
|
348
|
+
if (parts.length < 3) continue;
|
|
349
|
+
const [added, deleted, ...rest] = parts;
|
|
350
|
+
const isBinary = added === '-' || deleted === '-';
|
|
351
|
+
out.push({
|
|
352
|
+
path: normalizeRelPath(rest.join('\t')),
|
|
353
|
+
added: isBinary ? 0 : Number.parseInt(added, 10) || 0,
|
|
354
|
+
deleted: isBinary ? 0 : Number.parseInt(deleted, 10) || 0,
|
|
355
|
+
is_binary: isBinary,
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
out.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
359
|
+
return out;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** @param {string} text */
|
|
363
|
+
function splitZ(text) {
|
|
364
|
+
return text.split('\u0000').filter((entry) => entry !== '');
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// ---------------------------------------------------------------------------
|
|
368
|
+
// Worktree fingerprint
|
|
369
|
+
// ---------------------------------------------------------------------------
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Compute the `git-temp-index-tree/v1` worktree fingerprint.
|
|
373
|
+
*
|
|
374
|
+
* @param {object} options
|
|
375
|
+
* @param {string} options.cwd Worktree root (or any path inside it).
|
|
376
|
+
* @param {string} [options.tmpDir] Where the temp index/object store live.
|
|
377
|
+
* @param {string} [options.gitPath]
|
|
378
|
+
* @param {'temp'|'project'} [options.objectMode] `'temp'` (default) keeps new
|
|
379
|
+
* loose objects out of the project; `'project'` is the plain recipe.
|
|
380
|
+
* @param {number} [options.timeoutMs]
|
|
381
|
+
* @returns {Promise<{fingerprint: string|null, error: string|null, detail: string|null}>}
|
|
382
|
+
*/
|
|
383
|
+
export async function treeFingerprint(options) {
|
|
384
|
+
const { cwd, tmpDir, objectMode = 'temp', timeoutMs = 60000 } = options;
|
|
385
|
+
const gitPath = options.gitPath ?? resolveGit();
|
|
386
|
+
if (!gitPath) {
|
|
387
|
+
return { fingerprint: null, error: FINGERPRINT_ERRORS.GIT_MISSING, detail: null };
|
|
388
|
+
}
|
|
389
|
+
const gitOptions = { cwd, gitPath, timeoutMs, signal: options.signal };
|
|
390
|
+
|
|
391
|
+
let scratch = null;
|
|
392
|
+
let tempIndex = null;
|
|
393
|
+
try {
|
|
394
|
+
try {
|
|
395
|
+
scratch = mkdtempSync(join(tmpDir ?? tmpdir(), 'w2m-fp-'));
|
|
396
|
+
} catch (error) {
|
|
397
|
+
return {
|
|
398
|
+
fingerprint: null,
|
|
399
|
+
error: FINGERPRINT_ERRORS.TEMP_INDEX_FAILED,
|
|
400
|
+
detail: String(error?.message ?? error),
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
// The temp index has a random name: concurrent fingerprints in this process
|
|
404
|
+
// (and in sibling processes) must never share one GIT_INDEX_FILE.
|
|
405
|
+
tempIndex = join(scratch, `index-${process.pid}-${randomUUID()}`);
|
|
406
|
+
|
|
407
|
+
/** @type {Record<string,string|undefined>} */
|
|
408
|
+
const env = { GIT_INDEX_FILE: tempIndex };
|
|
409
|
+
|
|
410
|
+
if (objectMode === 'temp') {
|
|
411
|
+
const objectsDir = join(scratch, 'objects');
|
|
412
|
+
const alt = await gitRun(['rev-parse', '--git-path', 'objects'], gitOptions);
|
|
413
|
+
if (alt.code !== 0) {
|
|
414
|
+
// Not a repository (or unusable): let read-tree produce the diagnosis.
|
|
415
|
+
const reason = await diagnoseFailure(gitOptions);
|
|
416
|
+
return {
|
|
417
|
+
fingerprint: null,
|
|
418
|
+
error: reason.error,
|
|
419
|
+
detail: reason.detail ?? (alt.stderr.trim() || null),
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
try {
|
|
423
|
+
mkdirSync(objectsDir, { recursive: true });
|
|
424
|
+
} catch (error) {
|
|
425
|
+
return {
|
|
426
|
+
fingerprint: null,
|
|
427
|
+
error: FINGERPRINT_ERRORS.TEMP_INDEX_FAILED,
|
|
428
|
+
detail: String(error?.message ?? error),
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
env.GIT_OBJECT_DIRECTORY = objectsDir;
|
|
432
|
+
// Keep the real object database readable through an alternate, so no
|
|
433
|
+
// object is ever written into the project. `--git-path objects` may be
|
|
434
|
+
// relative to cwd (`.git/objects`) or already absolute.
|
|
435
|
+
const altPath = alt.stdout.trim();
|
|
436
|
+
env.GIT_ALTERNATE_OBJECT_DIRECTORIES = isAbsolute(altPath)
|
|
437
|
+
? altPath
|
|
438
|
+
: resolve(cwd, altPath);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const readTree = await gitRun(['read-tree', 'HEAD'], { ...gitOptions, env });
|
|
442
|
+
if (readTree.code !== 0) {
|
|
443
|
+
const reason = await diagnoseFailure(gitOptions, readTree);
|
|
444
|
+
return { fingerprint: null, error: reason.error, detail: reason.detail };
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const add = await gitRun(['add', '-A'], { ...gitOptions, env });
|
|
448
|
+
if (add.code !== 0) {
|
|
449
|
+
return {
|
|
450
|
+
fingerprint: null,
|
|
451
|
+
error: FINGERPRINT_ERRORS.ADD_FAILED,
|
|
452
|
+
detail: firstLine(add.stderr),
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const writeTree = await gitRun(['write-tree'], { ...gitOptions, env });
|
|
457
|
+
if (writeTree.code !== 0) {
|
|
458
|
+
return {
|
|
459
|
+
fingerprint: null,
|
|
460
|
+
error: FINGERPRINT_ERRORS.WRITE_TREE_FAILED,
|
|
461
|
+
detail: firstLine(writeTree.stderr),
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const tree = writeTree.stdout.trim();
|
|
466
|
+
if (!HEX40.test(tree)) {
|
|
467
|
+
return {
|
|
468
|
+
fingerprint: null,
|
|
469
|
+
error: FINGERPRINT_ERRORS.BAD_TREE,
|
|
470
|
+
detail: `unexpected write-tree output: ${tree.slice(0, 80)}`,
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
return { fingerprint: tree, error: null, detail: null };
|
|
474
|
+
} finally {
|
|
475
|
+
if (scratch) {
|
|
476
|
+
try {
|
|
477
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
478
|
+
} catch {
|
|
479
|
+
/* best effort */
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Turn a failed git call into a reason code (`NOT_A_REPO`, `NO_HEAD`, ...).
|
|
487
|
+
*
|
|
488
|
+
* @param {object} gitOptions
|
|
489
|
+
* @param {GitRunResult} [failed]
|
|
490
|
+
*/
|
|
491
|
+
async function diagnoseFailure(gitOptions, failed) {
|
|
492
|
+
if (failed?.timed_out) {
|
|
493
|
+
return { error: FINGERPRINT_ERRORS.GIT_TIMEOUT, detail: 'git timed out' };
|
|
494
|
+
}
|
|
495
|
+
const inside = await gitRun(['rev-parse', '--git-dir'], gitOptions);
|
|
496
|
+
if (inside.code !== 0) {
|
|
497
|
+
return { error: FINGERPRINT_ERRORS.NOT_A_REPO, detail: firstLine(inside.stderr) };
|
|
498
|
+
}
|
|
499
|
+
const head = await revParse('HEAD^{commit}', gitOptions);
|
|
500
|
+
if (!head) {
|
|
501
|
+
return { error: FINGERPRINT_ERRORS.NO_HEAD, detail: 'repository has no commits yet' };
|
|
502
|
+
}
|
|
503
|
+
return {
|
|
504
|
+
error: FINGERPRINT_ERRORS.READ_TREE_FAILED,
|
|
505
|
+
detail: firstLine(failed?.stderr ?? ''),
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/** @param {string} text */
|
|
510
|
+
function firstLine(text) {
|
|
511
|
+
const line = String(text ?? '').split('\n').find((entry) => entry.trim() !== '');
|
|
512
|
+
return line ? line.trim() : null;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Collect the pre-execution anchors.
|
|
517
|
+
*
|
|
518
|
+
* Never throws: a missing repository or an unborn HEAD becomes
|
|
519
|
+
* `fingerprint_error` plus a `FINGERPRINT_UNAVAILABLE` warning, and the relay
|
|
520
|
+
* decides that the result is `unverifiable` (we still run the command, because
|
|
521
|
+
* its output is usually the interesting part).
|
|
522
|
+
*
|
|
523
|
+
* @param {object} options
|
|
524
|
+
* @param {string} options.cwd
|
|
525
|
+
* @param {string} [options.tmpDir]
|
|
526
|
+
* @param {string} [options.gitPath]
|
|
527
|
+
* @param {'temp'|'project'} [options.objectMode]
|
|
528
|
+
* @returns {Promise<{head_commit: string|null, head_tree: string|null, pre_tree_fingerprint: string|null, fingerprint_error: string|null, fingerprint_error_detail: string|null, dirty_before: boolean|null, untracked: string[]}>}
|
|
529
|
+
*/
|
|
530
|
+
export async function collectPreAnchors(options) {
|
|
531
|
+
const gitOptions = { cwd: options.cwd, gitPath: options.gitPath, timeoutMs: options.timeoutMs };
|
|
532
|
+
const [head, dirty, untracked] = await Promise.all([
|
|
533
|
+
headCommit(gitOptions),
|
|
534
|
+
isDirty(gitOptions),
|
|
535
|
+
untrackedFiles(gitOptions),
|
|
536
|
+
]);
|
|
537
|
+
const fp = await treeFingerprint(options);
|
|
538
|
+
const headTreeValue = head ? await headTree(gitOptions) : null;
|
|
539
|
+
|
|
540
|
+
return {
|
|
541
|
+
head_commit: head,
|
|
542
|
+
head_tree: headTreeValue,
|
|
543
|
+
pre_tree_fingerprint: fp.fingerprint,
|
|
544
|
+
fingerprint_error: fp.error,
|
|
545
|
+
fingerprint_error_detail: fp.detail,
|
|
546
|
+
dirty_before: dirty,
|
|
547
|
+
untracked,
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Collect the post-execution worktree state.
|
|
553
|
+
*
|
|
554
|
+
* @param {object} options
|
|
555
|
+
* @param {string} options.cwd
|
|
556
|
+
* @param {string} [options.base] Revision to diff against (usually base_commit).
|
|
557
|
+
* @param {string} [options.tmpDir]
|
|
558
|
+
* @param {string} [options.gitPath]
|
|
559
|
+
* @param {'temp'|'project'} [options.objectMode]
|
|
560
|
+
* @returns {Promise<{head_commit: string|null, post_tree_fingerprint: string|null, fingerprint_error: string|null, fingerprint_error_detail: string|null, untracked: string[], diff_numstat: Array<object>}>}
|
|
561
|
+
*/
|
|
562
|
+
export async function collectPostState(options) {
|
|
563
|
+
const gitOptions = { cwd: options.cwd, gitPath: options.gitPath, timeoutMs: options.timeoutMs };
|
|
564
|
+
const [head, untracked] = await Promise.all([
|
|
565
|
+
headCommit(gitOptions),
|
|
566
|
+
untrackedFiles(gitOptions),
|
|
567
|
+
]);
|
|
568
|
+
const numstat = options.base ? await diffNumstat(options.base, gitOptions) : [];
|
|
569
|
+
const fp = await treeFingerprint(options);
|
|
570
|
+
|
|
571
|
+
return {
|
|
572
|
+
head_commit: head,
|
|
573
|
+
post_tree_fingerprint: fp.fingerprint,
|
|
574
|
+
fingerprint_error: fp.error,
|
|
575
|
+
fingerprint_error_detail: fp.detail,
|
|
576
|
+
untracked,
|
|
577
|
+
diff_numstat: numstat,
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Guard for "the project we were told to run in actually contains this cwd".
|
|
583
|
+
*
|
|
584
|
+
* Containment is decided with `path.relative`, which (unlike a string prefix
|
|
585
|
+
* test) is correct on Windows where drive letters and path case vary.
|
|
586
|
+
*
|
|
587
|
+
* @param {string} projectRoot Absolute project root.
|
|
588
|
+
* @param {string} cwdRel Forward-slash relative path from the offer.
|
|
589
|
+
* @returns {{ok: true, cwd: string, cwd_rel: string}|{ok: false, reason: string}}
|
|
590
|
+
*/
|
|
591
|
+
export function resolveProjectCwd(projectRoot, cwdRel) {
|
|
592
|
+
const root = resolve(projectRoot);
|
|
593
|
+
const normalized = normalizeRelPath(cwdRel);
|
|
594
|
+
const target = resolve(root, normalized.split('/').join(sep));
|
|
595
|
+
const rel = relative(root, target);
|
|
596
|
+
if (rel === '..' || rel.startsWith('..' + sep) || isAbsolute(rel)) {
|
|
597
|
+
return { ok: false, reason: 'CWD_OUTSIDE_PROJECT' };
|
|
598
|
+
}
|
|
599
|
+
return { ok: true, cwd: target, cwd_rel: normalizeRelPath(rel) };
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Make sure a directory exists (used for the state tree, never for the project
|
|
604
|
+
* unless the caller asked for it).
|
|
605
|
+
*
|
|
606
|
+
* @param {string} dir
|
|
607
|
+
*/
|
|
608
|
+
export function ensureDir(dir) {
|
|
609
|
+
mkdirSync(dir, { recursive: true });
|
|
610
|
+
return dir;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/** Absolute parent directory of a file path. */
|
|
614
|
+
export function parentDir(file) {
|
|
615
|
+
return dirname(file);
|
|
616
|
+
}
|