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/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
- const boxLine = lines[task.start] ?? ""
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[task.start] = boxLine.replace(
221
+ lines[range.start] = boxLine.replace(
209
222
  /^(\s*)-\s*\[[ xX]\]/,
210
223
  (_match, indent) => `${indent}- ${status === "done" ? "[x]" : "[ ]"}`,
211
224
  )
212
225
 
213
- let statusLine = -1
214
- for (let i = task.start; i <= task.end + 1 && i < lines.length; i++) {
215
- const f = lines[i]?.match(fieldRe)
216
- if (f && f[1] === "status") {
217
- statusLine = i
218
- break
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
- if (verifyOutput) {
227
- // A task that is marked twice keeps one output block, not two.
228
- const previous = outputBlock(lines, task)
229
- if (previous) lines.splice(previous.start, previous.end - previous.start + 1)
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
- const verifyLine = fieldLine(lines, task, "verify")
232
- const anchor = verifyLine >= 0 ? verifyLine : lastFieldLine(lines, task)
233
- const source = lines[anchor] ?? lines[task.start] ?? ""
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 fieldIndent = (source.match(/^\s*/) ?? [""])[0]
237
- const contIndent = `${fieldIndent} `
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 block = [`${fieldIndent}- output: ${quoted[0].trim()}`, ...quoted.slice(1)]
244
- lines.splice(anchor + 1, 0, ...block)
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 {Task} task
317
+ * @param {{ start: number, end: number }} range
254
318
  * @param {string} name
255
319
  * @returns {number}
256
320
  */
257
- function fieldLine(lines, task, name) {
258
- for (let i = task.start; i <= task.end + 1 && i < lines.length; i++) {
259
- const f = lines[i]?.match(fieldRe)
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 field line of a task, or the task's own line when it has none.
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 {Task} task
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 lastFieldLine(lines, task) {
272
- let last = task.start
273
- for (let i = task.start; i <= task.end + 1 && i < lines.length; i++) {
274
- if (fieldRe.test(lines[i] ?? "")) last = i
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 last
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 {Task} task
426
+ * @param {{ start: number, end: number }} range
283
427
  * @returns {{ start: number, end: number } | null}
284
428
  */
285
- function outputBlock(lines, task) {
286
- const start = fieldLine(lines, task, "output")
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 <= task.end + 1 && i < lines.length; i++) {
290
- if (fieldRe.test(lines[i] ?? "")) break
291
- if (lines[i].trim() && !/^\s{4,}\S/.test(lines[i])) break
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 }
@@ -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
- /** Rotate by size so a long-running project cannot fill its disk. */
37
- function rotate(file: string) {
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 > MAX_BYTES) {
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 = url.match(/github\.com[/:]([^/]+)\/([^/]+?)(?:\.git)?$/)
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
- const userOtp = state.otp ? [`--otp=${state.otp}`] : []
167
- let result = npmCaptured([...args, ...withJson], userOtp)
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 (!done) return result
176
- const doneUrl = done[1]
177
- const auth = /"authUrl"\s*:\s*"([^"]+)"/.exec(result.out)
178
- const authId = /authId=([a-f0-9-]+)/.exec(doneUrl)
179
- const authUrl = auth ? auth[1] : `https://www.npmjs.com/auth/cli/${authId?.[1] ?? ""}`
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
- // Open it if this machine can. The URL is printed either way, because a
182
- // browser that cannot be opened here can be opened anywhere.
183
- //
184
- // OC_TRUST_NO_BROWSER turns the opening off, which is what a test needs:
185
- // a test that opens a browser on the machine it runs on is a test that
186
- // breaks whatever the person at the keyboard was doing.
187
- let opened = false
188
- if (!process.env.OC_TRUST_NO_BROWSER) {
189
- opened = openBrowser(authUrl)
190
- }
191
- console.error("")
192
- console.error(bold(" npm needs to confirm it is you."))
193
- console.error(` ${authUrl}`)
194
- console.error(dim(" Answer there however your npm account is set up - an authenticator"))
195
- console.error(dim(" app, a security key, or a code sent to your email."))
196
- // Saying the page is open when it is not sends the person looking for a
197
- // browser window that was never opened.
198
- console.error(
199
- dim(
200
- opened
201
- ? " This page is open in a browser, or copy the URL above."
202
- : " Open this page in a browser: the URL above is the way in.",
203
- ),
204
- )
205
- console.error("")
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
- const token = await waitForToken(doneUrl)
208
- if (!token) {
209
- console.error(red(" no answer came back, so nothing was changed."))
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
- // The code is used for THIS call only.
213
- result = npmCaptured([...args, ...withJson], [`--otp=${token}`])
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 seconds = Number(process.env.OC_TRUST_WAIT_SECONDS ?? 600)
228
- const attempts = Math.max(1, Math.round(seconds / 5))
229
- for (let i = 0; i < attempts; i++) {
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
- await new Promise((r) => setTimeout(r, 5000))
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
- let parsed
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
- const repo = repoOf(pkg.repository)
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. A code given at the
296
- // prompt is remembered for the rest of the run, so the user types it once.
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`)