opencode-harness-kit 0.5.3 → 0.5.5
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 +207 -17
- package/bin/oc-harness.mjs +278 -45
- package/dist/config.d.ts +5 -0
- package/dist/denylist.d.ts +5 -1
- package/dist/guard.d.ts +17 -1
- package/dist/health.d.ts +69 -7
- package/dist/index.js +452 -68
- package/dist/profile.d.ts +32 -2
- package/dist/telemetry-store.d.ts +22 -0
- package/docs/EXIT-CODES.md +7 -0
- package/docs/KNOWN-LIMITS.md +18 -7
- package/eval/measure-prune.mjs +13 -4
- package/package.json +1 -1
- package/src/config.js +126 -3
- package/src/denylist.js +46 -2
- package/src/guard.ts +78 -7
- package/src/health.js +130 -5
- package/src/lane-usage.js +13 -6
- package/src/profile.js +187 -25
- package/src/prune.ts +58 -13
- package/src/router.ts +217 -17
- package/src/rules.js +329 -19
- package/src/sqlite.js +80 -0
- package/src/tasks.js +193 -40
- package/src/telemetry-store.ts +27 -4
- package/src/trust.js +182 -54
package/src/tasks.js
CHANGED
|
@@ -195,6 +195,12 @@ export const isBlocked = (t) => t.status === "blocked"
|
|
|
195
195
|
* and its output became one string, and the driver handed that string to a
|
|
196
196
|
* shell. A separate field cannot be mistaken for a command.
|
|
197
197
|
*
|
|
198
|
+
* The writes happen in ONE order, and the order is part of the contract: the
|
|
199
|
+
* previous output is removed first, then the status is written, then the new
|
|
200
|
+
* output is written. A field line ends the output block, so a status line
|
|
201
|
+
* written before the old output was removed cut that block in half - and the
|
|
202
|
+
* half that survived was folded into the status by the parser.
|
|
203
|
+
*
|
|
198
204
|
* @param {string} text
|
|
199
205
|
* @param {Task} task
|
|
200
206
|
* @param {{ status: string, verifyOutput?: string }} outcome
|
|
@@ -202,46 +208,100 @@ export const isBlocked = (t) => t.status === "blocked"
|
|
|
202
208
|
*/
|
|
203
209
|
export function markTask(text, task, { status, verifyOutput }) {
|
|
204
210
|
const lines = String(text).split("\n")
|
|
205
|
-
|
|
211
|
+
// The lines of the task as they are RIGHT NOW.
|
|
212
|
+
//
|
|
213
|
+
// Every edit below moves the lines under it, so the range of the task is
|
|
214
|
+
// tracked here. The task the caller parsed was parsed before any of these
|
|
215
|
+
// edits, so its `end` goes stale as soon as one of them lands.
|
|
216
|
+
const range = { start: task.start, end: task.end }
|
|
217
|
+
|
|
218
|
+
const boxLine = lines[range.start] ?? ""
|
|
206
219
|
// The indentation is part of the document: a task nested under a heading
|
|
207
220
|
// keeps its nesting when it is marked.
|
|
208
|
-
lines[
|
|
221
|
+
lines[range.start] = boxLine.replace(
|
|
209
222
|
/^(\s*)-\s*\[[ xX]\]/,
|
|
210
223
|
(_match, indent) => `${indent}- ${status === "done" ? "[x]" : "[ ]"}`,
|
|
211
224
|
)
|
|
212
225
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
226
|
+
// The PREVIOUS output is removed FIRST, before anything else is written
|
|
227
|
+
// into the task.
|
|
228
|
+
//
|
|
229
|
+
// A task that is marked twice keeps one output block, not two.
|
|
230
|
+
//
|
|
231
|
+
// It used to be removed after the status line had been written. The
|
|
232
|
+
// status line is a field line, and a field line ends the output block, so
|
|
233
|
+
// the removal took the FIRST line of the old output and left its wrapped
|
|
234
|
+
// lines behind. The parser folds a wrapped line back into the field above
|
|
235
|
+
// it, so those leftovers were folded into the status - and the status read
|
|
236
|
+
// back as "done old line 2 old line 3" instead of "done". The task was not
|
|
237
|
+
// done; it was done with a story attached.
|
|
238
|
+
if (verifyOutput) {
|
|
239
|
+
const previous = outputBlock(lines, range)
|
|
240
|
+
if (previous) {
|
|
241
|
+
const removed = previous.end - previous.start + 1
|
|
242
|
+
lines.splice(previous.start, removed)
|
|
243
|
+
if (previous.end <= range.end) range.end -= removed
|
|
219
244
|
}
|
|
220
245
|
}
|
|
221
|
-
if (statusLine >= 0) lines[statusLine] = lines[statusLine].replace(
|
|
222
|
-
/(\s-\s+status\s*:).*$/,
|
|
223
|
-
`$1 ${status}`,
|
|
224
|
-
)
|
|
225
246
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
247
|
+
const statusLine = fieldLine(lines, range, "status")
|
|
248
|
+
if (statusLine >= 0) {
|
|
249
|
+
lines[statusLine] = lines[statusLine].replace(
|
|
250
|
+
/(\s-\s+status\s*:).*$/,
|
|
251
|
+
`$1 ${status}`,
|
|
252
|
+
)
|
|
253
|
+
} else {
|
|
254
|
+
// A task with no `status:` field had the status EVAPORATE.
|
|
255
|
+
//
|
|
256
|
+
// The status was only ever written into a status line that already
|
|
257
|
+
// existed, so a task marked blocked without one kept its unticked box
|
|
258
|
+
// and nothing else. The parser reads the status back from that box, so
|
|
259
|
+
// the block became "pending" - and `nextPending()` offered the blocked
|
|
260
|
+
// work again. The driver contract says blocked work is never retried.
|
|
261
|
+
//
|
|
262
|
+
// The status line is written where the other fields are, so the parser
|
|
263
|
+
// reads it back the way it reads them - and AFTER the whole block of
|
|
264
|
+
// the last field, not after the field line itself.
|
|
265
|
+
//
|
|
266
|
+
// Written after the field line, the status line landed INSIDE the block
|
|
267
|
+
// of a field that has wrapped lines under it: the output field, once the
|
|
268
|
+
// driver has run the task. The parser folded the wrapped lines into the
|
|
269
|
+
// status, so it read back as "blocked continued", the block was gone,
|
|
270
|
+
// and the blocked work was offered again.
|
|
271
|
+
const block = lastFieldBlock(lines, range)
|
|
272
|
+
const indent = fieldIndent(lines, range, block.field)
|
|
273
|
+
lines.splice(block.end + 1, 0, `${indent}- status: ${status}`)
|
|
274
|
+
range.end += 1
|
|
275
|
+
}
|
|
230
276
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
277
|
+
if (verifyOutput) {
|
|
278
|
+
// The output goes into its own `output:` field rather than under the
|
|
279
|
+
// `verify:` line. It used to be written as continuation lines under
|
|
280
|
+
// `verify:`, and the parser folded a continuation back into the field
|
|
281
|
+
// above it - so the command and its output became one string, and the
|
|
282
|
+
// driver handed that string to a shell. A separate field cannot be
|
|
283
|
+
// mistaken for a command.
|
|
284
|
+
//
|
|
285
|
+
// It is written after the whole block of the `verify:` field, so it
|
|
286
|
+
// cannot land inside that block either - a wrapped line under the
|
|
287
|
+
// verify field would be folded into the output above it.
|
|
288
|
+
const verifyAt = fieldLine(lines, range, "verify")
|
|
289
|
+
const block =
|
|
290
|
+
verifyAt >= 0
|
|
291
|
+
? { field: verifyAt, end: fieldBlockEnd(lines, range, verifyAt) }
|
|
292
|
+
: lastFieldBlock(lines, range)
|
|
234
293
|
// The field line sits at the same level as the field it belongs to; only
|
|
235
294
|
// the continuation lines under it are indented deeper.
|
|
236
|
-
const
|
|
237
|
-
const contIndent = `${
|
|
295
|
+
const indent = fieldIndent(lines, range, block.field)
|
|
296
|
+
const contIndent = `${indent} `
|
|
238
297
|
const quoted = String(verifyOutput)
|
|
239
298
|
.trimEnd()
|
|
240
299
|
.split("\n")
|
|
241
300
|
.map((l) => `${contIndent}${l}`)
|
|
242
301
|
// The first line rides on the field, the rest are continuations of it.
|
|
243
|
-
const
|
|
244
|
-
lines.splice(
|
|
302
|
+
const written = [`${indent}- output: ${quoted[0].trim()}`, ...quoted.slice(1)]
|
|
303
|
+
lines.splice(block.end + 1, 0, ...written)
|
|
304
|
+
range.end += written.length
|
|
245
305
|
}
|
|
246
306
|
|
|
247
307
|
return lines.join("\n")
|
|
@@ -249,46 +309,139 @@ export function markTask(text, task, { status, verifyOutput }) {
|
|
|
249
309
|
|
|
250
310
|
/**
|
|
251
311
|
* The line a named field sits on, or -1.
|
|
312
|
+
*
|
|
313
|
+
* The scan stops at a task line or a heading: the fields under a nested
|
|
314
|
+
* task belong to that task, not to the one above it.
|
|
315
|
+
*
|
|
252
316
|
* @param {string[]} lines
|
|
253
|
-
* @param {
|
|
317
|
+
* @param {{ start: number, end: number }} range
|
|
254
318
|
* @param {string} name
|
|
255
319
|
* @returns {number}
|
|
256
320
|
*/
|
|
257
|
-
function fieldLine(lines,
|
|
258
|
-
|
|
259
|
-
|
|
321
|
+
function fieldLine(lines, range, name) {
|
|
322
|
+
// The scan starts below the task's own line: that line is the task, not
|
|
323
|
+
// one of its fields.
|
|
324
|
+
for (let i = range.start + 1; i <= range.end + 1 && i < lines.length; i++) {
|
|
325
|
+
const line = lines[i] ?? ""
|
|
326
|
+
if (taskRe.test(line) || headingRe.test(line)) break
|
|
327
|
+
const f = line.match(fieldRe)
|
|
260
328
|
if (f && f[1] === name) return i
|
|
261
329
|
}
|
|
262
330
|
return -1
|
|
263
331
|
}
|
|
264
332
|
|
|
265
333
|
/**
|
|
266
|
-
* The last
|
|
334
|
+
* The last line of a field's block: the field line plus the lines wrapped
|
|
335
|
+
* under it.
|
|
336
|
+
*
|
|
337
|
+
* A field line is where a field is written, but it is not the end of the
|
|
338
|
+
* field: the parser folds a wrapped line back into the value above it, so
|
|
339
|
+
* the end of the block is the end of the value as the parser reads it. A
|
|
340
|
+
* field written inside that block takes the wrapped lines with it.
|
|
341
|
+
*
|
|
267
342
|
* @param {string[]} lines
|
|
268
|
-
* @param {
|
|
343
|
+
* @param {{ start: number, end: number }} range
|
|
344
|
+
* @param {number} fieldAt the line the field is written on
|
|
269
345
|
* @returns {number}
|
|
270
346
|
*/
|
|
271
|
-
function
|
|
272
|
-
let
|
|
273
|
-
for (let i =
|
|
274
|
-
|
|
347
|
+
function fieldBlockEnd(lines, range, fieldAt) {
|
|
348
|
+
let end = fieldAt
|
|
349
|
+
for (let i = fieldAt + 1; i <= range.end + 1 && i < lines.length; i++) {
|
|
350
|
+
const line = lines[i] ?? ""
|
|
351
|
+
// A task, a heading or the next field ends the block.
|
|
352
|
+
if (taskRe.test(line) || headingRe.test(line)) break
|
|
353
|
+
if (fieldRe.test(line)) break
|
|
354
|
+
// A blank line is not part of the value, but it does not end the block
|
|
355
|
+
// either: the parser skips it and folds the wrapped line under it back
|
|
356
|
+
// into the field.
|
|
357
|
+
if (!line.trim()) continue
|
|
358
|
+
// A line that is not indented deeper is not a wrapped line.
|
|
359
|
+
if (!/^\s{4,}\S/.test(line)) break
|
|
360
|
+
end = i
|
|
361
|
+
}
|
|
362
|
+
return end
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* The last field of a task: the line it is written on, and the last line of
|
|
367
|
+
* its block.
|
|
368
|
+
*
|
|
369
|
+
* `field` is -1 when the task has no field of its own, and `end` is then
|
|
370
|
+
* the task's own line - the place a first field is written.
|
|
371
|
+
*
|
|
372
|
+
* @param {string[]} lines
|
|
373
|
+
* @param {{ start: number, end: number }} range
|
|
374
|
+
* @returns {{ field: number, end: number }}
|
|
375
|
+
*/
|
|
376
|
+
function lastFieldBlock(lines, range) {
|
|
377
|
+
let field = -1
|
|
378
|
+
// The scan starts below the task's own line: that line is the task, not
|
|
379
|
+
// one of its fields.
|
|
380
|
+
for (let i = range.start + 1; i <= range.end + 1 && i < lines.length; i++) {
|
|
381
|
+
const line = lines[i] ?? ""
|
|
382
|
+
if (taskRe.test(line) || headingRe.test(line)) break
|
|
383
|
+
if (fieldRe.test(line)) field = i
|
|
275
384
|
}
|
|
276
|
-
return
|
|
385
|
+
if (field < 0) return { field: -1, end: range.start }
|
|
386
|
+
return { field, end: fieldBlockEnd(lines, range, field) }
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* The indentation a field line of a task is written with.
|
|
391
|
+
*
|
|
392
|
+
* The fields of a task sit one level deeper than the task itself, so a
|
|
393
|
+
* field inserted into a task that has none yet follows that convention: a
|
|
394
|
+
* field at the task's own level reads as a task of its own, or as prose the
|
|
395
|
+
* parser skips.
|
|
396
|
+
*
|
|
397
|
+
* @param {string[]} lines
|
|
398
|
+
* @param {{ start: number, end: number }} range
|
|
399
|
+
* @param {number} fieldAt the line the field is written on, or -1 when the
|
|
400
|
+
* task has no field of its own yet
|
|
401
|
+
* @returns {string}
|
|
402
|
+
*/
|
|
403
|
+
function fieldIndent(lines, range, fieldAt) {
|
|
404
|
+
const source = lines[fieldAt >= 0 ? fieldAt : range.start] ?? ""
|
|
405
|
+
const own = (source.match(/^\s*/) ?? [""])[0]
|
|
406
|
+
if (fieldAt < 0) return `${own} `
|
|
407
|
+
return own
|
|
277
408
|
}
|
|
278
409
|
|
|
279
410
|
/**
|
|
280
411
|
* The lines a task's `output:` field occupies, or null when it has none.
|
|
412
|
+
*
|
|
413
|
+
* The block ends at the first line that is NOT part of the output - and a
|
|
414
|
+
* task line or a heading is never part of it, however deeply it is indented.
|
|
415
|
+
*
|
|
416
|
+
* The indentation heuristic alone said otherwise: a nested task indented
|
|
417
|
+
* four spaces or more looked like a continuation of the output above it, so
|
|
418
|
+
* the block swallowed it and the rewrite DELETED it. The file's own header
|
|
419
|
+
* promises that every byte the loop does not own survives a rewrite, and a
|
|
420
|
+
* task the loop never owned was destroyed by marking the task above it.
|
|
421
|
+
*
|
|
422
|
+
* A blank line at the end of the block is not part of it: it belongs to the
|
|
423
|
+
* file, not to the output, and the rewrite must not swallow it.
|
|
424
|
+
*
|
|
281
425
|
* @param {string[]} lines
|
|
282
|
-
* @param {
|
|
426
|
+
* @param {{ start: number, end: number }} range
|
|
283
427
|
* @returns {{ start: number, end: number } | null}
|
|
284
428
|
*/
|
|
285
|
-
function outputBlock(lines,
|
|
286
|
-
const start = fieldLine(lines,
|
|
429
|
+
function outputBlock(lines, range) {
|
|
430
|
+
const start = fieldLine(lines, range, "output")
|
|
287
431
|
if (start < 0) return null
|
|
288
432
|
let end = start
|
|
289
|
-
for (let i = start + 1; i <=
|
|
290
|
-
|
|
291
|
-
|
|
433
|
+
for (let i = start + 1; i <= range.end + 1 && i < lines.length; i++) {
|
|
434
|
+
const line = lines[i] ?? ""
|
|
435
|
+
// A task or a heading ends the block. This is checked BEFORE the
|
|
436
|
+
// indentation heuristic, because a nested task is indented deeper than
|
|
437
|
+
// the field it sits under - that is what nesting means here.
|
|
438
|
+
if (taskRe.test(line) || headingRe.test(line)) break
|
|
439
|
+
if (fieldRe.test(line)) break
|
|
440
|
+
// A blank line is not part of the output, but it does not end the block
|
|
441
|
+
// either: the parser skips it and folds the wrapped line under it back
|
|
442
|
+
// into the output.
|
|
443
|
+
if (!line.trim()) continue
|
|
444
|
+
if (!/^\s{4,}\S/.test(line)) break
|
|
292
445
|
end = i
|
|
293
446
|
}
|
|
294
447
|
return { start, end }
|
package/src/telemetry-store.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
//
|
|
12
12
|
// The file is a JSONL in the project, gitignored, and never a network call.
|
|
13
13
|
|
|
14
|
-
import { appendFileSync, existsSync, mkdirSync, statSync, renameSync } from "node:fs"
|
|
14
|
+
import { appendFileSync, existsSync, mkdirSync, rmSync, statSync, renameSync } from "node:fs"
|
|
15
15
|
import { join } from "node:path"
|
|
16
16
|
|
|
17
17
|
/** Rotate above this, so a long-running project cannot fill its disk. */
|
|
@@ -33,10 +33,33 @@ export function logFiles(dir: string): string[] {
|
|
|
33
33
|
return out
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
/**
|
|
37
|
-
|
|
36
|
+
/**
|
|
37
|
+
* Rotate by size so a long-running project cannot fill its disk.
|
|
38
|
+
*
|
|
39
|
+
* THREE generations are kept: the current file, `.1` and `.2`. The reader
|
|
40
|
+
* enumerates all three, so the writer has to produce all three.
|
|
41
|
+
*
|
|
42
|
+
* The rotation used to rename the current file straight to `.1`, and a
|
|
43
|
+
* rename REPLACES the destination on POSIX - so the previous generation was
|
|
44
|
+
* destroyed on every rotation and `.2` never came into existence. Two
|
|
45
|
+
* rotations after the fact, the oldest history was gone and the third
|
|
46
|
+
* generation the reader looks for was missing: the history a project lost
|
|
47
|
+
* was lost on a schedule nobody was watching.
|
|
48
|
+
*
|
|
49
|
+
* So the oldest is shifted out of the way first, and the oldest of the
|
|
50
|
+
* three is dropped - the disk use stays bounded at three files, and the
|
|
51
|
+
* generation that is dropped is the one that was already two rotations
|
|
52
|
+
* old.
|
|
53
|
+
*
|
|
54
|
+
* @param file the file to rotate
|
|
55
|
+
* @param maxBytes the size above which it is rotated
|
|
56
|
+
*/
|
|
57
|
+
export function rotate(file: string, maxBytes: number = MAX_BYTES) {
|
|
38
58
|
try {
|
|
39
|
-
if (existsSync(file) && statSync(file).size >
|
|
59
|
+
if (existsSync(file) && statSync(file).size > maxBytes) {
|
|
60
|
+
const oldest = `${file}.2`
|
|
61
|
+
if (existsSync(oldest)) rmSync(oldest, { force: true })
|
|
62
|
+
if (existsSync(`${file}.1`)) renameSync(`${file}.1`, oldest)
|
|
40
63
|
renameSync(file, `${file}.1`)
|
|
41
64
|
}
|
|
42
65
|
} catch {
|
package/src/trust.js
CHANGED
|
@@ -32,6 +32,12 @@ import { fileURLToPath } from "node:url"
|
|
|
32
32
|
|
|
33
33
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..")
|
|
34
34
|
|
|
35
|
+
/** How long the two-factor wait polls for an answer. */
|
|
36
|
+
const POLL_MS = 5000
|
|
37
|
+
|
|
38
|
+
/** The window the two-factor wait is allowed to take, in seconds. */
|
|
39
|
+
const DEFAULT_WAIT_SECONDS = 600
|
|
40
|
+
|
|
35
41
|
/** @param {string} s */
|
|
36
42
|
const bold = (s) => `\x1b[1m${s}\x1b[0m`
|
|
37
43
|
/** @param {string} s */
|
|
@@ -52,12 +58,20 @@ const dim = (s) => `\x1b[2m${s}\x1b[0m`
|
|
|
52
58
|
* that form - and getting it wrong is one of the ways a configuration ends up
|
|
53
59
|
* not matching.
|
|
54
60
|
*
|
|
61
|
+
* Every form git and npm both accept is read here: an https URL, an ssh URL,
|
|
62
|
+
* and the `github:owner/repo` shorthand npm itself understands. A trailing
|
|
63
|
+
* slash does not make a different repository either, and it used to throw on
|
|
64
|
+
* one - so a package whose `repository` field ended in a slash could not be
|
|
65
|
+
* set up at all, and the failure was a stack trace rather than a sentence.
|
|
66
|
+
*
|
|
55
67
|
* @param {any} repository
|
|
56
68
|
* @returns {string}
|
|
57
69
|
*/
|
|
58
70
|
export function repoOf(repository) {
|
|
59
71
|
const url = typeof repository === "string" ? repository : repository?.url ?? ""
|
|
60
|
-
const m =
|
|
72
|
+
const m =
|
|
73
|
+
url.match(/github\.com[/:]([^/]+)\/([^/]+?)(?:\.git)?\/?$/) ??
|
|
74
|
+
url.match(/^github:([^/]+)\/([^/]+?)(?:\.git)?\/?$/)
|
|
61
75
|
if (!m) throw new Error(`cannot read a GitHub repository out of ${JSON.stringify(repository)}`)
|
|
62
76
|
return `${m[1]}/${m[2]}`
|
|
63
77
|
}
|
|
@@ -163,8 +177,15 @@ async function npmWithOtp(args, state = {}) {
|
|
|
163
177
|
// JSON body. Without this flag the create cannot be completed at all, and
|
|
164
178
|
// the failure says nothing about why.
|
|
165
179
|
const withJson = args.includes("--json") ? [] : ["--json"]
|
|
166
|
-
|
|
167
|
-
|
|
180
|
+
// The call is made WITHOUT a code first.
|
|
181
|
+
//
|
|
182
|
+
// npm sends a code with the request that was CHALLENGED, and the code used
|
|
183
|
+
// to be attached to every call in the chain instead. So a code was spent on
|
|
184
|
+
// a call that never needed it, and the call that DID need it got a fresh
|
|
185
|
+
// challenge - which the tool then answered with another browser flow. The
|
|
186
|
+
// chain was: challenge, spend the code, challenge again, spend it again,
|
|
187
|
+
// and one browser tab per challenge.
|
|
188
|
+
let result = npmCaptured([...args, ...withJson])
|
|
168
189
|
if (result.code === 0) return result
|
|
169
190
|
|
|
170
191
|
// The URLs come FROM the body, not from a guess. Guessing the done URL
|
|
@@ -172,51 +193,86 @@ async function npmWithOtp(args, state = {}) {
|
|
|
172
193
|
// answers "forbidden" - which is not a token, so the wait runs its full
|
|
173
194
|
// length and then reports that nobody answered.
|
|
174
195
|
const done = /"doneUrl"\s*:\s*"([^"]+)"/.exec(result.out)
|
|
175
|
-
if (
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
196
|
+
if (done) {
|
|
197
|
+
const doneUrl = done[1]
|
|
198
|
+
const auth = /"authUrl"\s*:\s*"([^"]+)"/.exec(result.out)
|
|
199
|
+
const authId = /authId=([a-f0-9-]+)/.exec(doneUrl)
|
|
200
|
+
const authUrl = auth ? auth[1] : `https://www.npmjs.com/auth/cli/${authId?.[1] ?? ""}`
|
|
180
201
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
202
|
+
// Open it if this machine can. The URL is printed either way, because a
|
|
203
|
+
// browser that cannot be opened here can be opened anywhere.
|
|
204
|
+
//
|
|
205
|
+
// OC_TRUST_NO_BROWSER turns the opening off, which is what a test needs:
|
|
206
|
+
// a test that opens a browser on the machine it runs on is a test that
|
|
207
|
+
// breaks whatever the person at the keyboard was doing.
|
|
208
|
+
let opened = false
|
|
209
|
+
if (!process.env.OC_TRUST_NO_BROWSER) {
|
|
210
|
+
opened = openBrowser(authUrl)
|
|
211
|
+
}
|
|
212
|
+
console.error("")
|
|
213
|
+
console.error(bold(" npm needs to confirm it is you."))
|
|
214
|
+
console.error(` ${authUrl}`)
|
|
215
|
+
console.error(dim(" Answer there however your npm account is set up - an authenticator"))
|
|
216
|
+
console.error(dim(" app, a security key, or a code sent to your email."))
|
|
217
|
+
// Saying the page is open when it is not sends the person looking for a
|
|
218
|
+
// browser window that was never opened.
|
|
219
|
+
console.error(
|
|
220
|
+
dim(
|
|
221
|
+
opened
|
|
222
|
+
? " This page is open in a browser, or copy the URL above."
|
|
223
|
+
: " Open this page in a browser: the URL above is the way in.",
|
|
224
|
+
),
|
|
225
|
+
)
|
|
226
|
+
console.error("")
|
|
206
227
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
228
|
+
const token = await waitForToken(doneUrl)
|
|
229
|
+
if (!token) {
|
|
230
|
+
console.error(
|
|
231
|
+
red(` no answer came back within ${waitSeconds()}s, so nothing was changed.`),
|
|
232
|
+
)
|
|
233
|
+
return result
|
|
234
|
+
}
|
|
235
|
+
// The code is used for THIS call only.
|
|
236
|
+
result = npmCaptured([...args, ...withJson], [`--otp=${token}`])
|
|
210
237
|
return result
|
|
211
238
|
}
|
|
212
|
-
|
|
213
|
-
|
|
239
|
+
|
|
240
|
+
// The registry wants an authenticator code rather than a browser, and the
|
|
241
|
+
// user gave one.
|
|
242
|
+
//
|
|
243
|
+
// The code answers THIS challenge and is spent by it, so it is not carried
|
|
244
|
+
// to the next call in the chain: a spent code is not accepted, and sending
|
|
245
|
+
// one produces a fresh challenge rather than the answer.
|
|
246
|
+
if (state.otp && /EOTP|one-time password|need otp/i.test(result.out)) {
|
|
247
|
+
const otp = state.otp
|
|
248
|
+
state.otp = undefined
|
|
249
|
+
result = npmCaptured([...args, ...withJson], [`--otp=${otp}`])
|
|
250
|
+
}
|
|
214
251
|
return result
|
|
215
252
|
}
|
|
216
253
|
|
|
254
|
+
/**
|
|
255
|
+
* The window the two-factor wait is allowed to take.
|
|
256
|
+
*
|
|
257
|
+
* @returns {number} seconds
|
|
258
|
+
*/
|
|
259
|
+
function waitSeconds() {
|
|
260
|
+
const seconds = Number(process.env.OC_TRUST_WAIT_SECONDS ?? DEFAULT_WAIT_SECONDS)
|
|
261
|
+
return Number.isFinite(seconds) && seconds > 0 ? seconds : DEFAULT_WAIT_SECONDS
|
|
262
|
+
}
|
|
263
|
+
|
|
217
264
|
/**
|
|
218
265
|
* Wait for the browser to answer.
|
|
219
266
|
*
|
|
267
|
+
* The wait is BOUNDED by the configured window, and the bound covers the
|
|
268
|
+
* request as well as the number of attempts.
|
|
269
|
+
*
|
|
270
|
+
* The request had no timeout of its own, and undici's default body timeout
|
|
271
|
+
* is five minutes - so an endpoint that sent its headers and then never
|
|
272
|
+
* ended its body held the command open for minutes per attempt, and the
|
|
273
|
+
* loop's own attempt count did not bound it at all. At the default window
|
|
274
|
+
* the worst case was hours, with nothing said about the wait.
|
|
275
|
+
*
|
|
220
276
|
* @param {string} doneUrl
|
|
221
277
|
* @returns {Promise<string | null>}
|
|
222
278
|
*/
|
|
@@ -224,21 +280,80 @@ async function waitForToken(doneUrl) {
|
|
|
224
280
|
// How long to wait for a human. The window is generous, because the person
|
|
225
281
|
// may be answering an email, and it is configurable so a CI run does not
|
|
226
282
|
// hang on it.
|
|
227
|
-
const
|
|
228
|
-
|
|
229
|
-
|
|
283
|
+
const deadline = Date.now() + waitSeconds() * 1000
|
|
284
|
+
while (Date.now() < deadline) {
|
|
285
|
+
// The remaining budget bounds the request itself, so a server that sends
|
|
286
|
+
// its headers and never ends its body cannot hold the command open past
|
|
287
|
+
// the window.
|
|
288
|
+
const controller = new AbortController()
|
|
289
|
+
const timer = setTimeout(() => controller.abort(), Math.max(1, deadline - Date.now()))
|
|
230
290
|
try {
|
|
231
|
-
const res = await fetch(doneUrl)
|
|
291
|
+
const res = await fetch(doneUrl, { signal: controller.signal })
|
|
232
292
|
const body = /** @type {any} */ (await res.json())
|
|
233
293
|
if (body && typeof body.token === "string" && body.token) return body.token
|
|
234
294
|
} catch {
|
|
235
295
|
// Not ready yet, or the network blipped. Try again.
|
|
296
|
+
} finally {
|
|
297
|
+
clearTimeout(timer)
|
|
236
298
|
}
|
|
237
|
-
|
|
299
|
+
// Wait for the next attempt, but never past the deadline.
|
|
300
|
+
const wait = Math.min(POLL_MS, deadline - Date.now())
|
|
301
|
+
if (wait > 0) await new Promise((r) => setTimeout(r, wait))
|
|
238
302
|
}
|
|
239
303
|
return null
|
|
240
304
|
}
|
|
241
305
|
|
|
306
|
+
/**
|
|
307
|
+
* The configurations in npm's own answer.
|
|
308
|
+
*
|
|
309
|
+
* npm prints ONE JSON OBJECT PER CONFIGURATION - one object per line, not
|
|
310
|
+
* an array of objects. The answer was parsed as a single JSON document, so a
|
|
311
|
+
* package with two configurations could not be read at all, and the tool
|
|
312
|
+
* said "could not read the trusted publisher configuration" about a package
|
|
313
|
+
* that had one. That is the opposite of a diagnostic: it hides the
|
|
314
|
+
* configuration the tool exists to repair.
|
|
315
|
+
*
|
|
316
|
+
* Both shapes are read: the ndjson npm actually prints, and the single
|
|
317
|
+
* object or array that other versions print.
|
|
318
|
+
*
|
|
319
|
+
* @param {string} out npm's answer
|
|
320
|
+
* @returns {{ configs: any[], failed: boolean, detail?: string }}
|
|
321
|
+
*/
|
|
322
|
+
export function parseTrustList(out) {
|
|
323
|
+
const text = String(out ?? "").trim()
|
|
324
|
+
if (!text) return { configs: [], failed: false }
|
|
325
|
+
|
|
326
|
+
// One document first: a single object, or an array, on one line or
|
|
327
|
+
// pretty-printed over several.
|
|
328
|
+
let whole = null
|
|
329
|
+
try {
|
|
330
|
+
whole = JSON.parse(text)
|
|
331
|
+
} catch {
|
|
332
|
+
// Not one document. npm prints one object per line when a package has
|
|
333
|
+
// more than one configuration, and that is ndjson - not JSON.
|
|
334
|
+
}
|
|
335
|
+
const documents = whole === null ? text.split("\n").map((l) => l.trim()).filter(Boolean) : [text]
|
|
336
|
+
|
|
337
|
+
/** @type {any[]} */
|
|
338
|
+
const items = []
|
|
339
|
+
for (const doc of documents) {
|
|
340
|
+
let parsed
|
|
341
|
+
try {
|
|
342
|
+
parsed = JSON.parse(doc)
|
|
343
|
+
} catch {
|
|
344
|
+
// A line that is not JSON is npm's own noise, and an answer that
|
|
345
|
+
// cannot be read is a FAILURE - not an empty list. An empty list
|
|
346
|
+
// would say "no trusted publisher is configured", and the tool would
|
|
347
|
+
// then create one that already exists.
|
|
348
|
+
return { configs: [], failed: true, detail: out }
|
|
349
|
+
}
|
|
350
|
+
if (Array.isArray(parsed)) items.push(...parsed)
|
|
351
|
+
else if (parsed && Array.isArray(parsed.configs)) items.push(...parsed.configs)
|
|
352
|
+
else items.push(parsed)
|
|
353
|
+
}
|
|
354
|
+
return { configs: items.filter(Boolean), failed: false }
|
|
355
|
+
}
|
|
356
|
+
|
|
242
357
|
/**
|
|
243
358
|
* The stored trusted publisher configurations for this package.
|
|
244
359
|
*
|
|
@@ -259,14 +374,7 @@ async function listConfigs(name, state = {}) {
|
|
|
259
374
|
return { configs: [], failed: true, detail: out }
|
|
260
375
|
}
|
|
261
376
|
if (!out.trim()) return { configs: [], failed: false }
|
|
262
|
-
|
|
263
|
-
try {
|
|
264
|
-
parsed = JSON.parse(out)
|
|
265
|
-
} catch {
|
|
266
|
-
return { configs: [], failed: true, detail: out }
|
|
267
|
-
}
|
|
268
|
-
const items = Array.isArray(parsed) ? parsed : parsed.configs ?? [parsed]
|
|
269
|
-
return { configs: items.filter(Boolean), failed: false }
|
|
377
|
+
return parseTrustList(out)
|
|
270
378
|
}
|
|
271
379
|
|
|
272
380
|
/**
|
|
@@ -289,11 +397,31 @@ function describe(config) {
|
|
|
289
397
|
export async function main(argv = []) {
|
|
290
398
|
const pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8"))
|
|
291
399
|
const name = pkg.name
|
|
292
|
-
|
|
400
|
+
let repo
|
|
401
|
+
try {
|
|
402
|
+
repo = repoOf(pkg.repository)
|
|
403
|
+
} catch (err) {
|
|
404
|
+
// A diagnostic, not a stack trace.
|
|
405
|
+
//
|
|
406
|
+
// The throw used to escape the command and kill it with a JavaScript
|
|
407
|
+
// stack trace, which says nothing about the package.json field that
|
|
408
|
+
// was wrong - and the person reading it had to work out that the
|
|
409
|
+
// release tool was the thing that died.
|
|
410
|
+
console.error(red(`cannot work out the GitHub repository of ${name}.`))
|
|
411
|
+
console.error(dim(` ${err instanceof Error ? err.message : String(err)}`))
|
|
412
|
+
console.error(dim(" the `repository` field of package.json has to point at a GitHub repository."))
|
|
413
|
+
return 1
|
|
414
|
+
}
|
|
293
415
|
const file = process.env.TRUSTED_PUBLISHER_FILE ?? "ci.yml"
|
|
294
416
|
const checkOnly = argv.includes("--check")
|
|
295
|
-
// A code given on the command line is used as-is
|
|
296
|
-
//
|
|
417
|
+
// A code given on the command line is used as-is, and only for the call
|
|
418
|
+
// that was challenged.
|
|
419
|
+
//
|
|
420
|
+
// There is no prompt for a code. The challenge is answered in a browser,
|
|
421
|
+
// and npm's own helper only offers that when stdin AND stdout are both
|
|
422
|
+
// terminals - which a piped command is not. A code typed at a prompt would
|
|
423
|
+
// never arrive, so the comment about remembering it described a prompt
|
|
424
|
+
// that no longer existed.
|
|
297
425
|
const state = { otp: argv.find((a) => a.startsWith("--otp="))?.slice("--otp=".length) }
|
|
298
426
|
|
|
299
427
|
console.log(`\n${bold("npm trusted publishing")} ${dim(`${name} · ${repo} · ${file}`)}\n`)
|