opencode-harness-kit 0.3.6 → 0.3.8

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 (3) hide show
  1. package/CHANGELOG.md +42 -1
  2. package/package.json +1 -1
  3. package/src/trust.js +121 -65
package/CHANGELOG.md CHANGED
@@ -14,6 +14,45 @@ is not happening.
14
14
 
15
15
  ## [Unreleased]
16
16
 
17
+ ## [0.3.8] — 2026-10-20
18
+
19
+ ### Fixed
20
+
21
+ - **`oc-harness trust` completes the two-factor challenge the way npm does, so
22
+ it works with whatever second factor the account has.** npm answers a
23
+ request that needs one with the URL to authenticate at and the URL to
24
+ collect the result from - the URL in the error text is masked, but the one
25
+ in the JSON body is not.
26
+
27
+ The URL is opened in a browser and printed, the result is collected, and the
28
+ command carries on. That works with an authenticator app, a security key,
29
+ or a code sent to the user's email - none of which is a code typed into a
30
+ terminal.
31
+
32
+ npm's own helper does the same thing, but only when stdin AND stdout are
33
+ both terminals. A tool that pipes npm's output takes the terminal away from
34
+ it, and npm then gives up and prints a URL nobody can open. That is what
35
+ was happening, and it is why the command looked like it wanted a code from
36
+ an authenticator app and nothing else.
37
+ - The wait for a human is configurable with `OC_TRUST_WAIT_SECONDS`, so a CI
38
+ run does not hang on it.
39
+
40
+ ## [0.3.7] — 2026-10-20
41
+
42
+ ### Fixed
43
+
44
+ - **`oc-harness trust` no longer reports a failed create as a configured
45
+ one.** It printed "the trusted publisher is configured" after npm refused,
46
+ because it checked the error TEXT rather than the exit code. The user walks
47
+ away believing the publish path is fixed and finds out at the next release.
48
+ The exit code is what decides now, and npm's own words are printed when it
49
+ is not zero.
50
+ - **A failed read is reported as a failed read**, with npm's answer attached,
51
+ rather than as "nothing is configured" - which sent the reader off to
52
+ create a configuration that already existed.
53
+ - **The one-time password prompt goes to stderr, on a line of its own.** A
54
+ question buried in npm's output is a question nobody answers.
55
+
17
56
  ## [0.3.6] — 2026-10-20
18
57
 
19
58
  ### Fixed
@@ -319,7 +358,9 @@ silent in the worst way:
319
358
 
320
359
  ---
321
360
 
322
- [Unreleased]: https://github.com/pham-canh/space-harness/compare/v0.3.6...HEAD
361
+ [Unreleased]: https://github.com/pham-canh/space-harness/compare/v0.3.8...HEAD
362
+ [0.3.8]: https://github.com/pham-canh/space-harness/compare/v0.3.7...v0.3.8
363
+ [0.3.7]: https://github.com/pham-canh/space-harness/compare/v0.3.6...v0.3.7
323
364
  [0.3.6]: https://github.com/pham-canh/space-harness/compare/v0.3.5...v0.3.6
324
365
  [0.3.5]: https://github.com/pham-canh/space-harness/compare/v0.3.4...v0.3.5
325
366
  [0.3.4]: https://github.com/pham-canh/space-harness/compare/v0.3.1...v0.3.4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-harness-kit",
3
- "version": "0.3.6",
3
+ "version": "0.3.8",
4
4
  "description": "Harness kit for OpenCode: capability profiles, a fleet of job lanes with one free model each, role-based reasoning effort, context pruning, and measured baselines.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/pham-canh/space-harness#readme",
package/src/trust.js CHANGED
@@ -25,7 +25,7 @@
25
25
  // only runs from one directory is a release tool that people run from the
26
26
  // wrong one.
27
27
 
28
- import { execFileSync } from "node:child_process"
28
+ import { execFileSync, spawnSync } from "node:child_process"
29
29
  import { readFileSync } from "node:fs"
30
30
  import { dirname, join } from "node:path"
31
31
  import { fileURLToPath } from "node:url"
@@ -84,74 +84,101 @@ function npm(args, opts = {}) {
84
84
  }
85
85
 
86
86
  /**
87
- * Run npm, and if it asks for a one-time password, ask for one here and run
88
- * it again.
87
+ * Run npm with its output captured.
89
88
  *
90
- * npm's own helper for this opens a browser - but only when stdin and stdout
91
- * are both terminals, and under `npx` they are not. The result is a failure
92
- * that prints a web URL masked as `auth/cli/***`, which cannot be opened.
89
+ * @param {string[]} args
90
+ * @param {{ otp?: string }} [state]
91
+ * @returns {{ out: string, code: number }}
92
+ */
93
+ function npmCaptured(args, state = {}) {
94
+ const extra = state.otp ? [`--otp=${state.otp}`] : []
95
+ const r = spawnSync("npm", [...args, ...extra], {
96
+ encoding: "utf8",
97
+ stdio: ["ignore", "pipe", "pipe"],
98
+ })
99
+ return { out: `${r.stdout ?? ""}${r.stderr ?? ""}`, code: r.status ?? 1 }
100
+ }
101
+
102
+ /**
103
+ * Run npm, and complete a two-factor challenge if it asks for one.
104
+ *
105
+ * npm answers a request that needs a second factor with a body carrying the
106
+ * URL to authenticate at and the URL to collect the result from. The URL in
107
+ * the ERROR TEXT is masked - `auth/cli/***` - but the one in the JSON body is
108
+ * not, and that is the one that can be opened.
109
+ *
110
+ * So the challenge is completed the way npm completes it: the URL is opened
111
+ * in a browser, the user answers there however their account is set up - a
112
+ * code from an authenticator app, a security key, or one mailed to them - and
113
+ * the result is collected from the second URL.
93
114
  *
94
- * So the code is asked for here, and the same code is passed straight back to
95
- * npm as `--otp`.
115
+ * npm's own helper does exactly this, but only when stdin AND stdout are
116
+ * both terminals. A tool that pipes npm's output takes the terminal away from
117
+ * it, and npm then gives up and prints a URL nobody can open. So the flow is
118
+ * done here, where the URL can be recovered.
96
119
  *
97
120
  * @param {string[]} args
98
121
  * @param {{ otp?: string }} [state]
99
- * @returns {Promise<string>}
122
+ * @returns {Promise<{ out: string, code: number }>}
100
123
  */
101
124
  async function npmWithOtp(args, state = {}) {
102
- /** @param {string[]} extra */
103
- const run = (extra) => {
104
- try {
105
- return {
106
- out: execFileSync("npm", [...args, ...extra], {
107
- encoding: "utf8",
108
- stdio: ["ignore", "pipe", "pipe"],
109
- }),
110
- failed: false,
111
- }
112
- } catch (err) {
113
- const e = /** @type {any} */ (err)
114
- return { out: `${e.stdout ?? ""}${e.stderr ?? ""}`, failed: true }
115
- }
116
- }
125
+ let result = npmCaptured(args, state)
126
+ if (result.code === 0) return result
117
127
 
118
- const withOtp = state.otp ? [`--otp=${state.otp}`] : []
119
- let result = run(withOtp)
120
- if (!result.failed || !/one-time password|EOTP/i.test(result.out)) return result.out
128
+ const m = /"doneUrl"\s*:\s*"[^"]*authId=([a-f0-9-]+)"/.exec(result.out)
129
+ if (!m) return result
130
+ const authId = m[1]
131
+ const authUrl = `https://www.npmjs.com/auth/cli/${authId}`
132
+ const doneUrl = `https://registry.npmjs.org/-/v1/done?authId=${authId}`
121
133
 
122
- // npm wants a code. Get one.
123
- const code = state.otp ?? (await askForOtp())
124
- if (!code) {
125
- console.error(red("npm needs a one-time password and none was given."))
126
- console.error(dim(` run it again with --otp=<the code from your authenticator app>`))
127
- process.exit(1)
134
+ // Open it if this machine can. The URL is printed either way, because a
135
+ // browser that cannot be opened here can be opened anywhere.
136
+ try {
137
+ execFileSync("xdg-open", [authUrl], { stdio: "ignore", timeout: 10_000 })
138
+ } catch {
139
+ // No browser here. The URL below is the way out.
128
140
  }
129
- state.otp = code
130
- result = run([`--otp=${code}`])
131
- return result.out
141
+ console.error("")
142
+ console.error(bold(" npm needs to confirm it is you."))
143
+ console.error(` ${authUrl}`)
144
+ console.error(dim(" Answer there however your npm account is set up - an authenticator"))
145
+ console.error(dim(" app, a security key, or a code sent to your email."))
146
+ console.error(dim(" This page is open in a browser, or copy the URL above."))
147
+ console.error("")
148
+
149
+ const token = await waitForToken(doneUrl)
150
+ if (!token) {
151
+ console.error(red(" no answer came back, so nothing was changed."))
152
+ return result
153
+ }
154
+ state.otp = token
155
+ result = npmCaptured(args, state)
156
+ return result
132
157
  }
133
158
 
134
159
  /**
135
- * Ask for the one-time password.
160
+ * Wait for the browser to answer.
136
161
  *
162
+ * @param {string} doneUrl
137
163
  * @returns {Promise<string | null>}
138
164
  */
139
- async function askForOtp() {
140
- if (!process.stdin.isTTY) {
141
- console.error(
142
- red("this needs a one-time password, and there is no terminal to ask for it on."),
143
- )
144
- console.error(dim(` run it with --otp=<the code from your authenticator app>`))
145
- return null
146
- }
147
- const { createInterface } = await import("node:readline/promises")
148
- const rl = createInterface({ input: process.stdin, output: process.stdout })
149
- try {
150
- const answer = (await rl.question(" one-time password from your authenticator app: ")).trim()
151
- return answer || null
152
- } finally {
153
- rl.close()
165
+ async function waitForToken(doneUrl) {
166
+ // How long to wait for a human. The window is generous, because the person
167
+ // may be answering an email, and it is configurable so a CI run does not
168
+ // hang on it.
169
+ const seconds = Number(process.env.OC_TRUST_WAIT_SECONDS ?? 600)
170
+ const attempts = Math.max(1, Math.round(seconds / 5))
171
+ for (let i = 0; i < attempts; i++) {
172
+ try {
173
+ const res = await fetch(doneUrl)
174
+ const body = /** @type {any} */ (await res.json())
175
+ if (body && typeof body.token === "string" && body.token) return body.token
176
+ } catch {
177
+ // Not ready yet, or the network blipped. Try again.
178
+ }
179
+ await new Promise((r) => setTimeout(r, 5000))
154
180
  }
181
+ return null
155
182
  }
156
183
 
157
184
  /**
@@ -163,19 +190,22 @@ async function askForOtp() {
163
190
  *
164
191
  * @param {string} name
165
192
  * @param {{ otp?: string }} [state]
166
- * @returns {Promise<{ configs: any[], failed: boolean }>}
193
+ * @returns {Promise<{ configs: any[], failed: boolean, detail?: string }>}
167
194
  */
168
195
  async function listConfigs(name, state = {}) {
169
- const out = await npmWithOtp(["trust", "list", name, "--json"], state)
170
- if (/one-time password|EOTP/i.test(out) && !state.otp) {
171
- return { configs: [], failed: true }
196
+ const { out, code } = await npmWithOtp(["trust", "list", name, "--json"], state)
197
+ if (code !== 0) {
198
+ // A package with no configuration answers 404, and a failed call answers
199
+ // with something else. They are different facts.
200
+ if (/E404|not found/i.test(out)) return { configs: [], failed: false }
201
+ return { configs: [], failed: true, detail: out }
172
202
  }
173
203
  if (!out.trim()) return { configs: [], failed: false }
174
204
  let parsed
175
205
  try {
176
206
  parsed = JSON.parse(out)
177
207
  } catch {
178
- return { configs: [], failed: true }
208
+ return { configs: [], failed: true, detail: out }
179
209
  }
180
210
  const items = Array.isArray(parsed) ? parsed : parsed.configs ?? [parsed]
181
211
  return { configs: items.filter(Boolean), failed: false }
@@ -226,10 +256,13 @@ export async function main(argv = []) {
226
256
  return 1
227
257
  }
228
258
 
229
- const { configs, failed } = await listConfigs(name, state)
259
+ const { configs, failed, detail } = await listConfigs(name, state)
230
260
  if (failed && !configs.length) {
231
261
  console.error(red("could not read the trusted publisher configuration."))
232
- console.error(dim(" npm answered with something that is not a configuration list."))
262
+ if (detail) {
263
+ console.error(dim(" npm said:"))
264
+ for (const line of detail.trim().split("\n").slice(-12)) console.error(dim(" " + line))
265
+ }
233
266
  console.error(dim(` run \`npm trust list ${name}\` to see the raw answer.`))
234
267
  return 1
235
268
  }
@@ -244,7 +277,7 @@ export async function main(argv = []) {
244
277
  return 1
245
278
  }
246
279
  console.log(` creating it: ${file}, no environment, publish and stage\n`)
247
- const out = await npmWithOtp(
280
+ const { out, code } = await npmWithOtp(
248
281
  [
249
282
  "trust",
250
283
  "github",
@@ -259,9 +292,19 @@ export async function main(argv = []) {
259
292
  ],
260
293
  state,
261
294
  )
262
- if (/one-time password|EOTP/i.test(out)) {
263
- console.error(red("npm still wants a one-time password."))
264
- console.error(dim(" run it again with --otp=<the code from your authenticator app>"))
295
+ if (code !== 0) {
296
+ // Printing "configured" after a failed create is the worst thing this
297
+ // command could do: the user walks away believing the publish path is
298
+ // fixed, and finds out at the next release.
299
+ console.error(red("npm did not create the trusted publisher."))
300
+ if (/one-time password|EOTP/i.test(out)) {
301
+ console.error(dim(" run it again with --otp=<the code from your authenticator app>"))
302
+ }
303
+ const detail = out.trim()
304
+ if (detail) {
305
+ console.error(dim(" npm said:"))
306
+ for (const line of detail.split("\n").slice(-12)) console.error(dim(" " + line))
307
+ }
265
308
  return 1
266
309
  }
267
310
  console.log(`\n ${green("✓")} the trusted publisher is configured\n`)
@@ -314,8 +357,12 @@ export async function main(argv = []) {
314
357
  console.error(" revoke it on npmjs.com and run this again.")
315
358
  return 1
316
359
  }
317
- await npmWithOtp(["trust", "revoke", name, "--id", id, "-y"], state)
318
- await npmWithOtp(
360
+ const revoked = await npmWithOtp(["trust", "revoke", name, "--id", id, "-y"], state)
361
+ if (revoked.code !== 0) {
362
+ console.error(red("npm did not revoke the old configuration, so nothing was replaced."))
363
+ return 1
364
+ }
365
+ const created = await npmWithOtp(
319
366
  [
320
367
  "trust",
321
368
  "github",
@@ -330,6 +377,15 @@ export async function main(argv = []) {
330
377
  ],
331
378
  state,
332
379
  )
380
+ if (created.code !== 0) {
381
+ console.error(red("npm did not create the replacement configuration."))
382
+ const detail = created.out.trim()
383
+ if (detail) {
384
+ console.error(dim(" npm said:"))
385
+ for (const line of detail.split("\n").slice(-12)) console.error(dim(" " + line))
386
+ }
387
+ return 1
388
+ }
333
389
  console.log(`\n ${green("✓")} the trusted publisher is configured\n`)
334
390
  }
335
391
  return 0