opencode-harness-kit 0.3.3 → 0.3.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/CHANGELOG.md CHANGED
@@ -14,10 +14,22 @@ is not happening.
14
14
 
15
15
  ## [Unreleased]
16
16
 
17
- ## [0.3.3] — 2026-10-20
17
+ ## [0.3.5] — 2026-10-20
18
18
 
19
- > 0.3.2 was never published: it carried the fix below, but the publish it
20
- > needed had not been set up yet. It is folded into this release.
19
+ ### Added
20
+
21
+ - **`oc-harness trust`** configures npm trusted publishing from the command
22
+ line, and says which field is wrong when it is wrong. It works from any
23
+ directory: a release tool that only runs from one place is a release tool
24
+ that people run from the wrong one, and the failure is `Cannot read
25
+ package.json`, which says nothing about what the command was for.
26
+
27
+ ## [0.3.4] — 2026-10-20
28
+
29
+ > 0.3.2 and 0.3.3 were never published. 0.3.2 carried the fix below but the
30
+ > publish path it needed was not set up yet; 0.3.3 was **staged** by a
31
+ > diagnostic that turned out to have side effects, and a staged version cannot
32
+ > be published over directly. Both are folded into this release.
21
33
 
22
34
  ### Added
23
35
 
@@ -292,8 +304,9 @@ silent in the worst way:
292
304
 
293
305
  ---
294
306
 
295
- [Unreleased]: https://github.com/pham-canh/space-harness/compare/v0.3.3...HEAD
296
- [0.3.3]: https://github.com/pham-canh/space-harness/compare/v0.3.1...v0.3.3
307
+ [Unreleased]: https://github.com/pham-canh/space-harness/compare/v0.3.5...HEAD
308
+ [0.3.5]: https://github.com/pham-canh/space-harness/compare/v0.3.4...v0.3.5
309
+ [0.3.4]: https://github.com/pham-canh/space-harness/compare/v0.3.1...v0.3.4
297
310
  [0.3.1]: https://github.com/pham-canh/space-harness/compare/v0.3.0...v0.3.1
298
311
  [0.3.0]: https://github.com/pham-canh/space-harness/compare/v0.2.1...v0.3.0
299
312
  [0.2.1]: https://github.com/pham-canh/space-harness/compare/v0.2.0...v0.2.1
@@ -46,6 +46,7 @@ import {
46
46
  import { generateConfig, globalInstructionsPath } from "../src/config.js"
47
47
  import { planEffort } from "../src/effort.js"
48
48
  import { findTelemetryFile, readLaneUsage, averageMs } from "../src/lane-usage.js"
49
+ import { main as trustMain } from "../src/trust.js"
49
50
 
50
51
  const CONFIG_DIR =
51
52
  process.env.OPENCODE_CONFIG_DIR ?? join(homedir(), ".config/opencode")
@@ -1409,6 +1410,9 @@ try {
1409
1410
  case "lanes":
1410
1411
  cmdLanes()
1411
1412
  break
1413
+ case "trust":
1414
+ process.exitCode = trustMain(args.slice(1))
1415
+ break
1412
1416
  case "doctor":
1413
1417
  await cmdDoctor()
1414
1418
  break
@@ -1433,6 +1437,7 @@ try {
1433
1437
  link symlink the built plugin into ~/.config/opencode/plugins
1434
1438
  agents report agents the effort tiering does not reach
1435
1439
  lanes report which lanes the fleet actually used
1440
+ trust set up npm trusted publishing for this package
1436
1441
  doctor re-check profiles against models.dev and the installed config
1437
1442
  baseline measure the current opencode install
1438
1443
  run drive .space/tasks.md to done with the build agent
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-harness-kit",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
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",
@@ -53,7 +53,7 @@
53
53
  "check": "npm run test:all && npm run test:install && node bin/oc-harness.mjs verify",
54
54
  "check:online": "npm run check && node bin/oc-harness.mjs doctor",
55
55
  "baseline": "node bin/baseline.mjs",
56
- "release:trust": "node tools/setup-trusted-publishing.mjs",
56
+ "release:trust": "node bin/oc-harness.mjs trust",
57
57
  "eval": "node eval/run.mjs",
58
58
  "measure": "node eval/measure-install.mjs",
59
59
  "measure:check": "node eval/measure-install.mjs --min-narrow 45 --min-ctx-drop 20",
package/src/trust.js ADDED
@@ -0,0 +1,236 @@
1
+ // Set up npm trusted publishing for this package, and say what is wrong with
2
+ // it if it is wrong.
3
+ //
4
+ // Why this exists: npm's web form for a trusted publisher has four fields and
5
+ // npm does not validate what you type - "npm does not verify your trusted
6
+ // publisher configuration when you save it". A typo in any of them is
7
+ // discovered at publish time, and npm reports it as:
8
+ //
9
+ // OIDC token exchange error - package not found
10
+ //
11
+ // which reads like a missing package. The package is there. The configuration
12
+ // is not matching, and npm does not say which field.
13
+ //
14
+ // So this compares what npm has stored against what the workflow will
15
+ // actually present, and says which field differs. That is the whole job.
16
+ //
17
+ // Requirements npm itself states for these commands:
18
+ // - npm 11.15.0 or later
19
+ // - write access to the package
20
+ // - two-factor authentication enabled on the account
21
+ // - the package must already exist on the registry
22
+ //
23
+ // The root is resolved from THIS file, not from the working directory, so the
24
+ // command works from anywhere - which matters, because a release tool that
25
+ // only runs from one directory is a release tool that people run from the
26
+ // wrong one.
27
+
28
+ import { execFileSync } from "node:child_process"
29
+ import { readFileSync } from "node:fs"
30
+ import { dirname, join } from "node:path"
31
+ import { fileURLToPath } from "node:url"
32
+
33
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..")
34
+
35
+ /** @param {string} s */
36
+ const bold = (s) => `\x1b[1m${s}\x1b[0m`
37
+ /** @param {string} s */
38
+ const red = (s) => `\x1b[31m${s}\x1b[0m`
39
+ /** @param {string} s */
40
+ const green = (s) => `\x1b[32m${s}\x1b[0m`
41
+ /** @param {string} s */
42
+ const yellow = (s) => `\x1b[33m${s}\x1b[0m`
43
+ /** @param {string} s */
44
+ const dim = (s) => `\x1b[2m${s}\x1b[0m`
45
+
46
+ /**
47
+ * The repository in the `owner/repo` form npm wants.
48
+ *
49
+ * npm matches the trusted publisher's repository against the OIDC token's
50
+ * `repository` claim, which is `owner/repo` with no scheme and no `.git`. The
51
+ * `repository` field in package.json is a git URL, so it has to be reduced to
52
+ * that form - and getting it wrong is one of the ways a configuration ends up
53
+ * not matching.
54
+ *
55
+ * @param {any} repository
56
+ * @returns {string}
57
+ */
58
+ export function repoOf(repository) {
59
+ const url = typeof repository === "string" ? repository : repository?.url ?? ""
60
+ const m = url.match(/github\.com[/:]([^/]+)\/([^/]+?)(?:\.git)?$/)
61
+ if (!m) throw new Error(`cannot read a GitHub repository out of ${JSON.stringify(repository)}`)
62
+ return `${m[1]}/${m[2]}`
63
+ }
64
+
65
+ /**
66
+ * Run npm and return its output.
67
+ *
68
+ * @param {string[]} args
69
+ * @param {{ allowFail?: boolean }} [opts]
70
+ * @returns {string}
71
+ */
72
+ function npm(args, opts = {}) {
73
+ const allowFail = opts.allowFail === true
74
+ try {
75
+ return execFileSync("npm", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] })
76
+ } catch (err) {
77
+ if (allowFail) return ""
78
+ const e = /** @type {any} */ (err)
79
+ const detail = `${e.stdout ?? ""}${e.stderr ?? ""}`.trim()
80
+ console.error(red(`npm ${args.join(" ")} failed`))
81
+ if (detail) console.error(detail)
82
+ process.exit(1)
83
+ }
84
+ }
85
+
86
+ /**
87
+ * The stored trusted publisher configurations for this package.
88
+ * @param {string} name
89
+ * @returns {any[]}
90
+ */
91
+ function listConfigs(name) {
92
+ const out = npm(["trust", "list", name, "--json"], { allowFail: true })
93
+ if (!out.trim()) return []
94
+ let parsed
95
+ try {
96
+ parsed = JSON.parse(out)
97
+ } catch {
98
+ return []
99
+ }
100
+ const items = Array.isArray(parsed) ? parsed : parsed.configs ?? [parsed]
101
+ return items.filter(Boolean)
102
+ }
103
+
104
+ /**
105
+ * The fields npm actually matches, in the shape npm stores them.
106
+ * @param {any} config
107
+ */
108
+ function describe(config) {
109
+ const claims = config.claims ?? {}
110
+ const file = config.file ?? claims.workflow_ref?.file
111
+ const repository = config.repository ?? claims.repository
112
+ const environment = config.environment ?? claims.environment
113
+ const permissions = config.permissions ?? []
114
+ return { file, repository, environment, permissions }
115
+ }
116
+
117
+ /**
118
+ * @param {string[]} argv
119
+ * @returns {number} the exit code
120
+ */
121
+ export function main(argv = []) {
122
+ const pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8"))
123
+ const name = pkg.name
124
+ const repo = repoOf(pkg.repository)
125
+ const file = process.env.TRUSTED_PUBLISHER_FILE ?? "ci.yml"
126
+ const checkOnly = argv.includes("--check")
127
+
128
+ console.log(`\n${bold("npm trusted publishing")} ${dim(`${name} · ${repo} · ${file}`)}\n`)
129
+
130
+ // 0. The preconditions npm states, checked before anything else.
131
+ const version = npm(["--version"]).trim()
132
+ const [major, minor] = version.split(".").map(Number)
133
+ if (major < 11 || (major === 11 && minor < 15)) {
134
+ console.error(red(`npm ${version} is too old for \`npm trust\`, which needs 11.15.0 or later.`))
135
+ console.error(" npm install -g npm@latest")
136
+ return 1
137
+ }
138
+ try {
139
+ npm(["whoami"])
140
+ } catch {
141
+ console.error(red("not logged in to the npm registry."))
142
+ console.error(" npm login")
143
+ return 1
144
+ }
145
+
146
+ const configs = listConfigs(name)
147
+
148
+ if (!configs.length) {
149
+ console.log(yellow(" no trusted publisher is configured for this package."))
150
+ console.log(dim(" The registry answers a publish from an unconfigured package with"))
151
+ console.log(dim(" 'OIDC token exchange error - package not found', which reads like a"))
152
+ console.log(dim(" missing package. The package is there; nothing is configured.\n"))
153
+ if (checkOnly) {
154
+ console.log(dim(" run without --check to create it\n"))
155
+ return 1
156
+ }
157
+ 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
+ ])
170
+ console.log(`\n ${green("✓")} the trusted publisher is configured\n`)
171
+ return 0
172
+ }
173
+
174
+ // 1. npm allows exactly one configuration per package, and it cannot be
175
+ // edited - only revoked and replaced. So the comparison is against the
176
+ // one that is stored.
177
+ for (const config of configs) {
178
+ const got = describe(config)
179
+ const problems = []
180
+ if (got.repository !== repo) {
181
+ problems.push(`repository is ${JSON.stringify(got.repository)}, expected ${JSON.stringify(repo)}`)
182
+ }
183
+ if (got.file !== file) {
184
+ problems.push(`workflow file is ${JSON.stringify(got.file)}, expected ${JSON.stringify(file)}`)
185
+ }
186
+ if (got.environment) {
187
+ problems.push(
188
+ `environment is ${JSON.stringify(got.environment)}, expected none - the workflow declares no environment, so a claim that is never sent can never match`,
189
+ )
190
+ }
191
+ if (!got.permissions.includes("createPackage")) {
192
+ problems.push(
193
+ `permissions are [${got.permissions.join(", ")}], expected createPackage - without it the configuration cannot publish`,
194
+ )
195
+ }
196
+
197
+ if (!problems.length) {
198
+ console.log(` ${green("✓")} the stored configuration matches the workflow`)
199
+ console.log(dim(` repository ${got.repository} · file ${got.file} · no environment`))
200
+ console.log(dim(` permissions ${got.permissions.join(", ")}`))
201
+ console.log(`\n ${green("nothing to do")}\n`)
202
+ return 0
203
+ }
204
+
205
+ console.log(yellow(` the stored configuration does not match the workflow:`))
206
+ for (const p of problems) console.log(` - ${p}`)
207
+ console.log("")
208
+ if (checkOnly) {
209
+ console.log(dim(" run without --check to replace it\n"))
210
+ return 1
211
+ }
212
+ console.log(dim(" npm allows one configuration per package and cannot edit it,"))
213
+ console.log(dim(" so it is revoked and replaced.\n"))
214
+ const id = config.id ?? config.trustId ?? config._id
215
+ if (!id) {
216
+ console.error(red(" the stored configuration has no id, so it cannot be revoked from here."))
217
+ console.error(" revoke it on npmjs.com and run this again.")
218
+ return 1
219
+ }
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
+ ])
233
+ console.log(`\n ${green("✓")} the trusted publisher is configured\n`)
234
+ }
235
+ return 0
236
+ }