opencode-harness-kit 0.3.5 → 0.3.7

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 CHANGED
@@ -14,6 +14,37 @@ is not happening.
14
14
 
15
15
  ## [Unreleased]
16
16
 
17
+ ## [0.3.7] — 2026-10-20
18
+
19
+ ### Fixed
20
+
21
+ - **`oc-harness trust` no longer reports a failed create as a configured
22
+ one.** It printed "the trusted publisher is configured" after npm refused,
23
+ because it checked the error TEXT rather than the exit code. The user walks
24
+ away believing the publish path is fixed and finds out at the next release.
25
+ The exit code is what decides now, and npm's own words are printed when it
26
+ is not zero.
27
+ - **A failed read is reported as a failed read**, with npm's answer attached,
28
+ rather than as "nothing is configured" - which sent the reader off to
29
+ create a configuration that already existed.
30
+ - **The one-time password prompt goes to stderr, on a line of its own.** A
31
+ question buried in npm's output is a question nobody answers.
32
+
33
+ ## [0.3.6] — 2026-10-20
34
+
35
+ ### Fixed
36
+
37
+ - **`oc-harness trust` asks for the one-time password itself.** npm's own
38
+ helper opens a browser for it, but only when stdin and stdout are both
39
+ terminals — and under `npx` they are not. The result was a failure that
40
+ printed a web URL masked as `auth/cli/***`, which cannot be opened. The code
41
+ is asked for in the terminal now, and `--otp=<code>` works where there is
42
+ none.
43
+ - **A failed read is no longer reported as "nothing is configured".** A
44
+ package with no trusted publisher and a call that could not read one are
45
+ different facts, and treating the second as the first sends you off to
46
+ create a configuration that already exists.
47
+
17
48
  ## [0.3.5] — 2026-10-20
18
49
 
19
50
  ### Added
@@ -304,7 +335,9 @@ silent in the worst way:
304
335
 
305
336
  ---
306
337
 
307
- [Unreleased]: https://github.com/pham-canh/space-harness/compare/v0.3.5...HEAD
338
+ [Unreleased]: https://github.com/pham-canh/space-harness/compare/v0.3.7...HEAD
339
+ [0.3.7]: https://github.com/pham-canh/space-harness/compare/v0.3.6...v0.3.7
340
+ [0.3.6]: https://github.com/pham-canh/space-harness/compare/v0.3.5...v0.3.6
308
341
  [0.3.5]: https://github.com/pham-canh/space-harness/compare/v0.3.4...v0.3.5
309
342
  [0.3.4]: https://github.com/pham-canh/space-harness/compare/v0.3.1...v0.3.4
310
343
  [0.3.1]: https://github.com/pham-canh/space-harness/compare/v0.3.0...v0.3.1
@@ -1411,7 +1411,7 @@ try {
1411
1411
  cmdLanes()
1412
1412
  break
1413
1413
  case "trust":
1414
- process.exitCode = trustMain(args.slice(1))
1414
+ process.exitCode = await trustMain(args.slice(1))
1415
1415
  break
1416
1416
  case "doctor":
1417
1417
  await cmdDoctor()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-harness-kit",
3
- "version": "0.3.5",
3
+ "version": "0.3.7",
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
@@ -83,22 +83,111 @@ function npm(args, opts = {}) {
83
83
  }
84
84
  }
85
85
 
86
+ /**
87
+ * Run npm, and if it asks for a one-time password, ask for one here and run
88
+ * it again.
89
+ *
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.
93
+ *
94
+ * So the code is asked for here, and the same code is passed straight back to
95
+ * npm as `--otp`.
96
+ *
97
+ * @param {string[]} args
98
+ * @param {{ otp?: string }} [state]
99
+ * @returns {Promise<{ out: string, code: number }>}
100
+ */
101
+ 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
+ code: 0,
111
+ }
112
+ } catch (err) {
113
+ const e = /** @type {any} */ (err)
114
+ return { out: `${e.stdout ?? ""}${e.stderr ?? ""}`, code: e.status ?? 1 }
115
+ }
116
+ }
117
+
118
+ const withOtp = state.otp ? [`--otp=${state.otp}`] : []
119
+ let result = run(withOtp)
120
+ if (result.code === 0 || !/one-time password|EOTP/i.test(result.out)) return result
121
+
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
+ return { out: result.out, code: result.code }
128
+ }
129
+ state.otp = code
130
+ result = run([`--otp=${code}`])
131
+ return result
132
+ }
133
+
134
+ /**
135
+ * Ask for the one-time password.
136
+ *
137
+ * @returns {Promise<string | null>}
138
+ */
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
+ // The prompt goes to stderr, and it gets a line of its own: a question
149
+ // buried in the middle of npm's output is a question nobody answers.
150
+ process.stderr.write("\n")
151
+ const rl = createInterface({ input: process.stdin, output: process.stderr })
152
+ try {
153
+ const answer = (
154
+ await rl.question(" one-time password from your authenticator app: ")
155
+ ).trim()
156
+ process.stderr.write("\n")
157
+ return answer || null
158
+ } finally {
159
+ rl.close()
160
+ }
161
+ }
162
+
86
163
  /**
87
164
  * The stored trusted publisher configurations for this package.
165
+ *
166
+ * A package with no configuration is not the same as a call that failed: the
167
+ * first is a fact and the second is a question, and treating the second as the
168
+ * first sends you off to create a configuration that already exists.
169
+ *
88
170
  * @param {string} name
89
- * @returns {any[]}
171
+ * @param {{ otp?: string }} [state]
172
+ * @returns {Promise<{ configs: any[], failed: boolean, detail?: string }>}
90
173
  */
91
- function listConfigs(name) {
92
- const out = npm(["trust", "list", name, "--json"], { allowFail: true })
93
- if (!out.trim()) return []
174
+ async function listConfigs(name, state = {}) {
175
+ const { out, code } = await npmWithOtp(["trust", "list", name, "--json"], state)
176
+ if (code !== 0) {
177
+ // A package with no configuration answers 404, and a failed call answers
178
+ // with something else. They are different facts.
179
+ if (/E404|not found/i.test(out)) return { configs: [], failed: false }
180
+ return { configs: [], failed: true, detail: out }
181
+ }
182
+ if (!out.trim()) return { configs: [], failed: false }
94
183
  let parsed
95
184
  try {
96
185
  parsed = JSON.parse(out)
97
186
  } catch {
98
- return []
187
+ return { configs: [], failed: true, detail: out }
99
188
  }
100
189
  const items = Array.isArray(parsed) ? parsed : parsed.configs ?? [parsed]
101
- return items.filter(Boolean)
190
+ return { configs: items.filter(Boolean), failed: false }
102
191
  }
103
192
 
104
193
  /**
@@ -116,14 +205,17 @@ function describe(config) {
116
205
 
117
206
  /**
118
207
  * @param {string[]} argv
119
- * @returns {number} the exit code
208
+ * @returns {Promise<number>} the exit code
120
209
  */
121
- export function main(argv = []) {
210
+ export async function main(argv = []) {
122
211
  const pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8"))
123
212
  const name = pkg.name
124
213
  const repo = repoOf(pkg.repository)
125
214
  const file = process.env.TRUSTED_PUBLISHER_FILE ?? "ci.yml"
126
215
  const checkOnly = argv.includes("--check")
216
+ // A code given on the command line is used as-is. A code given at the
217
+ // prompt is remembered for the rest of the run, so the user types it once.
218
+ const state = { otp: argv.find((a) => a.startsWith("--otp="))?.slice("--otp=".length) }
127
219
 
128
220
  console.log(`\n${bold("npm trusted publishing")} ${dim(`${name} · ${repo} · ${file}`)}\n`)
129
221
 
@@ -143,7 +235,16 @@ export function main(argv = []) {
143
235
  return 1
144
236
  }
145
237
 
146
- const configs = listConfigs(name)
238
+ const { configs, failed, detail } = await listConfigs(name, state)
239
+ if (failed && !configs.length) {
240
+ console.error(red("could not read the trusted publisher configuration."))
241
+ if (detail) {
242
+ console.error(dim(" npm said:"))
243
+ for (const line of detail.trim().split("\n").slice(-12)) console.error(dim(" " + line))
244
+ }
245
+ console.error(dim(` run \`npm trust list ${name}\` to see the raw answer.`))
246
+ return 1
247
+ }
147
248
 
148
249
  if (!configs.length) {
149
250
  console.log(yellow(" no trusted publisher is configured for this package."))
@@ -155,18 +256,36 @@ export function main(argv = []) {
155
256
  return 1
156
257
  }
157
258
  console.log(` creating it: ${file}, no environment, publish and stage\n`)
158
- npm([
159
- "trust",
160
- "github",
161
- name,
162
- "--file",
163
- file,
164
- "--repo",
165
- repo,
166
- "--allow-publish",
167
- "--allow-stage-publish",
168
- "-y",
169
- ])
259
+ const { out, code } = await npmWithOtp(
260
+ [
261
+ "trust",
262
+ "github",
263
+ name,
264
+ "--file",
265
+ file,
266
+ "--repo",
267
+ repo,
268
+ "--allow-publish",
269
+ "--allow-stage-publish",
270
+ "-y",
271
+ ],
272
+ state,
273
+ )
274
+ if (code !== 0) {
275
+ // Printing "configured" after a failed create is the worst thing this
276
+ // command could do: the user walks away believing the publish path is
277
+ // fixed, and finds out at the next release.
278
+ console.error(red("npm did not create the trusted publisher."))
279
+ if (/one-time password|EOTP/i.test(out)) {
280
+ console.error(dim(" run it again with --otp=<the code from your authenticator app>"))
281
+ }
282
+ const detail = out.trim()
283
+ if (detail) {
284
+ console.error(dim(" npm said:"))
285
+ for (const line of detail.split("\n").slice(-12)) console.error(dim(" " + line))
286
+ }
287
+ return 1
288
+ }
170
289
  console.log(`\n ${green("✓")} the trusted publisher is configured\n`)
171
290
  return 0
172
291
  }
@@ -217,19 +336,35 @@ export function main(argv = []) {
217
336
  console.error(" revoke it on npmjs.com and run this again.")
218
337
  return 1
219
338
  }
220
- npm(["trust", "revoke", name, "--id", id, "-y"])
221
- npm([
222
- "trust",
223
- "github",
224
- name,
225
- "--file",
226
- file,
227
- "--repo",
228
- repo,
229
- "--allow-publish",
230
- "--allow-stage-publish",
231
- "-y",
232
- ])
339
+ const revoked = await npmWithOtp(["trust", "revoke", name, "--id", id, "-y"], state)
340
+ if (revoked.code !== 0) {
341
+ console.error(red("npm did not revoke the old configuration, so nothing was replaced."))
342
+ return 1
343
+ }
344
+ const created = await npmWithOtp(
345
+ [
346
+ "trust",
347
+ "github",
348
+ name,
349
+ "--file",
350
+ file,
351
+ "--repo",
352
+ repo,
353
+ "--allow-publish",
354
+ "--allow-stage-publish",
355
+ "-y",
356
+ ],
357
+ state,
358
+ )
359
+ if (created.code !== 0) {
360
+ console.error(red("npm did not create the replacement configuration."))
361
+ const detail = created.out.trim()
362
+ if (detail) {
363
+ console.error(dim(" npm said:"))
364
+ for (const line of detail.split("\n").slice(-12)) console.error(dim(" " + line))
365
+ }
366
+ return 1
367
+ }
233
368
  console.log(`\n ${green("✓")} the trusted publisher is configured\n`)
234
369
  }
235
370
  return 0