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.
- package/CHANGELOG.md +42 -1
- package/package.json +1 -1
- 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.
|
|
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.
|
|
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
|
|
88
|
-
* it again.
|
|
87
|
+
* Run npm with its output captured.
|
|
89
88
|
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
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
|
-
*
|
|
95
|
-
* npm
|
|
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
|
-
|
|
103
|
-
|
|
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
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
//
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
*
|
|
160
|
+
* Wait for the browser to answer.
|
|
136
161
|
*
|
|
162
|
+
* @param {string} doneUrl
|
|
137
163
|
* @returns {Promise<string | null>}
|
|
138
164
|
*/
|
|
139
|
-
async function
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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 (
|
|
171
|
-
|
|
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
|
-
|
|
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 (
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
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
|