severally-mcp 2.3.1 → 2.4.2
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.ja.md +52 -17
- package/README.md +52 -16
- package/bin/severally-mcp.mjs +14 -2
- package/docs/install.ja.md +48 -20
- package/docs/install.md +48 -21
- package/package.json +15 -3
- package/plugins/severally/.claude-plugin/plugin.json +1 -1
- package/plugins/severally/.codex-plugin/plugin.json +1 -1
- package/plugins/severally/dist/severally-mcp.mjs +6573 -5978
- package/plugins/severally/skills/_template/reference.md.tmpl +2 -2
- package/plugins/severally/skills/antigravity/severally/reference.md +2 -2
- package/plugins/severally/skills/claude/severally/reference.md +2 -2
- package/plugins/severally/skills/codex/severally/reference.md +2 -2
- package/plugins/severally/skills/opencode/severally/reference.md +2 -2
- package/scripts/build.mjs +76 -68
- package/scripts/doctor.mjs +66 -20
- package/scripts/init-config.mjs +8 -6
- package/scripts/install.mjs +327 -90
- package/scripts/live-check.mjs +62 -23
- package/scripts/live-mcp-check.mjs +52 -16
- package/scripts/test.mjs +10 -4
- package/src/adapters/antigravity-sandbox.mjs +62 -28
- package/src/adapters/antigravity.mjs +111 -30
- package/src/adapters/claude-code.mjs +121 -28
- package/src/adapters/codex.mjs +125 -33
- package/src/adapters/opencode-sandbox.mjs +59 -22
- package/src/adapters/opencode.mjs +144 -30
- package/src/brief.mjs +148 -47
- package/src/consultants.mjs +255 -48
- package/src/cross-spawn-escape.d.ts +7 -0
- package/src/export.mjs +99 -18
- package/src/expose-paths.mjs +310 -50
- package/src/failures.mjs +31 -14
- package/src/jobs.mjs +863 -249
- package/src/jsonc.mjs +11 -3
- package/src/parse-result.mjs +172 -40
- package/src/platform.mjs +56 -8
- package/src/policy.mjs +245 -52
- package/src/redact.mjs +59 -16
- package/src/result-schema.mjs +13 -0
- package/src/run.mjs +225 -30
- package/src/schema.mjs +132 -27
- package/src/server.mjs +178 -81
- package/src/status.mjs +94 -13
- package/src/store.mjs +418 -35
package/src/expose-paths.mjs
CHANGED
|
@@ -22,7 +22,58 @@ import { spawnSync } from 'node:child_process';
|
|
|
22
22
|
import { POLICY, isProtectedPath } from './policy.mjs';
|
|
23
23
|
import { redact } from './redact.mjs';
|
|
24
24
|
|
|
25
|
+
/** @typedef {{ files: number, bytes: number }} ExposeTotals */
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A path left out of the copy, with why: 'ignored', 'symlink', 'protected',
|
|
29
|
+
* 'submodule', 'gone', 'masked' or 'replaced'. `exposed_as` names it where a
|
|
30
|
+
* follow-up carries over an earlier round's entry.
|
|
31
|
+
* @typedef {{ source: string, reason: string, exposed_as?: string | undefined }} SkippedPath
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* One exposed entry as the walk and the copy report it. `kind` is 'file',
|
|
36
|
+
* 'directory', 'diff' or 'change_files'.
|
|
37
|
+
* @typedef {object} ExposeEntry
|
|
38
|
+
* @property {number} index
|
|
39
|
+
* @property {string} source
|
|
40
|
+
* @property {string} kind
|
|
41
|
+
* @property {string} exposedAs
|
|
42
|
+
* @property {number} files
|
|
43
|
+
* @property {number} bytes
|
|
44
|
+
* @property {string[]} paths
|
|
45
|
+
* @property {string | null} [sha256]
|
|
46
|
+
* @property {string | undefined} [base]
|
|
47
|
+
* @property {string[]} [changes]
|
|
48
|
+
* @property {boolean} [changed] a follow-up's copy of a parent path: whether its bytes changed since
|
|
49
|
+
* @property {boolean} [inherited] a follow-up copied it again from the parent round
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* What an expose_paths request comes to: the entries, their totals, and what was left out.
|
|
54
|
+
* @typedef {{ entries: ExposeEntry[], totals: ExposeTotals, skipped: SkippedPath[] }} ExposeManifest
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
/** A path as the lead names it, or as an earlier round numbered it. @typedef {string | { source: string, index: number }} ExposePathSpec */
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* An entry of an earlier round's brief record (briefRecord's `expose_paths.entries`).
|
|
61
|
+
* @typedef {object} ExposedEntryRecord
|
|
62
|
+
* @property {number} index
|
|
63
|
+
* @property {string} source
|
|
64
|
+
* @property {string | undefined} [exposed_as]
|
|
65
|
+
* @property {string | undefined} [kind]
|
|
66
|
+
* @property {string | undefined} [base]
|
|
67
|
+
* @property {string[] | undefined} [changes]
|
|
68
|
+
*/
|
|
69
|
+
|
|
70
|
+
/** @typedef {{ path: string, status: string }} RepoChange */
|
|
71
|
+
|
|
25
72
|
export class ExposeError extends Error {
|
|
73
|
+
/**
|
|
74
|
+
* @param {string} message
|
|
75
|
+
* @param {string} code
|
|
76
|
+
*/
|
|
26
77
|
constructor(message, code) {
|
|
27
78
|
super(message);
|
|
28
79
|
this.name = 'ExposeError';
|
|
@@ -30,7 +81,13 @@ export class ExposeError extends Error {
|
|
|
30
81
|
}
|
|
31
82
|
}
|
|
32
83
|
|
|
33
|
-
/**
|
|
84
|
+
/** @param {unknown} err */
|
|
85
|
+
const messageOf = (err) => (err instanceof Error ? err.message : String(err));
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* A size a reader can judge at a glance; the brief is read, not parsed.
|
|
89
|
+
* @param {number} bytes
|
|
90
|
+
*/
|
|
34
91
|
export function humanSize(bytes) {
|
|
35
92
|
if (bytes < 1024) return `${bytes} B`;
|
|
36
93
|
const kb = bytes / 1024;
|
|
@@ -42,6 +99,7 @@ export function humanSize(bytes) {
|
|
|
42
99
|
// Getting it wrong is survivable in one direction only -- a binary treated as
|
|
43
100
|
// text would be corrupted by redact()'s utf8 round-trip, so the test errs
|
|
44
101
|
// toward calling things binary.
|
|
102
|
+
/** @param {Uint8Array} buf */
|
|
45
103
|
function isBinary(buf) {
|
|
46
104
|
const len = Math.min(buf.length, 8_000);
|
|
47
105
|
for (let i = 0; i < len; i++) if (buf[i] === 0) return true;
|
|
@@ -50,22 +108,27 @@ function isBinary(buf) {
|
|
|
50
108
|
|
|
51
109
|
// Before a file is read: the caps are a few megabytes, and a 1 GB file must
|
|
52
110
|
// be refused by its size, not after it has been read into memory and written.
|
|
111
|
+
/**
|
|
112
|
+
* @param {ExposeTotals} totals
|
|
113
|
+
* @param {number} size
|
|
114
|
+
*/
|
|
53
115
|
function ensureRoom(totals, size) {
|
|
54
116
|
checkBudget({ files: totals.files + 1, bytes: totals.bytes + size });
|
|
55
117
|
}
|
|
56
118
|
|
|
119
|
+
/** @param {ExposeTotals} totals */
|
|
57
120
|
function checkBudget(totals) {
|
|
58
121
|
if (totals.files > POLICY.input.exposeFilesMax) {
|
|
59
122
|
throw new ExposeError(
|
|
60
|
-
`expose_paths reaches more than ${POLICY.input.exposeFilesMax} files, over the file cap; name the files or `
|
|
61
|
-
|
|
123
|
+
`expose_paths reaches more than ${POLICY.input.exposeFilesMax} files, over the file cap; name the files or ` +
|
|
124
|
+
'the subdirectory that actually matters instead of the tree above them',
|
|
62
125
|
'too_large',
|
|
63
126
|
);
|
|
64
127
|
}
|
|
65
128
|
if (totals.bytes > POLICY.input.exposeBytesMax) {
|
|
66
129
|
throw new ExposeError(
|
|
67
|
-
`expose_paths reaches more than ${humanSize(POLICY.input.exposeBytesMax)}, over the size cap; name the files `
|
|
68
|
-
|
|
130
|
+
`expose_paths reaches more than ${humanSize(POLICY.input.exposeBytesMax)}, over the size cap; name the files ` +
|
|
131
|
+
'or the subdirectory that actually matters instead of the tree above them',
|
|
69
132
|
'too_large',
|
|
70
133
|
);
|
|
71
134
|
}
|
|
@@ -74,12 +137,13 @@ function checkBudget(totals) {
|
|
|
74
137
|
// A path the lead named, as opposed to one found while walking: it is refused
|
|
75
138
|
// rather than skipped, because a lead who typed a symlink meant something by
|
|
76
139
|
// it and should hear which entry could not be used.
|
|
140
|
+
/** @param {string} source */
|
|
77
141
|
function statEntry(source) {
|
|
78
142
|
let stat;
|
|
79
143
|
try {
|
|
80
144
|
stat = fs.lstatSync(source);
|
|
81
145
|
} catch (err) {
|
|
82
|
-
throw new ExposeError(`expose_paths entry "${source}" cannot be read: ${err
|
|
146
|
+
throw new ExposeError(`expose_paths entry "${source}" cannot be read: ${messageOf(err)}`, 'not_found');
|
|
83
147
|
}
|
|
84
148
|
if (stat.isSymbolicLink()) {
|
|
85
149
|
throw new ExposeError(`expose_paths entry "${source}" is a symlink; name the path it points at instead`, 'invalid_type');
|
|
@@ -94,6 +158,13 @@ function statEntry(source) {
|
|
|
94
158
|
// accounts for the file (it owns the totals), and the budget is checked after
|
|
95
159
|
// every single one, so an oversized tree stops at the file that crossed the
|
|
96
160
|
// line rather than after the whole thing has been walked or copied.
|
|
161
|
+
/**
|
|
162
|
+
* @param {string} root
|
|
163
|
+
* @param {string} dir
|
|
164
|
+
* @param {(abs: string, rel: string) => void} onFile
|
|
165
|
+
* @param {ExposeTotals} totals
|
|
166
|
+
* @param {SkippedPath[]} skipped
|
|
167
|
+
*/
|
|
97
168
|
function walkDir(root, dir, onFile, totals, skipped) {
|
|
98
169
|
let items;
|
|
99
170
|
try {
|
|
@@ -101,7 +172,7 @@ function walkDir(root, dir, onFile, totals, skipped) {
|
|
|
101
172
|
// entries were created.
|
|
102
173
|
items = fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
103
174
|
} catch (err) {
|
|
104
|
-
throw new ExposeError(`expose_paths entry "${dir}" cannot be read: ${err
|
|
175
|
+
throw new ExposeError(`expose_paths entry "${dir}" cannot be read: ${messageOf(err)}`, 'not_found');
|
|
105
176
|
}
|
|
106
177
|
for (const item of items) {
|
|
107
178
|
const child = path.join(dir, item.name);
|
|
@@ -138,10 +209,18 @@ function walkDir(root, dir, onFile, totals, skipped) {
|
|
|
138
209
|
|
|
139
210
|
// walkDir's order: name by name at every level, not the whole path as one
|
|
140
211
|
// string ("a/x" comes before "a-b" there, after it as a string).
|
|
212
|
+
/**
|
|
213
|
+
* @param {string} a
|
|
214
|
+
* @param {string} b
|
|
215
|
+
*/
|
|
141
216
|
function byPathParts(a, b) {
|
|
142
217
|
const x = a.split('/');
|
|
143
218
|
const y = b.split('/');
|
|
144
|
-
for (
|
|
219
|
+
for (const [i, part] of x.entries()) {
|
|
220
|
+
const other = y[i];
|
|
221
|
+
if (other === undefined) break;
|
|
222
|
+
if (part !== other) return part < other ? -1 : 1;
|
|
223
|
+
}
|
|
145
224
|
return x.length - y.length;
|
|
146
225
|
}
|
|
147
226
|
|
|
@@ -152,13 +231,23 @@ function byPathParts(a, b) {
|
|
|
152
231
|
// submodule, or reach outside the directory. False when this is not a work
|
|
153
232
|
// tree, git is missing or lists nothing (an ignored directory the lead named
|
|
154
233
|
// on purpose): the caller walks it instead.
|
|
234
|
+
/**
|
|
235
|
+
* @param {string} dir
|
|
236
|
+
* @param {string} exposedAs
|
|
237
|
+
* @param {(abs: string, rel: string) => void} onFile
|
|
238
|
+
* @param {ExposeTotals} totals
|
|
239
|
+
* @param {SkippedPath[]} skipped
|
|
240
|
+
* @returns {boolean}
|
|
241
|
+
*/
|
|
155
242
|
function walkGit(dir, exposedAs, onFile, totals, skipped) {
|
|
156
243
|
let listed;
|
|
157
244
|
let ignored;
|
|
158
245
|
try {
|
|
159
246
|
listed = [...new Set(nulSplit(git(dir, ['ls-files', '-z', '--cached', '--others', '--exclude-standard'])))];
|
|
160
247
|
ignored = nulSplit(git(dir, ['ls-files', '-z', '--others', '--ignored', '--exclude-standard', '--directory']));
|
|
161
|
-
} catch {
|
|
248
|
+
} catch {
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
162
251
|
if (listed.length === 0) return false;
|
|
163
252
|
for (const p of ignored) skipped.push({ source: path.join(dir, p), reason: 'ignored' });
|
|
164
253
|
if (fs.existsSync(path.join(dir, '.git'))) skipped.push({ source: path.join(dir, '.git'), reason: 'ignored' });
|
|
@@ -169,21 +258,43 @@ function walkGit(dir, exposedAs, onFile, totals, skipped) {
|
|
|
169
258
|
// walkDir never enters a protected directory (.env.d/, .ssh/ ...); a
|
|
170
259
|
// listed file is checked with each directory above it for the same reason.
|
|
171
260
|
const parts = rel.split('/');
|
|
172
|
-
const guarded = parts
|
|
261
|
+
const guarded = parts
|
|
262
|
+
.slice(0, -1)
|
|
263
|
+
.map((_, i) => path.join(dir, ...parts.slice(0, i + 1)))
|
|
264
|
+
.find(isProtectedPath);
|
|
173
265
|
if (guarded) {
|
|
174
266
|
if (!protectedDirs.has(guarded)) skipped.push({ source: guarded, reason: 'protected' });
|
|
175
267
|
protectedDirs.add(guarded);
|
|
176
268
|
continue;
|
|
177
269
|
}
|
|
178
|
-
if (isProtectedPath(abs)) {
|
|
270
|
+
if (isProtectedPath(abs)) {
|
|
271
|
+
skipped.push({ source: abs, reason: 'protected' });
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
179
274
|
let stat;
|
|
180
275
|
// Deleted in the work tree but still in the index: not part of the tree
|
|
181
276
|
// as it is now, so not part of the copy either.
|
|
182
|
-
try {
|
|
277
|
+
try {
|
|
278
|
+
stat = fs.lstatSync(abs);
|
|
279
|
+
} catch {
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
183
282
|
// A directory here is a submodule (or a nested repository): its own history, not this one's files.
|
|
184
|
-
if (stat.isDirectory()) {
|
|
185
|
-
|
|
186
|
-
|
|
283
|
+
if (stat.isDirectory()) {
|
|
284
|
+
skipped.push({ source: abs, reason: 'submodule' });
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
const inside = (() => {
|
|
288
|
+
try {
|
|
289
|
+
return fs.realpathSync(abs).startsWith(realDir + path.sep);
|
|
290
|
+
} catch {
|
|
291
|
+
return false;
|
|
292
|
+
}
|
|
293
|
+
})();
|
|
294
|
+
if (stat.isSymbolicLink() || !inside) {
|
|
295
|
+
skipped.push({ source: abs, reason: 'symlink' });
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
187
298
|
if (!stat.isFile()) continue;
|
|
188
299
|
onFile(abs, path.normalize(rel));
|
|
189
300
|
checkBudget(totals);
|
|
@@ -193,8 +304,14 @@ function walkGit(dir, exposedAs, onFile, totals, skipped) {
|
|
|
193
304
|
|
|
194
305
|
// Each path is a string, or { source, index } to keep the name an earlier
|
|
195
306
|
// round gave it.
|
|
307
|
+
/**
|
|
308
|
+
* @param {ExposePathSpec[]} exposePaths
|
|
309
|
+
* @param {(abs: string, rel: string, index: number, source: string, totals: ExposeTotals) => void} onFile
|
|
310
|
+
* @returns {ExposeManifest}
|
|
311
|
+
*/
|
|
196
312
|
function walkEntries(exposePaths, onFile) {
|
|
197
313
|
const totals = { files: 0, bytes: 0 };
|
|
314
|
+
/** @type {SkippedPath[]} */
|
|
198
315
|
const skipped = [];
|
|
199
316
|
const entries = exposePaths.map((p, i) => {
|
|
200
317
|
const { source, index } = typeof p === 'string' ? { source: p, index: i } : p;
|
|
@@ -205,6 +322,7 @@ function walkEntries(exposePaths, onFile) {
|
|
|
205
322
|
const before = { files: totals.files, bytes: totals.bytes };
|
|
206
323
|
// What a directory holds, for the brief: not every consultant has a tool
|
|
207
324
|
// that lists a directory, and a name it cannot see is a file it never reads.
|
|
325
|
+
/** @type {string[]} */
|
|
208
326
|
const paths = [];
|
|
209
327
|
if (isProtectedPath(source)) {
|
|
210
328
|
skipped.push({ source, reason: 'protected' });
|
|
@@ -212,6 +330,7 @@ function walkEntries(exposePaths, onFile) {
|
|
|
212
330
|
onFile(source, '', index, source, totals);
|
|
213
331
|
checkBudget(totals);
|
|
214
332
|
} else {
|
|
333
|
+
/** @type {(abs: string, rel: string) => void} */
|
|
215
334
|
const add = (abs, rel) => {
|
|
216
335
|
paths.push(rel.split(path.sep).join('/'));
|
|
217
336
|
onFile(abs, rel, index, source, totals);
|
|
@@ -237,6 +356,8 @@ function walkEntries(exposePaths, onFile) {
|
|
|
237
356
|
* real copy may write slightly fewer bytes once a text file's
|
|
238
357
|
* credential-shaped strings are masked, which only ever shrinks what was
|
|
239
358
|
* already reported.
|
|
359
|
+
* @param {ExposePathSpec[] | null | undefined} exposePaths
|
|
360
|
+
* @returns {ExposeManifest | null}
|
|
240
361
|
*/
|
|
241
362
|
export function previewExposePaths(exposePaths) {
|
|
242
363
|
if (!exposePaths || exposePaths.length === 0) return null;
|
|
@@ -253,11 +374,15 @@ export function previewExposePaths(exposePaths) {
|
|
|
253
374
|
* are copied unchanged, since a utf8 round-trip would corrupt them. Walks
|
|
254
375
|
* from scratch and re-checks the caps, so a tree that grew or vanished since
|
|
255
376
|
* the preview fails here instead of being copied in part.
|
|
377
|
+
* @param {string} workdir
|
|
378
|
+
* @param {ExposePathSpec[] | null | undefined} exposePaths
|
|
379
|
+
* @returns {ExposeManifest | null}
|
|
256
380
|
*/
|
|
257
381
|
export function materializeExposedPaths(workdir, exposePaths) {
|
|
258
382
|
if (!exposePaths || exposePaths.length === 0) return null;
|
|
259
383
|
const root = path.join(workdir, 'workspace');
|
|
260
384
|
fs.mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
385
|
+
/** @type {import('node:crypto').Hash[]} */
|
|
261
386
|
const hashes = [];
|
|
262
387
|
const manifest = walkEntries(exposePaths, (abs, rel, index, source, totals) => {
|
|
263
388
|
ensureRoom(totals, fs.statSync(abs).size);
|
|
@@ -273,6 +398,10 @@ export function materializeExposedPaths(workdir, exposePaths) {
|
|
|
273
398
|
|
|
274
399
|
// The bytes a consultant gets for one file, written to dest: valid-utf8 text
|
|
275
400
|
// through redact(), anything else unchanged.
|
|
401
|
+
/**
|
|
402
|
+
* @param {string} abs
|
|
403
|
+
* @param {string} dest
|
|
404
|
+
*/
|
|
276
405
|
function copyMasked(abs, dest) {
|
|
277
406
|
const buf = fs.readFileSync(abs);
|
|
278
407
|
let out = buf;
|
|
@@ -295,27 +424,59 @@ function copyMasked(abs, dest) {
|
|
|
295
424
|
// sha256 covers the copies in path order, so the same files give the same
|
|
296
425
|
// hash however they were found. `gone` is where a path that no longer exists
|
|
297
426
|
// goes; by default it is dropped, as a change listing a vanished path is.
|
|
427
|
+
/**
|
|
428
|
+
* @param {string} workdir
|
|
429
|
+
* @param {string} top
|
|
430
|
+
* @param {RepoChange[]} changes
|
|
431
|
+
* @param {ExposeManifest} manifest
|
|
432
|
+
* @param {(c: RepoChange) => void} shown
|
|
433
|
+
* @param {((c: RepoChange) => void) | null} [gone]
|
|
434
|
+
*/
|
|
298
435
|
function copyRepoFiles(workdir, top, changes, manifest, shown, gone = null) {
|
|
299
436
|
const root = path.join(workdir, 'workspace', 'repo');
|
|
300
437
|
// Made even when nothing is copied (a change that only deletes): each
|
|
301
438
|
// member's workspace is copied from here.
|
|
302
439
|
fs.mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
303
440
|
const realTop = fs.realpathSync(top);
|
|
441
|
+
/** @type {{ path: string, size: number }[]} */
|
|
304
442
|
const copied = [];
|
|
305
443
|
for (const c of changes) {
|
|
306
444
|
const abs = path.join(top, c.path);
|
|
307
|
-
if (isProtectedPath(abs)) {
|
|
308
|
-
|
|
445
|
+
if (isProtectedPath(abs)) {
|
|
446
|
+
manifest.skipped.push({ source: abs, reason: 'protected' });
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
if (c.status === 'D') {
|
|
450
|
+
shown(c);
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
309
453
|
let stat;
|
|
310
454
|
// When `gone` is given, the paths are an earlier round's, and a skipped
|
|
311
455
|
// one carries the name it had there.
|
|
312
456
|
const named = gone ? { exposed_as: `workspace/repo/${c.path}` } : {};
|
|
313
|
-
try {
|
|
457
|
+
try {
|
|
458
|
+
stat = fs.lstatSync(abs);
|
|
459
|
+
} catch {
|
|
460
|
+
gone?.(c);
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
314
463
|
// A directory on the way that is now a link out of the repository.
|
|
315
|
-
const inside = (() => {
|
|
316
|
-
|
|
464
|
+
const inside = (() => {
|
|
465
|
+
try {
|
|
466
|
+
return fs.realpathSync(abs).startsWith(realTop + path.sep);
|
|
467
|
+
} catch {
|
|
468
|
+
return false;
|
|
469
|
+
}
|
|
470
|
+
})();
|
|
471
|
+
if (stat.isSymbolicLink() || !inside) {
|
|
472
|
+
manifest.skipped.push({ source: abs, reason: 'symlink', ...named });
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
317
475
|
// A submodule: its commit line stays in the diff, nothing to copy.
|
|
318
|
-
if (!stat.isFile()) {
|
|
476
|
+
if (!stat.isFile()) {
|
|
477
|
+
shown(c);
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
319
480
|
shown(c);
|
|
320
481
|
copied.push({ path: c.path, size: stat.size });
|
|
321
482
|
}
|
|
@@ -341,24 +502,49 @@ function copyRepoFiles(workdir, top, changes, manifest, shown, gone = null) {
|
|
|
341
502
|
* ('masked'). The parent's diff is not taken again: the files it touched are
|
|
342
503
|
* re-copied as they are now (`diffs`, for reexposeChange), because a diff
|
|
343
504
|
* against a base that has since moved would be a different change.
|
|
505
|
+
* @param {ExposedEntryRecord[]} parentEntries
|
|
506
|
+
* @param {string[]} exposePaths
|
|
344
507
|
*/
|
|
345
508
|
export function planInheritance(parentEntries, exposePaths) {
|
|
509
|
+
/** @type {{ source: string, index: number }[]} */
|
|
346
510
|
const paths = [];
|
|
511
|
+
/** @type {SkippedPath[]} */
|
|
347
512
|
const skipped = [];
|
|
513
|
+
/** @type {ExposedEntryRecord[]} */
|
|
348
514
|
const diffs = [];
|
|
349
|
-
|
|
515
|
+
/** @param {string} p */
|
|
516
|
+
const exists = (p) => {
|
|
517
|
+
try {
|
|
518
|
+
fs.lstatSync(p);
|
|
519
|
+
return true;
|
|
520
|
+
} catch {
|
|
521
|
+
return false;
|
|
522
|
+
}
|
|
523
|
+
};
|
|
350
524
|
for (const e of parentEntries) {
|
|
351
|
-
if (String(e.source).includes('[REDACTED')) {
|
|
352
|
-
|
|
353
|
-
|
|
525
|
+
if (String(e.source).includes('[REDACTED')) {
|
|
526
|
+
skipped.push({ source: e.source, reason: 'masked', exposed_as: e.exposed_as });
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
if (!exists(e.source)) {
|
|
530
|
+
skipped.push({ source: e.source, reason: 'gone', exposed_as: e.exposed_as });
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
if (e.kind === 'diff' || e.kind === 'change_files') {
|
|
534
|
+
diffs.push(e);
|
|
535
|
+
continue;
|
|
536
|
+
}
|
|
354
537
|
paths.push({ source: e.source, index: e.index });
|
|
355
538
|
}
|
|
356
539
|
// New paths are numbered after every name the earlier round used.
|
|
357
540
|
let next = parentEntries.reduce((n, e) => Math.max(n, e.index + 1), 0);
|
|
358
541
|
for (const p of exposePaths) if (!paths.some((x) => x.source === p)) paths.push({ source: p, index: next++ });
|
|
359
542
|
if (paths.length > POLICY.input.exposePathsMax) {
|
|
360
|
-
throw new ExposeError(
|
|
361
|
-
|
|
543
|
+
throw new ExposeError(
|
|
544
|
+
`expose_from_parent and expose_paths together name ${paths.length} entries, over the cap of ` +
|
|
545
|
+
`${POLICY.input.exposePathsMax}; name fewer new paths`,
|
|
546
|
+
'too_large',
|
|
547
|
+
);
|
|
362
548
|
}
|
|
363
549
|
return { paths, skipped, diffs };
|
|
364
550
|
}
|
|
@@ -367,27 +553,53 @@ export function planInheritance(parentEntries, exposePaths) {
|
|
|
367
553
|
* The files an earlier round's change touched, copied again under
|
|
368
554
|
* workspace/repo/ as they are now, added to `manifest` as a 'change_files'
|
|
369
555
|
* entry. Read from that round's record of the change, not from git.
|
|
556
|
+
* @param {string} workdir
|
|
557
|
+
* @param {ExposedEntryRecord} entry
|
|
558
|
+
* @param {ExposeManifest} manifest
|
|
559
|
+
* @returns {boolean}
|
|
370
560
|
*/
|
|
371
561
|
export function reexposeChange(workdir, entry, manifest) {
|
|
562
|
+
/** @type {RepoChange[]} */
|
|
372
563
|
const changes = [];
|
|
373
564
|
for (const c of entry.changes ?? []) {
|
|
374
565
|
const m = /^(.*) \(([^()]+)\)$/.exec(c);
|
|
375
|
-
const p = m
|
|
376
|
-
if (p.includes('[REDACTED')) {
|
|
566
|
+
const p = m?.[1] ?? c;
|
|
567
|
+
if (p.includes('[REDACTED')) {
|
|
568
|
+
manifest.skipped.push({ source: path.join(entry.source, p), reason: 'masked', exposed_as: `workspace/repo/${p}` });
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
377
571
|
// A file the change deleted that is back now is copied as it is.
|
|
378
572
|
const deleted = m?.[2] === 'deleted';
|
|
379
573
|
let back = false;
|
|
380
|
-
if (deleted)
|
|
574
|
+
if (deleted)
|
|
575
|
+
try {
|
|
576
|
+
fs.lstatSync(path.join(entry.source, p));
|
|
577
|
+
back = true;
|
|
578
|
+
} catch {
|
|
579
|
+
/* still deleted */
|
|
580
|
+
}
|
|
381
581
|
changes.push({ path: p, status: deleted ? (back ? 'restored' : 'D') : (m?.[2] ?? 'M') });
|
|
382
582
|
}
|
|
383
583
|
const before = { ...manifest.totals };
|
|
584
|
+
/** @type {RepoChange[]} */
|
|
384
585
|
const shown = [];
|
|
385
|
-
const { copied, sha256 } = copyRepoFiles(
|
|
386
|
-
|
|
586
|
+
const { copied, sha256 } = copyRepoFiles(
|
|
587
|
+
workdir,
|
|
588
|
+
entry.source,
|
|
589
|
+
changes,
|
|
590
|
+
manifest,
|
|
591
|
+
(c) => shown.push(c),
|
|
592
|
+
(c) =>
|
|
593
|
+
manifest.skipped.push({ source: path.join(entry.source, c.path), reason: 'gone', exposed_as: `workspace/repo/${c.path}` }),
|
|
594
|
+
);
|
|
387
595
|
// Nothing of the change is left to show: no entry, so a follow-up with
|
|
388
596
|
// nothing else to go on is refused rather than sent with an empty copy.
|
|
389
597
|
if (shown.length === 0) {
|
|
390
|
-
try {
|
|
598
|
+
try {
|
|
599
|
+
fs.rmdirSync(path.join(workdir, 'workspace', 'repo'));
|
|
600
|
+
} catch {
|
|
601
|
+
/* holds another entry's files */
|
|
602
|
+
}
|
|
391
603
|
return false;
|
|
392
604
|
}
|
|
393
605
|
manifest.entries.push({
|
|
@@ -410,20 +622,31 @@ export function reexposeChange(workdir, entry, manifest) {
|
|
|
410
622
|
// switched off, pathspecs are literal, and no shell is involved.
|
|
411
623
|
// ponytail: a clean filter (filter.<driver>.clean in .git/config) still runs
|
|
412
624
|
// on `git diff` of the working tree; .git/config is local, not cloned.
|
|
625
|
+
/**
|
|
626
|
+
* @param {string} cwd
|
|
627
|
+
* @param {string[]} args
|
|
628
|
+
*/
|
|
413
629
|
function git(cwd, args) {
|
|
414
630
|
// Without the server's own GIT_* (GIT_DIR, GIT_INDEX_FILE ... would point
|
|
415
631
|
// at another repository), and without taking the lead's index lock.
|
|
416
632
|
const env = Object.fromEntries(Object.entries(process.env).filter(([k]) => !k.toUpperCase().startsWith('GIT_')));
|
|
417
633
|
const r = spawnSync('git', ['--literal-pathspecs', '-c', 'core.fsmonitor=', '-C', cwd, ...args], {
|
|
418
|
-
timeout: 20_000,
|
|
634
|
+
timeout: 20_000,
|
|
635
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
636
|
+
windowsHide: true,
|
|
637
|
+
env: { ...env, GIT_OPTIONAL_LOCKS: '0' },
|
|
419
638
|
});
|
|
420
639
|
if (r.error || r.status !== 0) {
|
|
421
|
-
const why =
|
|
640
|
+
const why =
|
|
641
|
+
r.error && 'code' in r.error && r.error.code === 'ENOENT'
|
|
642
|
+
? 'git is not on PATH'
|
|
643
|
+
: String(r.stderr ?? r.error?.message ?? '').trim();
|
|
422
644
|
throw new ExposeError(`context.expose_diff: git ${args[0]} failed in "${cwd}": ${why}`, 'not_found');
|
|
423
645
|
}
|
|
424
646
|
return r.stdout;
|
|
425
647
|
}
|
|
426
648
|
|
|
649
|
+
/** @param {Buffer} buf */
|
|
427
650
|
const nulSplit = (buf) => buf.toString('utf8').split('\0').filter(Boolean);
|
|
428
651
|
|
|
429
652
|
/**
|
|
@@ -433,22 +656,38 @@ const nulSplit = (buf) => buf.toString('utf8').split('\0').filter(Boolean);
|
|
|
433
656
|
* name the copies; deleted files are listed, not copied. Protected paths are
|
|
434
657
|
* left out of the copy and of the diff. Returns the diff text, masked and cut
|
|
435
658
|
* to maxChars, for the brief.
|
|
659
|
+
* @param {string} workdir
|
|
660
|
+
* @param {{ repo: string, base?: string | undefined }} source
|
|
661
|
+
* @param {ExposeManifest} manifest
|
|
662
|
+
* @param {number} maxChars
|
|
663
|
+
* @returns {string}
|
|
436
664
|
*/
|
|
437
665
|
export function exposeDiff(workdir, { repo, base = 'HEAD' }, manifest, maxChars) {
|
|
438
666
|
const top = git(repo, ['rev-parse', '--show-toplevel']).toString('utf8').trim();
|
|
439
667
|
git(top, ['rev-parse', '--verify', `${base}^{commit}`]);
|
|
440
|
-
const tokens = nulSplit(
|
|
441
|
-
'--no-textconv', base, '--'])
|
|
668
|
+
const tokens = nulSplit(
|
|
669
|
+
git(top, ['diff', '--name-status', '-z', '--no-renames', '--no-relative', '--no-ext-diff', '--no-textconv', base, '--']),
|
|
670
|
+
);
|
|
671
|
+
/** @type {RepoChange[]} */
|
|
442
672
|
const changes = [];
|
|
443
|
-
for (let i = 0; i + 1 < tokens.length; i += 2)
|
|
444
|
-
|
|
673
|
+
for (let i = 0; i + 1 < tokens.length; i += 2) {
|
|
674
|
+
const status = tokens[i];
|
|
675
|
+
const file = tokens[i + 1];
|
|
676
|
+
if (status !== undefined && file !== undefined) changes.push({ status, path: file });
|
|
677
|
+
}
|
|
678
|
+
for (const p of nulSplit(git(top, ['ls-files', '--others', '--exclude-standard', '-z'])))
|
|
679
|
+
changes.push({ status: 'untracked', path: p });
|
|
445
680
|
|
|
446
681
|
const before = { ...manifest.totals };
|
|
682
|
+
/** @type {RepoChange[]} */
|
|
447
683
|
const shown = [];
|
|
448
684
|
const { copied, sha256 } = copyRepoFiles(workdir, top, changes, manifest, (c) => shown.push(c));
|
|
449
685
|
if (shown.length === 0) {
|
|
450
|
-
throw new ExposeError(
|
|
451
|
-
|
|
686
|
+
throw new ExposeError(
|
|
687
|
+
`context.expose_diff: no change against ${base} to show` +
|
|
688
|
+
(changes.length ? ' (every changed file is a protected path)' : ''),
|
|
689
|
+
'not_found',
|
|
690
|
+
);
|
|
452
691
|
}
|
|
453
692
|
manifest.entries.push({
|
|
454
693
|
index: manifest.entries.reduce((n, e) => Math.max(n, e.index + 1), 0),
|
|
@@ -461,7 +700,9 @@ export function exposeDiff(workdir, { repo, base = 'HEAD' }, manifest, maxChars)
|
|
|
461
700
|
// The copies, as paths a consultant can open; the change itself, with
|
|
462
701
|
// what happened to each path, is listed apart.
|
|
463
702
|
paths: copied,
|
|
464
|
-
changes: shown
|
|
703
|
+
changes: shown
|
|
704
|
+
.map((c) => `${c.path} (${c.status === 'untracked' ? 'untracked' : c.status === 'D' ? 'deleted' : c.status})`)
|
|
705
|
+
.sort(),
|
|
465
706
|
sha256,
|
|
466
707
|
});
|
|
467
708
|
|
|
@@ -474,18 +715,37 @@ export function exposeDiff(workdir, { repo, base = 'HEAD' }, manifest, maxChars)
|
|
|
474
715
|
// without its old content.
|
|
475
716
|
const tracked = new Set(shown.filter((c) => c.status !== 'untracked').map((c) => c.path));
|
|
476
717
|
const full = tracked.size
|
|
477
|
-
? git(top, [
|
|
478
|
-
|
|
718
|
+
? git(top, [
|
|
719
|
+
'diff',
|
|
720
|
+
'--no-ext-diff',
|
|
721
|
+
'--no-textconv',
|
|
722
|
+
'--no-color',
|
|
723
|
+
'--src-prefix=a/',
|
|
724
|
+
'--dst-prefix=b/',
|
|
725
|
+
'--no-relative',
|
|
726
|
+
'--submodule=short',
|
|
727
|
+
'--irreversible-delete',
|
|
728
|
+
base,
|
|
729
|
+
'--',
|
|
730
|
+
]).toString('utf8')
|
|
479
731
|
: '';
|
|
480
732
|
// A section whose header names no shown path (a protected one, or one git
|
|
481
733
|
// had to quote) is dropped rather than guessed at.
|
|
482
|
-
let text = redact(
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
734
|
+
let text = redact(
|
|
735
|
+
full
|
|
736
|
+
.split(/^(?=diff --git )/m)
|
|
737
|
+
.filter((sec) => {
|
|
738
|
+
const rest = sec.slice('diff --git '.length, sec.indexOf('\n'));
|
|
739
|
+
const p = rest.slice(2, 2 + (rest.length - 1) / 2 - 2);
|
|
740
|
+
return tracked.has(p) && rest === `a/${p} b/${p}`;
|
|
741
|
+
})
|
|
742
|
+
.join(''),
|
|
743
|
+
);
|
|
487
744
|
if (text.length > maxChars) {
|
|
488
|
-
text =
|
|
745
|
+
text =
|
|
746
|
+
maxChars < 500
|
|
747
|
+
? ''
|
|
748
|
+
: `${text.slice(0, maxChars - 200)}\n... [diff cut by severally: ${text.length} characters in all; every changed file is under workspace/repo/]`;
|
|
489
749
|
}
|
|
490
750
|
return text;
|
|
491
751
|
}
|