@ham2k/extension-tools 0.2.0 → 0.4.0

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.
@@ -0,0 +1,339 @@
1
+ #!/usr/bin/env node
2
+ // Copyright ©️ 2026 Sebastian Delmont <sd@ham2k.com>
3
+ // SPDX-License-Identifier: MIT
4
+ //
5
+ // Publishes a `.h2kext` to the extension catalog — catalog.ham2k.net, whose
6
+ // SPEC.md lives in the ham2k/catalog.ham2k.net repo; the § references below
7
+ // are to it (§5.1 is the publish request, §15 this command).
8
+ //
9
+ // H2K_CATALOG_TOKEN=h2kc_… h2kext-publish <bundle.h2kext | build dir>
10
+ // [--channel <stable|unstable|bleeding>] [--notes "…"] [--catalog <url>]
11
+ // [--force-name]
12
+ //
13
+ // One POST of the raw bytes, then one PUT for the notes; no retries: a refusal
14
+ // is the catalog's verdict on these bytes, and sending them again can only
15
+ // repeat it. The token is read from the environment for those requests and
16
+ // never written anywhere — not to a config file, not to the terminal, not into
17
+ // an error message.
18
+
19
+ import { realpathSync } from "node:fs"
20
+ import { mkdtemp, readFile, rm, stat } from "node:fs/promises"
21
+ import { tmpdir } from "node:os"
22
+ import { join } from "node:path"
23
+ import { pathToFileURL } from "node:url"
24
+ import { inflateRawSync } from "node:zlib"
25
+
26
+ import { MANIFEST_ENTRY, pack, PackError } from "./h2kext.mjs"
27
+
28
+ export const DEFAULT_CATALOG = "https://catalog.ham2k.net"
29
+ export const PUBLISH_PATH = "/api/v1/publish"
30
+ export const SUBMISSIONS_PATH = "/api/v1/submissions"
31
+ export const TOKEN_ENV = "H2K_CATALOG_TOKEN"
32
+ /// How finished a release is, steadiest first — NOT an app edition. The
33
+ /// catalog cascades, so a release on `stable` is visible to everyone and one
34
+ /// on `bleeding` only to somebody who asked for it.
35
+ export const CHANNELS = ["stable", "unstable", "bleeding"]
36
+
37
+ /// What `--channel` means when it is not given. Publishing without saying is
38
+ /// publishing something finished; anything rougher is a choice worth typing.
39
+ export const DEFAULT_CHANNEL = "stable"
40
+
41
+ /// The catalog refuses a larger upload before reading it (§5.1 step 2),
42
+ /// because the app never downloads more than this (`kMaxDownloadBytes`).
43
+ /// Checked here so the refusal costs nothing instead of a wasted upload.
44
+ export const MAX_BUNDLE_BYTES = 16 * 1024 * 1024
45
+
46
+ /// Exit codes. Usage problems — including a missing token — are 2 so a script
47
+ /// can tell "you called this wrong" from "the catalog said no" (1).
48
+ export const EXIT_REFUSED = 1
49
+ export const EXIT_USAGE = 2
50
+
51
+ /// How a submission status reads on the result line. `pending` means a
52
+ /// reviewer has not looked yet; saying "review" is what stops an author
53
+ /// waiting for the extension to appear.
54
+ const STATUS_WORDS = { pending: "pending review" }
55
+
56
+ export class PublishError extends Error {
57
+ constructor(message, exitCode = EXIT_REFUSED) {
58
+ super(message)
59
+ this.exitCode = exitCode
60
+ }
61
+ }
62
+
63
+ const USAGE = `Publish an extension bundle to the Ham2K extension catalog.
64
+
65
+ ${TOKEN_ENV}=h2kc_… h2kext-publish <bundle.h2kext | build dir> [--channel <stable|unstable|bleeding>]
66
+ [--notes "<release notes>"] [--catalog <url>] [--force-name]
67
+
68
+ A directory is packaged first, exactly as h2kext-pack would. A bundle over
69
+ 16 MiB is refused here, as the catalog would refuse it.
70
+
71
+ The token comes from the ${TOKEN_ENV} environment variable and only from
72
+ there — a flag would land in shell history and \`ps\`. Get one at
73
+ ${DEFAULT_CATALOG}/publish. Nothing here stores it.
74
+
75
+ --channel how finished this release is (default ${DEFAULT_CHANNEL});
76
+ stable is visible to everyone, bleeding only to somebody
77
+ who asked for it
78
+ --notes release notes for this version, markdown; sent in a second
79
+ request once the upload is accepted
80
+ --catalog defaults to ${DEFAULT_CATALOG}
81
+ --force-name waive the key naming convention when packaging a directory
82
+ (Ham2K's own builds; the catalog checks it again regardless)
83
+
84
+ Exits 0 once the catalog holds the submission and its notes, 1 when it refuses
85
+ or cannot be reached, 2 for a usage problem.`
86
+
87
+ /// The manifest inside a bundle's bytes.
88
+ ///
89
+ /// A reader for one entry of a zip whose writer is ours — h2kext.mjs via yazl:
90
+ /// stored or deflated, no zip64, no encryption — which is why this is forty
91
+ /// lines and not a dependency. Sizes come from the central directory, never
92
+ /// the local header: yazl streams, so the local header's sizes are zero and
93
+ /// the truth sits in the data descriptor after the entry.
94
+ export function readManifestFromBundle(bytes) {
95
+ const buf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes)
96
+ if (buf.length < 22 || buf.readUInt32LE(0) !== 0x04034b50) {
97
+ throw new PublishError("not a .h2kext: not a zip")
98
+ }
99
+ // The end-of-central-directory record is the last 22 bytes plus a comment of
100
+ // up to 64 KiB, so the search walks backwards over that much and no more.
101
+ let eocd = -1
102
+ for (let i = buf.length - 22; i >= Math.max(0, buf.length - 22 - 0xffff); i--) {
103
+ if (buf.readUInt32LE(i) === 0x06054b50) {
104
+ eocd = i
105
+ break
106
+ }
107
+ }
108
+ if (eocd < 0) throw new PublishError("not a .h2kext: no zip central directory")
109
+
110
+ const count = buf.readUInt16LE(eocd + 10)
111
+ let offset = buf.readUInt32LE(eocd + 16)
112
+ for (let n = 0; n < count; n++) {
113
+ if (offset + 46 > buf.length || buf.readUInt32LE(offset) !== 0x02014b50) {
114
+ throw new PublishError("not a .h2kext: damaged zip central directory")
115
+ }
116
+ const method = buf.readUInt16LE(offset + 10)
117
+ const compressedSize = buf.readUInt32LE(offset + 20)
118
+ const nameLength = buf.readUInt16LE(offset + 28)
119
+ const extraLength = buf.readUInt16LE(offset + 30)
120
+ const commentLength = buf.readUInt16LE(offset + 32)
121
+ const localOffset = buf.readUInt32LE(offset + 42)
122
+ const name = buf.toString("utf8", offset + 46, offset + 46 + nameLength)
123
+ if (name === MANIFEST_ENTRY) {
124
+ // Every offset here comes from the file itself, so a truncated or
125
+ // doctored bundle can point past its own end: refused as a bundle,
126
+ // not as a RangeError out of Buffer.
127
+ const damaged = `not a .h2kext: ${MANIFEST_ENTRY} entry is truncated or damaged`
128
+ if (localOffset + 30 > buf.length || buf.readUInt32LE(localOffset) !== 0x04034b50) throw new PublishError(damaged)
129
+ const start = localOffset + 30 + buf.readUInt16LE(localOffset + 26) + buf.readUInt16LE(localOffset + 28)
130
+ if (start + compressedSize > buf.length) throw new PublishError(damaged)
131
+ const data = buf.subarray(start, start + compressedSize)
132
+ let raw
133
+ if (method === 0) raw = data
134
+ else if (method === 8) {
135
+ try {
136
+ raw = inflateRawSync(data)
137
+ } catch (e) {
138
+ throw new PublishError(`${damaged}: ${e.message}`)
139
+ }
140
+ } else throw new PublishError(`not a .h2kext: ${MANIFEST_ENTRY} uses zip compression method ${method}`)
141
+ try {
142
+ return JSON.parse(raw.toString("utf8"))
143
+ } catch (e) {
144
+ throw new PublishError(`${MANIFEST_ENTRY} inside the bundle is not valid JSON: ${e.message}`)
145
+ }
146
+ }
147
+ offset += 46 + nameLength + extraLength + commentLength
148
+ }
149
+ throw new PublishError(`not a .h2kext: no ${MANIFEST_ENTRY} inside`)
150
+ }
151
+
152
+ /// The bundle's bytes: the file as given, or a directory packaged the way
153
+ /// h2kext-pack packages it, into a temporary file that is gone before this
154
+ /// returns.
155
+ async function loadBundle(source, { forceName }) {
156
+ let info
157
+ try {
158
+ info = await stat(source)
159
+ } catch (e) {
160
+ throw new PublishError(`${source}: ${e.code === "ENOENT" ? "no such file or directory" : e.message}`)
161
+ }
162
+ if (!info.isDirectory()) return await readFile(source)
163
+
164
+ const tmp = await mkdtemp(join(tmpdir(), "h2kext-publish-"))
165
+ try {
166
+ const { outPath } = await pack(source, { outPath: join(tmp, "bundle.h2kext"), forceName })
167
+ return await readFile(outPath)
168
+ } catch (e) {
169
+ if (e instanceof PackError) throw new PublishError(`${source} is not ready to publish:\n${e.message}`)
170
+ throw e
171
+ } finally {
172
+ await rm(tmp, { recursive: true, force: true })
173
+ }
174
+ }
175
+
176
+ /// One request to the catalog, its answer parsed as JSON when it is JSON. A
177
+ /// network failure is a [PublishError] naming the catalog, not a stack trace.
178
+ async function call(fetch, catalog, path, init) {
179
+ let response
180
+ try {
181
+ response = await fetch(`${catalog}${path}`, init)
182
+ } catch (e) {
183
+ throw new PublishError(`could not reach ${catalog}: ${e.cause?.message ?? e.message}`)
184
+ }
185
+ const text = await response.text()
186
+ let body
187
+ try {
188
+ body = JSON.parse(text)
189
+ } catch {
190
+ body = undefined
191
+ }
192
+ return { response, text, body }
193
+ }
194
+
195
+ /// What a refusal says: the server's `error` and `code`, or its raw body when
196
+ /// it sent no JSON — a proxy's HTML is still worth seeing.
197
+ function refusal({ response, text, body }) {
198
+ return body?.error ? `${body.error}${body.code ? ` (${body.code})` : ""}` : text.trim() || response.statusText || "no response body"
199
+ }
200
+
201
+ /// Uploads [bytes] as the raw request body (§5.1: no multipart) and
202
+ /// returns the catalog's 201 body. Any other answer is a [PublishError].
203
+ ///
204
+ /// `Content-Length` is not set here: fetch derives it from a Buffer body, and a
205
+ /// header set by hand that disagreed with the body would be the request's
206
+ /// failure, not the catalog's.
207
+ export async function publish({ bytes, manifest, channel, catalog, token }, { fetch = globalThis.fetch } = {}) {
208
+ const answer = await call(fetch, catalog, `${PUBLISH_PATH}?channel=${channel}`, {
209
+ method: "POST",
210
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/zip" },
211
+ body: bytes,
212
+ })
213
+ if (answer.response.status === 201 && answer.body) return answer.body
214
+ throw new PublishError(`${catalog} refused ${manifest.key} ${manifest.version} with HTTP ${answer.response.status}: ${refusal(answer)}`)
215
+ }
216
+
217
+ /// Sets a submission's release notes (§5.1, the second call). Any answer
218
+ /// outside 2xx is a [PublishError]; the submission it was for stands.
219
+ export async function setReleaseNotes({ submissionId, notes, catalog, token }, { fetch = globalThis.fetch } = {}) {
220
+ const answer = await call(fetch, catalog, `${SUBMISSIONS_PATH}/${encodeURIComponent(submissionId)}/notes`, {
221
+ method: "PUT",
222
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
223
+ body: JSON.stringify({ releaseNotes: notes }),
224
+ })
225
+ if (answer.response.ok) return
226
+ throw new PublishError(`${catalog} refused the release notes for submission ${submissionId} with HTTP ${answer.response.status}: ${refusal(answer)}`)
227
+ }
228
+
229
+ /// The command. Returns the exit code rather than exiting, so the same code
230
+ /// runs under a test with a fake `fetch` and captured output.
231
+ export async function main(argv, { env = process.env, fetch = globalThis.fetch, stdout = process.stdout, stderr = process.stderr } = {}) {
232
+ if (argv.length === 0 || argv.includes("--help") || argv.includes("-h")) {
233
+ stdout.write(`${USAGE}\n`)
234
+ return argv.length === 0 ? EXIT_USAGE : 0
235
+ }
236
+
237
+ const token = env[TOKEN_ENV]
238
+
239
+ // Every line out goes through this, so the token cannot reach a terminal or
240
+ // a CI log by way of an error that happened to quote a header or a URL.
241
+ const redact = (s) => (token ? String(s).split(token).join("<token>") : String(s))
242
+ const say = (s) => stdout.write(`${redact(s)}\n`)
243
+ const complain = (s) => stderr.write(`h2kext-publish: ${redact(s)}\n`)
244
+
245
+ try {
246
+ // Walked by index, like h2kext-pack: `indexOf` finds the FIRST occurrence,
247
+ // and a value can repeat an option's name. Unlike h2kext-pack, an option
248
+ // this does not know is a refusal: `--token h2kc_…` from an older script
249
+ // would otherwise be ignored and the token sent nowhere, after sitting in
250
+ // the shell history — the reason the flag does not exist.
251
+ let source
252
+ let channel
253
+ let notes
254
+ let catalog = DEFAULT_CATALOG
255
+ let forceName = false
256
+ // An option at the end of the line has no value, and `--notes` with none
257
+ // would silently publish without notes — the one option whose absence
258
+ // looks like success.
259
+ const valueOf = (i) => {
260
+ if (i + 1 >= argv.length) throw new PublishError(`${argv[i]} needs a value`, EXIT_USAGE)
261
+ return argv[i + 1]
262
+ }
263
+ for (let i = 0; i < argv.length; i++) {
264
+ const arg = argv[i]
265
+ if (arg === "--channel") channel = valueOf(i++)
266
+ else if (arg === "--notes") notes = valueOf(i++)
267
+ else if (arg === "--catalog") catalog = valueOf(i++)
268
+ else if (arg === "--force-name") forceName = true
269
+ else if (arg === "--token") throw new PublishError(`there is no --token option; set ${TOKEN_ENV} instead`, EXIT_USAGE)
270
+ else if (arg.startsWith("-")) throw new PublishError(`unknown option '${arg}'`, EXIT_USAGE)
271
+ else if (source === undefined) source = arg
272
+ }
273
+
274
+ if (!source) throw new PublishError("name the bundle or directory to publish", EXIT_USAGE)
275
+ channel ??= DEFAULT_CHANNEL
276
+ if (!CHANNELS.includes(channel)) {
277
+ throw new PublishError(`--channel must be one of ${CHANNELS.join(", ")}${channel ? `, not '${channel}'` : ""}`, EXIT_USAGE)
278
+ }
279
+ if (!token) {
280
+ throw new PublishError(`no token — set ${TOKEN_ENV} (get one at ${catalog}/publish)`, EXIT_USAGE)
281
+ }
282
+ try {
283
+ catalog = new URL(catalog).href.replace(/\/$/, "")
284
+ } catch {
285
+ throw new PublishError(`--catalog must be a URL, not '${catalog}'`, EXIT_USAGE)
286
+ }
287
+
288
+ const bytes = await loadBundle(source, { forceName })
289
+ if (bytes.length > MAX_BUNDLE_BYTES) {
290
+ throw new PublishError(
291
+ `${source} is ${bytes.length} bytes; the catalog takes at most 16 MiB (${MAX_BUNDLE_BYTES} bytes), because the app downloads no more than that`,
292
+ )
293
+ }
294
+ const manifest = readManifestFromBundle(bytes)
295
+ for (const field of ["key", "version"]) {
296
+ if (typeof manifest[field] !== "string" || manifest[field].trim() === "") {
297
+ throw new PublishError(`${MANIFEST_ENTRY} inside the bundle has no '${field}'`)
298
+ }
299
+ }
300
+ say(`publishing ${manifest.key} ${manifest.version}${manifest.name ? ` — ${manifest.name}` : ""} → ${channel} at ${catalog}`)
301
+
302
+ const result = await publish({ bytes, manifest, channel, catalog, token }, { fetch })
303
+ say(`published ${result.key} ${result.version} → ${result.channel} (${STATUS_WORDS[result.status] ?? result.status})`)
304
+ if (result.url) say(result.url)
305
+
306
+ if (notes !== undefined) {
307
+ try {
308
+ if (result.submissionId === undefined || result.submissionId === null) {
309
+ throw new PublishError(`the catalog's answer named no submissionId to attach them to`)
310
+ }
311
+ await setReleaseNotes({ submissionId: result.submissionId, notes, catalog, token }, { fetch })
312
+ } catch (e) {
313
+ if (!(e instanceof PublishError)) throw e
314
+ // The version is published and stays published: the notes are the
315
+ // site's to edit at any time, so nothing is rolled back — but the
316
+ // exit code says the command did not do all it was asked.
317
+ complain(`warning: ${result.key} ${result.version} is published, but its release notes were not set — ${e.message}. Edit them on the site instead.`)
318
+ return EXIT_REFUSED
319
+ }
320
+ }
321
+ return 0
322
+ } catch (e) {
323
+ if (e instanceof PublishError) {
324
+ complain(e.message)
325
+ return e.exitCode
326
+ }
327
+ // Anything else is a bug here, not a refusal — but it still goes through
328
+ // the redactor rather than to an uncaught-exception stack trace.
329
+ complain(e.stack ?? e)
330
+ return EXIT_REFUSED
331
+ }
332
+ }
333
+
334
+ // Run only as the command. `realpathSync` because npm installs a bin as a
335
+ // symlink while `import.meta.url` resolves to the real file — compared raw,
336
+ // `npx h2kext-publish` would import this module and then do nothing.
337
+ if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
338
+ process.exit(await main(process.argv.slice(2)))
339
+ }