opencode-harness-kit 0.4.2 → 0.4.4

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.
Files changed (73) hide show
  1. package/CHANGELOG.md +544 -19
  2. package/CONTRIBUTING.md +242 -0
  3. package/GLOBAL.md +34 -5
  4. package/MIGRATION.md +151 -0
  5. package/README.md +294 -37
  6. package/SECURITY.md +30 -0
  7. package/bin/baseline.mjs +15 -7
  8. package/bin/git-hooks/pre-commit +41 -9
  9. package/bin/oc-harness.mjs +1265 -82
  10. package/dist/config.d.ts +127 -0
  11. package/dist/denylist.d.ts +33 -0
  12. package/dist/effort.d.ts +64 -0
  13. package/dist/guard.d.ts +23 -0
  14. package/dist/health.d.ts +43 -0
  15. package/dist/index.d.ts +9 -0
  16. package/dist/index.js +532 -149
  17. package/dist/plugin.d.ts +15 -0
  18. package/dist/profile.d.ts +399 -0
  19. package/dist/project.d.ts +8 -0
  20. package/dist/prune.d.ts +6 -0
  21. package/dist/router.d.ts +3 -0
  22. package/dist/rules.d.ts +109 -0
  23. package/dist/safe.d.ts +7 -0
  24. package/dist/telemetry-store.d.ts +12 -0
  25. package/dist/telemetry.d.ts +3 -0
  26. package/docs/EXIT-CODES.md +71 -0
  27. package/docs/KNOWN-LIMITS.md +45 -21
  28. package/docs/RESEARCH.md +9 -4
  29. package/docs/ROUTING.md +53 -8
  30. package/eval/RESULTS.md +443 -0
  31. package/eval/ab.mjs +743 -0
  32. package/eval/attribute.mjs +170 -0
  33. package/eval/flags.mjs +88 -0
  34. package/eval/measure-install.mjs +323 -0
  35. package/eval/measure-prune.mjs +339 -0
  36. package/eval/metrics.js +79 -0
  37. package/eval/premises.mjs +200 -0
  38. package/eval/prepare.mjs +233 -0
  39. package/eval/run.mjs +376 -0
  40. package/eval/tasks-self.json +81 -0
  41. package/eval/tasks-ts.json +50 -0
  42. package/eval/tasks.json +83 -0
  43. package/eval/thresholds.mjs +134 -0
  44. package/eval/verify.mjs +287 -0
  45. package/package.json +44 -17
  46. package/profiles/fleet-all.json +6 -1
  47. package/profiles/fleet-safe.json +7 -3
  48. package/profiles/schema.json +182 -51
  49. package/prompts/build.md +4 -2
  50. package/skills/space-build/SKILL.md +4 -2
  51. package/skills/space-finish/SKILL.md +6 -2
  52. package/skills/space-plan/reference/task-template.md +13 -5
  53. package/src/config.js +182 -32
  54. package/src/denylist.js +95 -0
  55. package/src/driver/run.mjs +81 -16
  56. package/src/effort.js +15 -4
  57. package/src/guard.ts +165 -13
  58. package/src/health.js +80 -0
  59. package/src/index.ts +15 -4
  60. package/src/lane-usage.js +72 -18
  61. package/src/plugin-type-check.ts +38 -0
  62. package/src/plugin.ts +39 -0
  63. package/src/profile.js +225 -26
  64. package/src/project.js +47 -0
  65. package/src/prune.ts +110 -31
  66. package/src/router.ts +32 -63
  67. package/src/rules.js +99 -7
  68. package/src/safe.ts +27 -0
  69. package/src/sqlite.js +144 -0
  70. package/src/tasks.js +112 -29
  71. package/src/telemetry-store.ts +62 -0
  72. package/src/telemetry.ts +60 -57
  73. package/src/trust.js +70 -9
package/src/tasks.js CHANGED
@@ -6,7 +6,7 @@
6
6
  // the goal, the constraints, the decisions, any prose a human added -- survives
7
7
  // a rewrite untouched.
8
8
 
9
- export const TASK_FIELDS = ["files", "change", "verify", "status"]
9
+ export const TASK_FIELDS = ["files", "change", "verify", "status", "output"]
10
10
 
11
11
  const SECTIONS = ["Constraints", "Decisions", "Risks", "Tasks"]
12
12
 
@@ -17,12 +17,14 @@ const fieldRe = new RegExp(`^\\s+-\\s+(${TASK_FIELDS.join("|")})\\s*:\\s*(.*)$`)
17
17
  /**
18
18
  * @typedef {object} Task
19
19
  * @property {number} index position in the file, zero based
20
+ * @property {number} ordinal position among the tasks with the same title
20
21
  * @property {boolean} checked the checkbox was ticked
21
22
  * @property {string} title the task on one line
22
23
  * @property {string} files which files it is allowed to touch
23
24
  * @property {string} change what it is supposed to change
24
25
  * @property {string} verify the command that proves it
25
26
  * @property {string} status pending | done | blocked
27
+ * @property {string} output the output that command actually produced
26
28
  * @property {number} start first line of the task block
27
29
  * @property {number} end last line of the task block
28
30
  * @property {string} [key] indexed by field name when a block is written
@@ -74,21 +76,31 @@ export function parseTasks(text) {
74
76
 
75
77
  /** @type {Task[]} */
76
78
  const tasks = []
79
+ // How many tasks with the same title have been seen. A driver that has to
80
+ // find its task again after the file changed underneath it needs a way to
81
+ // tell two identically-named tasks apart.
82
+ const titleCounts = new Map()
77
83
  for (let i = 0; i < lines.length; i++) {
78
84
  const m = lines[i].match(taskRe)
79
85
  if (!m) continue
80
86
  // A checkbox outside the Tasks section is prose, not a task.
81
87
  if (!inSection(lines, i, "Tasks")) continue
82
88
 
89
+ const title = m[2].trim()
90
+ const ordinal = titleCounts.get(title) ?? 0
91
+ titleCounts.set(title, ordinal + 1)
92
+
83
93
  /** @type {Record<string, any> & Task} */
84
94
  const t = {
85
95
  index: tasks.length,
96
+ ordinal,
86
97
  checked: m[1].toLowerCase() === "x",
87
- title: m[2].trim(),
98
+ title,
88
99
  files: "",
89
100
  change: "",
90
101
  verify: "",
91
102
  status: "",
103
+ output: "",
92
104
  start: i,
93
105
  end: i,
94
106
  }
@@ -104,8 +116,17 @@ export function parseTasks(text) {
104
116
  continue
105
117
  }
106
118
  // A wrapped value continues the field above it, indented deeper.
119
+ //
120
+ // Under `verify` it cannot be the command. A continuation of the
121
+ // verify line is the output of that command, and folding it back into
122
+ // the field made the driver hand "npm test exit 0 in 3s" to a shell.
123
+ // It is collected as output instead, where it cannot be executed.
107
124
  if (last && lines[j].trim() && /^\s{4,}\S/.test(lines[j])) {
108
- t[last] = `${t[last]} ${lines[j].trim()}`.trim()
125
+ if (last === "verify") {
126
+ t.output = t.output ? `${t.output} ${lines[j].trim()}` : lines[j].trim()
127
+ } else {
128
+ t[last] = `${t[last]} ${lines[j].trim()}`.trim()
129
+ }
109
130
  continue
110
131
  }
111
132
  if (!lines[j].trim()) continue
@@ -165,11 +186,14 @@ export const isDone = (t) => t.checked || t.status === "done"
165
186
  export const isBlocked = (t) => t.status === "blocked"
166
187
 
167
188
  /**
168
- * Record the outcome of a task: flip the checkbox, set the status, and paste the
169
- * verify output that was actually observed.
189
+ * Record the outcome of a task: flip the checkbox, set the status, and record
190
+ * the verify output that was actually observed.
170
191
  *
171
- * The verify text is written under the `verify:` line rather than replacing it,
172
- * because the contract asks for the command and the real output together.
192
+ * The output goes into its own `output:` field rather than under the `verify:`
193
+ * line. It used to be written as continuation lines under `verify:`, and the
194
+ * parser folded a continuation back into the field above it - so the command
195
+ * and its output became one string, and the driver handed that string to a
196
+ * shell. A separate field cannot be mistaken for a command.
173
197
  *
174
198
  * @param {string} text
175
199
  * @param {Task} task
@@ -179,9 +203,11 @@ export const isBlocked = (t) => t.status === "blocked"
179
203
  export function markTask(text, task, { status, verifyOutput }) {
180
204
  const lines = String(text).split("\n")
181
205
  const boxLine = lines[task.start] ?? ""
206
+ // The indentation is part of the document: a task nested under a heading
207
+ // keeps its nesting when it is marked.
182
208
  lines[task.start] = boxLine.replace(
183
- /^\s*-\s*\[[ xX]\]/,
184
- status === "done" ? "- [x]" : "- [ ]",
209
+ /^(\s*)-\s*\[[ xX]\]/,
210
+ (_match, indent) => `${indent}- ${status === "done" ? "[x]" : "[ ]"}`,
185
211
  )
186
212
 
187
213
  let statusLine = -1
@@ -198,28 +224,76 @@ export function markTask(text, task, { status, verifyOutput }) {
198
224
  )
199
225
 
200
226
  if (verifyOutput) {
201
- let verifyLine = -1
202
- for (let i = task.start; i <= task.end + 1 && i < lines.length; i++) {
203
- const f = lines[i]?.match(fieldRe)
204
- if (f && f[1] === "verify") {
205
- verifyLine = i
206
- break
207
- }
208
- }
209
- const verifySource = verifyLine >= 0 ? lines[verifyLine] : undefined
210
- if (verifySource) {
211
- const indent = (verifySource.match(/^\s*/) ?? [""])[0] + " "
212
- const quoted = String(verifyOutput)
213
- .trimEnd()
214
- .split("\n")
215
- .map((l) => `${indent}${l}`)
216
- lines.splice(verifyLine + 1, 0, ...quoted)
217
- }
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)
230
+
231
+ const verifyLine = fieldLine(lines, task, "verify")
232
+ const anchor = verifyLine >= 0 ? verifyLine : lastFieldLine(lines, task)
233
+ const source = lines[anchor] ?? lines[task.start] ?? ""
234
+ // The field line sits at the same level as the field it belongs to; only
235
+ // the continuation lines under it are indented deeper.
236
+ const fieldIndent = (source.match(/^\s*/) ?? [""])[0]
237
+ const contIndent = `${fieldIndent} `
238
+ const quoted = String(verifyOutput)
239
+ .trimEnd()
240
+ .split("\n")
241
+ .map((l) => `${contIndent}${l}`)
242
+ // 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)
218
245
  }
219
246
 
220
247
  return lines.join("\n")
221
248
  }
222
249
 
250
+ /**
251
+ * The line a named field sits on, or -1.
252
+ * @param {string[]} lines
253
+ * @param {Task} task
254
+ * @param {string} name
255
+ * @returns {number}
256
+ */
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)
260
+ if (f && f[1] === name) return i
261
+ }
262
+ return -1
263
+ }
264
+
265
+ /**
266
+ * The last field line of a task, or the task's own line when it has none.
267
+ * @param {string[]} lines
268
+ * @param {Task} task
269
+ * @returns {number}
270
+ */
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
275
+ }
276
+ return last
277
+ }
278
+
279
+ /**
280
+ * The lines a task's `output:` field occupies, or null when it has none.
281
+ * @param {string[]} lines
282
+ * @param {Task} task
283
+ * @returns {{ start: number, end: number } | null}
284
+ */
285
+ function outputBlock(lines, task) {
286
+ const start = fieldLine(lines, task, "output")
287
+ if (start < 0) return null
288
+ 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
292
+ end = i
293
+ }
294
+ return { start, end }
295
+ }
296
+
223
297
  /**
224
298
  * One task rendered as the prompt handed to the agent that will do it.
225
299
  *
@@ -237,12 +311,21 @@ export function taskPrompt(task) {
237
311
  /**
238
312
  * The summary the driver prints when it stops, whatever stopped it.
239
313
  *
314
+ * Every task is counted exactly once. A task that is ticked and also marked
315
+ * blocked is a contradiction a human can leave in the file, and counting it
316
+ * in both buckets made the three counters add up to more than the file holds.
317
+ *
240
318
  * @param {TaskFile} parsed
241
319
  * @returns {{ done: number, blocked: number, pending: number, total: number }}
242
320
  */
243
321
  export function summarize(parsed) {
244
- const done = parsed.tasks.filter(isDone).length
245
- const blocked = parsed.tasks.filter(isBlocked).length
246
- const pending = parsed.tasks.filter((t) => !isDone(t) && !isBlocked(t)).length
322
+ let done = 0
323
+ let blocked = 0
324
+ let pending = 0
325
+ for (const t of parsed.tasks) {
326
+ if (isDone(t)) done++
327
+ else if (isBlocked(t)) blocked++
328
+ else pending++
329
+ }
247
330
  return { done, blocked, pending, total: parsed.tasks.length }
248
331
  }
@@ -0,0 +1,62 @@
1
+ // The telemetry file, and the one thing that writes to it.
2
+ //
3
+ // It lives in its own module because TWO plugins write to it: the telemetry
4
+ // plugin, and the guard.
5
+ //
6
+ // The guard has to write to it because a denial never reaches
7
+ // `tool.execute.after`. The guard denies by throwing in `tool.execute.before`,
8
+ // and opencode calls `tool.execute.after` only after the tool has run - so a
9
+ // blocked command produces no `after` event at all. The record of a denial
10
+ // has to be written where the denial happens, or not at all.
11
+ //
12
+ // The file is a JSONL in the project, gitignored, and never a network call.
13
+
14
+ import { appendFileSync, existsSync, mkdirSync, statSync, renameSync } from "node:fs"
15
+ import { join } from "node:path"
16
+
17
+ /** Rotate above this, so a long-running project cannot fill its disk. */
18
+ export const MAX_BYTES = 4 * 1024 * 1024
19
+
20
+ export function logPath(dir: string): string {
21
+ return join(dir, "telemetry.jsonl")
22
+ }
23
+
24
+ /** The telemetry file and its rotated predecessors, oldest first. */
25
+ export function logFiles(dir: string): string[] {
26
+ const out: string[] = []
27
+ for (const suffix of [".2", ".1", ""]) {
28
+ const f = `${logPath(dir)}${suffix}`
29
+ if (existsSync(f)) out.push(f)
30
+ }
31
+ // Oldest first: `.2` was rotated twice, `.1` once, and the plain file is
32
+ // the newest.
33
+ return out
34
+ }
35
+
36
+ /** Rotate by size so a long-running project cannot fill its disk. */
37
+ function rotate(file: string) {
38
+ try {
39
+ if (existsSync(file) && statSync(file).size > MAX_BYTES) {
40
+ renameSync(file, `${file}.1`)
41
+ }
42
+ } catch {
43
+ // Best effort.
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Append one record.
49
+ *
50
+ * Nothing here is allowed to throw. Telemetry must never be load-bearing: a
51
+ * full disk or a read-only project must not break a turn.
52
+ */
53
+ export function write(dir: string, record: Record<string, unknown>) {
54
+ try {
55
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
56
+ const file = logPath(dir)
57
+ rotate(file)
58
+ appendFileSync(file, JSON.stringify({ t: new Date().toISOString(), ...record }) + "\n")
59
+ } catch {
60
+ // Telemetry must never be load-bearing.
61
+ }
62
+ }
package/src/telemetry.ts CHANGED
@@ -31,41 +31,31 @@
31
31
  // OpenCode 1.19, its `agent` field is undefined, so it cannot attribute a
32
32
  // call to a lane; and a second counter that cannot be correlated with the
33
33
  // first would double-count every call.
34
+ //
35
+ // WHAT A DENIAL LOOKS LIKE HERE
36
+ //
37
+ // A denial does NOT reach this plugin. The guard denies by throwing in
38
+ // `tool.execute.before`, and opencode calls `tool.execute.after` only after
39
+ // the tool has run - so a blocked command produces no `after` event. The
40
+ // record of a denial is written by the guard itself, into the same file.
41
+ //
42
+ // The `kind: "guard"` record that used to live here was a false positive: it
43
+ // scraped the word "blocked:" out of the OUTPUT of whatever tool ran, so a
44
+ // `read` of a source file that happened to contain the word produced a record
45
+ // claiming a rule had fired.
34
46
 
35
- import type { Plugin } from "@opencode-ai/plugin"
36
- import { appendFileSync, mkdirSync, existsSync, statSync, renameSync } from "node:fs"
47
+ import type { Plugin } from "./plugin.js"
37
48
  import { join } from "node:path"
38
-
39
- const MAX_BYTES = 4 * 1024 * 1024
40
-
41
- function logPath(dir: string): string {
42
- return join(dir, "telemetry.jsonl")
43
- }
44
-
45
- /** Rotate by size so a long-running project cannot fill its disk. */
46
- function rotate(file: string) {
47
- try {
48
- if (existsSync(file) && statSync(file).size > MAX_BYTES) {
49
- renameSync(file, `${file}.1`)
50
- }
51
- } catch {
52
- // Best effort.
53
- }
54
- }
55
-
56
- function write(dir: string, record: Record<string, unknown>) {
57
- try {
58
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
59
- const file = logPath(dir)
60
- rotate(file)
61
- appendFileSync(file, JSON.stringify({ t: new Date().toISOString(), ...record }) + "\n")
62
- } catch {
63
- // Telemetry must never be load-bearing.
64
- }
65
- }
66
-
67
- export const TelemetryPlugin: Plugin = async ({ worktree }) => {
68
- const dir = join(worktree, ".space")
49
+ import { write } from "./telemetry-store.js"
50
+ import { projectRoot } from "./project.js"
51
+ import { safeStringify } from "./safe.js"
52
+ import { ASK_GATED } from "./rules.js"
53
+
54
+ export const TelemetryPlugin: Plugin = async ({ worktree, directory }) => {
55
+ // The project root, not the worktree: the worktree is the filesystem root
56
+ // in a project that is not a git repository, so the telemetry was written
57
+ // somewhere nobody looks, or nowhere at all.
58
+ const dir = join(projectRoot(directory, worktree), ".space")
69
59
  const started = Date.now()
70
60
 
71
61
  // The message id of every lane record already written, and of every one
@@ -80,7 +70,7 @@ export const TelemetryPlugin: Plugin = async ({ worktree }) => {
80
70
  const closed = new Set<string>()
81
71
 
82
72
  return {
83
- event: async ({ event }) => {
73
+ event: async ({ event }: any) => {
84
74
  // Events are provider-specific and opencode may add shapes we have never
85
75
  // seen. Anything unrecognised is ignored rather than trusted.
86
76
  const type = (event as { type?: unknown } | undefined)?.type
@@ -88,7 +78,11 @@ export const TelemetryPlugin: Plugin = async ({ worktree }) => {
88
78
 
89
79
  // Provider rate limits are the one signal that changes behaviour, so
90
80
  // they get logged even though the event shape is provider-specific.
91
- if (/error|rate|limit|quota/i.test(type)) {
81
+ //
82
+ // The match is on whole tokens rather than on substrings, so an
83
+ // opencode event whose name happens to contain a letter of "rate" is
84
+ // not filed as a provider event and drowns the real ones.
85
+ if (/(^|[._-])(error|errors|rate|limit|limits|quota)([._-]|$)/i.test(type)) {
92
86
  write(dir, { kind: "provider-event", type, detail: truncate(safeStringify(event)) })
93
87
  return
94
88
  }
@@ -117,15 +111,19 @@ export const TelemetryPlugin: Plugin = async ({ worktree }) => {
117
111
  }
118
112
  },
119
113
 
120
- "tool.execute.after": async (input, output) => {
121
- const text = typeof output?.output === "string" ? output.output : ""
122
- if (text.includes("[opencode-harness-kit] blocked")) {
123
- write(dir, { kind: "guard", tool: input.tool, rule: extractRule(text) })
124
- return
125
- }
114
+ "tool.execute.after": async (input: any, output: any) => {
115
+ // A command the guard has a rule about, and that the guard let through.
116
+ //
117
+ // The list is `ASK_GATED` in rules.js, not a keyword list here: the
118
+ // guard's policy about what it does not block lives in one place, and a
119
+ // second copy of it would drift.
120
+ //
121
+ // This is the opposite of a denial, and the name says so. A record
122
+ // called "guard-attempt" that only ever contained ALLOWED commands was
123
+ // describing the wrong thing entirely.
126
124
  const command = String((input.args as { command?: unknown })?.command ?? "")
127
- if (input.tool === "bash" && /git push|rm -rf|chmod 777/.test(command)) {
128
- write(dir, { kind: "guard-attempt", tool: input.tool, command: truncate(command) })
125
+ if (input.tool === "bash" && ASK_GATED.some((p) => p.test(command))) {
126
+ write(dir, { kind: "guard-allowed", tool: input.tool, command: truncate(command) })
129
127
  }
130
128
  },
131
129
 
@@ -150,6 +148,10 @@ function laneOf(event: unknown): {
150
148
  model?: string
151
149
  provider?: string
152
150
  ms?: number
151
+ /** The context that request carried, in tokens. */
152
+ ctx?: number
153
+ /** The reasoning that request spent, in tokens. */
154
+ reasoning?: number
153
155
  } | null {
154
156
  const e = event as { properties?: any } | undefined
155
157
  const info = e?.properties?.info ?? e?.properties
@@ -166,31 +168,32 @@ function laneOf(event: unknown): {
166
168
  const ms = created !== undefined && completed !== undefined ? completed - created : undefined
167
169
 
168
170
  if (!id || !agent) return null
171
+ // The context the request carried, and the reasoning it spent.
172
+ //
173
+ // Without these, "which lanes are worth keeping" can only be answered on
174
+ // FREQUENCY - a lane used once a week looks the same as a lane used once a
175
+ // week that costs a million tokens. The two answers are different questions
176
+ // and the second one is the expensive one.
177
+ const tokens = (info as any).tokens ?? {}
178
+ const ctx =
179
+ typeof tokens.input === "number"
180
+ ? tokens.input + (typeof tokens.cache?.read === "number" ? tokens.cache.read : 0)
181
+ : undefined
182
+ const reasoning = typeof tokens.reasoning === "number" ? tokens.reasoning : undefined
183
+
169
184
  return {
170
185
  id,
171
186
  agent,
172
187
  model: typeof modelId === "string" ? modelId : undefined,
173
188
  provider: typeof providerId === "string" ? providerId : undefined,
174
189
  ms: typeof ms === "number" && ms >= 0 ? ms : undefined,
190
+ ctx: typeof ctx === "number" ? ctx : undefined,
191
+ reasoning,
175
192
  }
176
193
  }
177
194
 
178
- function extractRule(output: string): string {
179
- const m = output.match(/blocked:\s*([^\n]+)/)
180
- return m ? m[1].trim() : "unknown"
181
- }
182
-
183
195
  function truncate(s: string, n = 300): string {
184
196
  return s.length > n ? `${s.slice(0, n)}...` : s
185
197
  }
186
198
 
187
- /** Stringify without throwing on a cycle or a BigInt. */
188
- function safeStringify(value: unknown): string {
189
- try {
190
- return JSON.stringify(value) ?? String(value)
191
- } catch {
192
- return String(value)
193
- }
194
- }
195
-
196
199
  export default TelemetryPlugin
package/src/trust.js CHANGED
@@ -62,6 +62,37 @@ export function repoOf(repository) {
62
62
  return `${m[1]}/${m[2]}`
63
63
  }
64
64
 
65
+ /**
66
+ * Open a URL in the user's browser.
67
+ *
68
+ * `xdg-open` is the Linux opener. macOS uses `open` and Windows uses
69
+ * `rundll32`, so on two of the three platforms this kit claims to support
70
+ * the browser simply never opened - and the failure was swallowed, so the
71
+ * message afterwards still said the page was open.
72
+ *
73
+ * @param {string} url
74
+ * @returns {boolean} whether a browser was actually opened
75
+ */
76
+ export function openBrowser(url) {
77
+ /** @type {Array<[string, string[]]>} */
78
+ const candidates =
79
+ process.platform === "darwin"
80
+ ? [["open", [url]]]
81
+ : process.platform === "win32"
82
+ ? [["rundll32", ["url.dll,FileProtocolHandler", url]]]
83
+ : [["xdg-open", [url]], ["gio", ["open", url]], ["gnome-open", [url]]]
84
+ for (const [bin, args] of candidates) {
85
+ try {
86
+ execFileSync(bin, args, { stdio: "ignore", timeout: 10_000 })
87
+ return true
88
+ } catch {
89
+ // Try the next opener. The URL is printed either way, because a
90
+ // browser that cannot be opened here can be opened anywhere.
91
+ }
92
+ }
93
+ return false
94
+ }
95
+
65
96
  /**
66
97
  * Run npm and return its output.
67
98
  *
@@ -153,19 +184,24 @@ async function npmWithOtp(args, state = {}) {
153
184
  // OC_TRUST_NO_BROWSER turns the opening off, which is what a test needs:
154
185
  // a test that opens a browser on the machine it runs on is a test that
155
186
  // breaks whatever the person at the keyboard was doing.
187
+ let opened = false
156
188
  if (!process.env.OC_TRUST_NO_BROWSER) {
157
- try {
158
- execFileSync("xdg-open", [authUrl], { stdio: "ignore", timeout: 10_000 })
159
- } catch {
160
- // No browser here. The URL below is the way out.
161
- }
189
+ opened = openBrowser(authUrl)
162
190
  }
163
191
  console.error("")
164
192
  console.error(bold(" npm needs to confirm it is you."))
165
193
  console.error(` ${authUrl}`)
166
194
  console.error(dim(" Answer there however your npm account is set up - an authenticator"))
167
195
  console.error(dim(" app, a security key, or a code sent to your email."))
168
- console.error(dim(" This page is open in a browser, or copy the URL above."))
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
+ )
169
205
  console.error("")
170
206
 
171
207
  const token = await waitForToken(doneUrl)
@@ -270,10 +306,15 @@ export async function main(argv = []) {
270
306
  console.error(" npm install -g npm@latest")
271
307
  return 1
272
308
  }
273
- try {
274
- npm(["whoami"])
275
- } catch {
309
+ // Whether the account is logged in. `npm whoami` is run captured rather
310
+ // than through `npm()`, because `npm()` ends the process on a failure - so
311
+ // the guidance below was unreachable and the user saw only npm's raw
312
+ // error text.
313
+ const who = npmCaptured(["whoami"])
314
+ if (who.code !== 0) {
276
315
  console.error(red("not logged in to the npm registry."))
316
+ const said = who.out.trim().split("\n").filter(Boolean).slice(-2).join(" ")
317
+ if (said) console.error(dim(` npm said: ${said}`))
277
318
  console.error(" npm login")
278
319
  return 1
279
320
  }
@@ -379,6 +420,11 @@ export async function main(argv = []) {
379
420
  console.error(" revoke it on npmjs.com and run this again.")
380
421
  return 1
381
422
  }
423
+ // What is being replaced, kept so the failure below can say how to put
424
+ // it back. npm cannot edit a configuration, and a revoke that is not
425
+ // followed by a create leaves the package with NO trusted publisher at
426
+ // all - strictly worse than before this command ran.
427
+ const previous = describe(config)
382
428
  const revoked = await npmWithOtp(["trust", "revoke", name, "--id", id, "-y"], state)
383
429
  if (revoked.code !== 0) {
384
430
  console.error(red("npm did not revoke the old configuration, so nothing was replaced."))
@@ -401,6 +447,21 @@ export async function main(argv = []) {
401
447
  )
402
448
  if (created.code !== 0) {
403
449
  console.error(red("npm did not create the replacement configuration."))
450
+ // The old configuration is gone by now. Say so plainly, and say what
451
+ // the old one was, so the package is not left with nothing and nobody
452
+ // knowing why.
453
+ console.error(
454
+ red(" The old configuration has already been revoked, so this package has no trusted publisher now."),
455
+ )
456
+ console.error(
457
+ dim(` It was: repository ${previous.repository} · file ${previous.file} · no environment`),
458
+ )
459
+ console.error(dim(" Put it back with:"))
460
+ console.error(
461
+ dim(
462
+ ` npm trust github ${name} --file ${previous.file} --repo ${previous.repository} --allow-publish --allow-stage-publish -y`,
463
+ ),
464
+ )
404
465
  const detail = created.out.trim()
405
466
  if (detail) {
406
467
  console.error(dim(" npm said:"))