@ham2k/extension-tools 0.14.0 → 0.15.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.
package/README.md CHANGED
@@ -107,3 +107,32 @@ A channel says how finished a release is — `stable`, `unstable`, `bleeding`
107
107
  not which app edition is asking. They cascade upward, so `stable` reaches
108
108
  everyone and `bleeding` reaches only somebody who asked for it. `--channel` is
109
109
  optional and defaults to `stable`.
110
+
111
+ ### Screenshots
112
+
113
+ ```
114
+ screenshots/
115
+ 1-logging.png
116
+ 2-map.png
117
+ screenshots.json optional — { "1-logging.png": "Logging a park" }
118
+ ```
119
+
120
+ When your project has a `screenshots/` folder (or you name one with
121
+ `--screenshots <dir>`), `h2kext-publish` makes the catalog's screenshots for
122
+ your extension match it, after the bundle is accepted. The folder is the
123
+ whole set: PNG, JPEG or WebP files, at most 8, shown in file-name order
124
+ (`2-…` before `10-…`), captions from `screenshots.json`. Screenshots are never
125
+ packed into the bundle, and they belong to the extension rather than to a
126
+ version: every channel shows the same ones, and
127
+ `h2kext-publish build --screenshots-only` updates them without publishing a
128
+ version.
129
+
130
+ Each screenshot is at most 2 MiB and 3840 pixels on its longest side. The
131
+ command makes the thumbnail the app shows in search results itself, with
132
+ [sharp](https://sharp.pixelplumbing.com) — an optional dependency, so if it
133
+ could not install on your machine the command says so, and you can add your
134
+ screenshots on the site's [/publish](https://catalog.ham2k.net/publish) page
135
+ instead. An unchanged folder sends nothing. A new set waits for a reviewer,
136
+ like a version, while the approved one keeps showing. An empty folder named
137
+ with `--screenshots` takes every screenshot down at once; an empty
138
+ `./screenshots` found on its own is left alone.
@@ -8,18 +8,19 @@
8
8
  //
9
9
  // H2K_CATALOG_TOKEN=h2kc_… h2kext-publish <bundle.h2kext | build dir>
10
10
  // [--channel <stable|unstable|bleeding>] [--notes "…"] [--catalog <url>]
11
- // [--force-name]
11
+ // [--force-name] [--screenshots <dir>] [--screenshots-only]
12
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
13
+ // One POST of the raw bytes, then one PUT for the notes, then the screenshot
14
+ // sync (§4.8); no retries: a refusal is the catalog's verdict on these bytes,
15
+ // and sending them again can only repeat it. The token is read from the environment for those requests and
16
16
  // never written anywhere — not to a config file, not to the terminal, not into
17
17
  // an error message.
18
18
 
19
+ import { createHash } from "node:crypto"
19
20
  import { realpathSync } from "node:fs"
20
- import { mkdtemp, readFile, rm, stat } from "node:fs/promises"
21
+ import { mkdtemp, readdir, readFile, rm, stat } from "node:fs/promises"
21
22
  import { tmpdir } from "node:os"
22
- import { join } from "node:path"
23
+ import { extname, join, resolve } from "node:path"
23
24
  import { pathToFileURL } from "node:url"
24
25
  import { inflateRawSync } from "node:zlib"
25
26
 
@@ -43,6 +44,16 @@ export const DEFAULT_CHANNEL = "stable"
43
44
  /// Checked here so the refusal costs nothing instead of a wasted upload.
44
45
  export const MAX_BUNDLE_BYTES = 16 * 1024 * 1024
45
46
 
47
+ /// Screenshots (§4.8). The catalog checks every one of these again; checking
48
+ /// here turns a refusal after a round trip into one before any upload.
49
+ export const SCREENSHOTS_DIR = "screenshots"
50
+ export const CAPTIONS_FILE = "screenshots.json"
51
+ export const MAX_SCREENSHOTS = 8
52
+ export const MAX_SCREENSHOT_BYTES = 2 * 1024 * 1024
53
+ export const THUMB_SIDE = 480
54
+ export const CAPTION_MAX = 200
55
+ const SCREENSHOT_TYPES = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp" }
56
+
46
57
  /// Exit codes. Usage problems — including a missing token — are 2 so a script
47
58
  /// can tell "you called this wrong" from "the catalog said no" (1).
48
59
  export const EXIT_REFUSED = 1
@@ -64,6 +75,7 @@ const USAGE = `Publish an extension bundle to the Ham2K extension catalog.
64
75
 
65
76
  ${TOKEN_ENV}=h2kc_… h2kext-publish <bundle.h2kext | build dir> [--channel <stable|unstable|bleeding>]
66
77
  [--notes "<release notes>"] [--catalog <url>] [--force-name]
78
+ [--screenshots <dir>] [--screenshots-only]
67
79
 
68
80
  A directory is packaged first, exactly as h2kext-pack would. A bundle over
69
81
  16 MiB is refused here, as the catalog would refuse it.
@@ -80,6 +92,14 @@ ${DEFAULT_CATALOG}/publish. Nothing here stores it.
80
92
  --catalog defaults to ${DEFAULT_CATALOG}
81
93
  --force-name waive the key naming convention when packaging a directory
82
94
  (Ham2K's own builds; the catalog checks it again regardless)
95
+ --screenshots the folder holding the extension's screenshots (default
96
+ ./${SCREENSHOTS_DIR}, when it exists). The folder is the whole
97
+ set: PNG, JPEG or WebP files in file-name order, at most
98
+ ${MAX_SCREENSHOTS}, captions from an optional ${CAPTIONS_FILE}
99
+ ({ "<file name>": "<caption>" }). Thumbnails are made with the
100
+ optional \`sharp\` package. An unchanged folder sends nothing.
101
+ --screenshots-only
102
+ sync the screenshots without publishing the bundle
83
103
 
84
104
  Exits 0 once the catalog holds the submission and its notes, 1 when it refuses
85
105
  or cannot be reached, 2 for a usage problem.`
@@ -226,9 +246,177 @@ export async function setReleaseNotes({ submissionId, notes, catalog, token }, {
226
246
  throw new PublishError(`${catalog} refused the release notes for submission ${submissionId} with HTTP ${answer.response.status}: ${refusal(answer)}`)
227
247
  }
228
248
 
249
+ // ---------------------------------------------------------------------------
250
+ // Screenshots (§4.8)
251
+ // ---------------------------------------------------------------------------
252
+
253
+ function sha256Of(bytes) {
254
+ return createHash("sha256").update(bytes).digest("hex")
255
+ }
256
+
257
+ /// The type the catalog will read from the bytes, by the same magic numbers,
258
+ /// or null: a file named `.png` that is not one is refused here, by name.
259
+ export function sniffImageType(bytes) {
260
+ const b = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes)
261
+ if (b.length >= 8 && b.readUInt32BE(0) === 0x89504e47 && b.readUInt32BE(4) === 0x0d0a1a0a) return "image/png"
262
+ if (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return "image/jpeg"
263
+ if (b.length >= 12 && b.toString("latin1", 0, 4) === "RIFF" && b.toString("latin1", 8, 12) === "WEBP") return "image/webp"
264
+ return null
265
+ }
266
+
267
+ /// The folder as a set: every image file in file-name order — numeric-aware,
268
+ /// so `2-…` comes before `10-…` — with its bytes, hash and caption. Anything
269
+ /// else in the folder is ignored, so a README beside the pictures is fine.
270
+ export async function readScreenshotFolder(dir) {
271
+ let entries
272
+ try {
273
+ entries = await readdir(dir, { withFileTypes: true })
274
+ } catch (e) {
275
+ throw new PublishError(`${dir}: ${e.code === "ENOENT" ? "no such directory" : e.message}`)
276
+ }
277
+ // Files only: a directory named `old.png` is not a screenshot.
278
+ const names = entries.filter((entry) => entry.isFile()).map((entry) => entry.name)
279
+ let captions = {}
280
+ if (names.includes(CAPTIONS_FILE)) {
281
+ try {
282
+ captions = JSON.parse(await readFile(join(dir, CAPTIONS_FILE), "utf8"))
283
+ } catch (e) {
284
+ throw new PublishError(`${join(dir, CAPTIONS_FILE)} is not valid JSON: ${e.message}`)
285
+ }
286
+ if (typeof captions !== "object" || captions === null || Array.isArray(captions)) {
287
+ throw new PublishError(`${join(dir, CAPTIONS_FILE)} must map file names to captions`)
288
+ }
289
+ }
290
+ const files = names
291
+ .filter((name) => SCREENSHOT_TYPES[extname(name).toLowerCase()] && !name.startsWith("."))
292
+ .sort((a, b) => a.localeCompare(b, "en", { numeric: true }))
293
+ if (files.length > MAX_SCREENSHOTS) {
294
+ throw new PublishError(`${dir} holds ${files.length} screenshots; the catalog takes at most ${MAX_SCREENSHOTS}`)
295
+ }
296
+ for (const name of Object.keys(captions)) {
297
+ // A caption for a file that is not there is almost always a renamed file.
298
+ if (!files.includes(name)) throw new PublishError(`${join(dir, CAPTIONS_FILE)} has a caption for '${name}', which is not a screenshot in ${dir}`)
299
+ }
300
+ const shots = []
301
+ for (const name of files) {
302
+ const bytes = await readFile(join(dir, name))
303
+ const type = sniffImageType(bytes)
304
+ if (!type) throw new PublishError(`${join(dir, name)} is not a PNG, JPEG or WebP image`)
305
+ if (bytes.length > MAX_SCREENSHOT_BYTES) {
306
+ throw new PublishError(`${join(dir, name)} is ${bytes.length} bytes; a screenshot is at most 2 MiB (${MAX_SCREENSHOT_BYTES} bytes)`)
307
+ }
308
+ const caption = captions[name]
309
+ if (caption !== undefined && caption !== null && typeof caption !== "string") {
310
+ throw new PublishError(`${join(dir, CAPTIONS_FILE)}: the caption for '${name}' must be a string`)
311
+ }
312
+ const trimmed = caption?.trim() || null
313
+ if (trimmed && [...trimmed].length > CAPTION_MAX) {
314
+ throw new PublishError(`${join(dir, CAPTIONS_FILE)}: the caption for '${name}' is longer than ${CAPTION_MAX} characters`)
315
+ }
316
+ shots.push({ name, bytes, type, sha256: sha256Of(bytes), caption: trimmed })
317
+ }
318
+ return shots
319
+ }
320
+
321
+ /// A thumbnail at most [THUMB_SIDE] pixels on its longest side, in the
322
+ /// image's proportions, as WebP — a PNG screenshot's thumbnail kept as PNG is
323
+ /// often past the catalog's 128 KiB, and every client decodes WebP. `sharp`
324
+ /// is optional so packing and plain publishing never need a native module;
325
+ /// it is loaded only when a new picture needs a thumbnail.
326
+ export async function sharpThumbnail(bytes) {
327
+ let sharp
328
+ try {
329
+ sharp = (await import("sharp")).default
330
+ } catch {
331
+ throw new PublishError(
332
+ "making screenshot thumbnails needs the optional 'sharp' package — `npm install --save-dev sharp` in your project, or manage screenshots on the catalog site instead",
333
+ )
334
+ }
335
+ const data = await sharp(bytes).resize({ width: THUMB_SIDE, height: THUMB_SIDE, fit: "inside", withoutEnlargement: true }).webp({ quality: 80 }).toBuffer()
336
+ return { bytes: data, type: "image/webp" }
337
+ }
338
+
339
+ const sameItems = (set, shots) =>
340
+ set !== null &&
341
+ set !== undefined &&
342
+ set.items.length === shots.length &&
343
+ set.items.every((item, i) => item.image === shots[i].sha256 && (item.caption ?? null) === shots[i].caption)
344
+
345
+ /// Makes the catalog's set for [key] the folder's [shots], sending only what
346
+ /// it lacks: nothing at all when the waiting set — or the live one, with none
347
+ /// waiting — already is the folder; otherwise a thumbnail made only for a
348
+ /// picture no set of the key already has one for, the uploads the catalog
349
+ /// does not hold, and the set. Answers what happened, for the result line.
350
+ export async function syncScreenshots({ key, shots, catalog, token }, { fetch = globalThis.fetch, thumbnail = sharpThumbnail } = {}) {
351
+ const base = `/api/v1/extensions/${encodeURIComponent(key)}`
352
+ const auth = { authorization: `Bearer ${token}` }
353
+ const current = await call(fetch, catalog, `${base}/screenshots`, { headers: auth })
354
+ if (!current.response.ok) {
355
+ throw new PublishError(`${catalog} refused to show the screenshots of ${key} with HTTP ${current.response.status}: ${refusal(current)}`)
356
+ }
357
+ const { approved = null, pending = null } = current.body ?? {}
358
+ if (pending ? sameItems(pending, shots) : sameItems(approved, shots)) return { changed: false, set: pending ?? approved }
359
+
360
+ // A picture already in a set keeps that set's thumbnail: making it again
361
+ // with another sharp could give other bytes, and a new set to review.
362
+ const known = new Map()
363
+ for (const set of [approved, pending]) for (const item of set?.items ?? []) known.set(item.image, item.thumb)
364
+ const held = new Set([...known.keys(), ...known.values()])
365
+
366
+ const upload = async (bytes, type, what) => {
367
+ const sha256 = sha256Of(bytes)
368
+ if (held.has(sha256)) return sha256
369
+ const answer = await call(fetch, catalog, `${base}/media/${sha256}`, { method: "PUT", headers: { ...auth, "content-type": type }, body: bytes })
370
+ if (!answer.response.ok) throw new PublishError(`${catalog} refused ${what} with HTTP ${answer.response.status}: ${refusal(answer)}`)
371
+ held.add(sha256)
372
+ return sha256
373
+ }
374
+
375
+ const items = []
376
+ for (const shot of shots) {
377
+ const image = await upload(shot.bytes, shot.type, shot.name)
378
+ let thumb = known.get(shot.sha256)
379
+ if (!thumb) {
380
+ let made
381
+ try {
382
+ made = await thumbnail(shot.bytes)
383
+ } catch (e) {
384
+ // A file whose header looks right and whose body does not: said by
385
+ // name, as a refusal, not as a stack trace that hides whether the
386
+ // version went out.
387
+ if (e instanceof PublishError) throw e
388
+ throw new PublishError(`could not make a thumbnail of ${shot.name}: ${e.message}`)
389
+ }
390
+ thumb = await upload(made.bytes, made.type, `the thumbnail of ${shot.name}`)
391
+ }
392
+ items.push({ image, thumb, ...(shot.caption ? { caption: shot.caption } : {}) })
393
+ }
394
+
395
+ const answer = await call(fetch, catalog, `${base}/screenshots`, {
396
+ method: "PUT",
397
+ headers: { ...auth, "content-type": "application/json" },
398
+ body: JSON.stringify({ screenshots: items }),
399
+ })
400
+ if (!answer.response.ok || !answer.body?.set) {
401
+ throw new PublishError(`${catalog} refused the screenshots of ${key} with HTTP ${answer.response.status}: ${refusal(answer)}`)
402
+ }
403
+ return { changed: true, set: answer.body.set }
404
+ }
405
+
406
+ /// The result line's words for a set's state.
407
+ function screenshotState({ changed, set }) {
408
+ const count = set?.items.length ?? 0
409
+ const what = count === 0 ? "no screenshots" : `${count} screenshot${count === 1 ? "" : "s"}`
410
+ if (!changed) return `screenshots unchanged (${what}${set?.status === "pending" ? ", pending review" : ""})`
411
+ return `screenshots → ${what} (${STATUS_WORDS[set.status] ?? (set.status === "approved" ? "live" : set.status)})`
412
+ }
413
+
229
414
  /// The command. Returns the exit code rather than exiting, so the same code
230
415
  /// 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 } = {}) {
416
+ export async function main(
417
+ argv,
418
+ { env = process.env, fetch = globalThis.fetch, stdout = process.stdout, stderr = process.stderr, cwd = process.cwd(), thumbnail = sharpThumbnail } = {},
419
+ ) {
232
420
  if (argv.length === 0 || argv.includes("--help") || argv.includes("-h")) {
233
421
  stdout.write(`${USAGE}\n`)
234
422
  return argv.length === 0 ? EXIT_USAGE : 0
@@ -253,6 +441,8 @@ export async function main(argv, { env = process.env, fetch = globalThis.fetch,
253
441
  let notes
254
442
  let catalog = DEFAULT_CATALOG
255
443
  let forceName = false
444
+ let screenshotsDir
445
+ let screenshotsOnly = false
256
446
  // An option at the end of the line has no value, and `--notes` with none
257
447
  // would silently publish without notes — the one option whose absence
258
448
  // looks like success.
@@ -266,6 +456,8 @@ export async function main(argv, { env = process.env, fetch = globalThis.fetch,
266
456
  else if (arg === "--notes") notes = valueOf(i++)
267
457
  else if (arg === "--catalog") catalog = valueOf(i++)
268
458
  else if (arg === "--force-name") forceName = true
459
+ else if (arg === "--screenshots") screenshotsDir = valueOf(i++)
460
+ else if (arg === "--screenshots-only") screenshotsOnly = true
269
461
  else if (arg === "--token") throw new PublishError(`there is no --token option; set ${TOKEN_ENV} instead`, EXIT_USAGE)
270
462
  else if (arg.startsWith("-")) throw new PublishError(`unknown option '${arg}'`, EXIT_USAGE)
271
463
  else if (source === undefined) source = arg
@@ -285,6 +477,32 @@ export async function main(argv, { env = process.env, fetch = globalThis.fetch,
285
477
  throw new PublishError(`--catalog must be a URL, not '${catalog}'`, EXIT_USAGE)
286
478
  }
287
479
 
480
+ // The folder is read before anything is sent, so what can be checked
481
+ // without decoding — type, size, count, captions — stops the command
482
+ // before the version is published rather than after; the rest is the
483
+ // catalog's to refuse. Without `--screenshots`, only a folder that exists
484
+ // and holds pictures is a set: a project with none, or a stray empty
485
+ // `screenshots/` where the command happens to run, has not asked for its
486
+ // screenshots to be taken down. Naming the folder is how to ask.
487
+ let shots
488
+ const folder = resolve(cwd, screenshotsDir ?? SCREENSHOTS_DIR)
489
+ if (
490
+ screenshotsDir !== undefined ||
491
+ (await stat(folder).then(
492
+ (s) => s.isDirectory(),
493
+ () => false,
494
+ ))
495
+ ) {
496
+ shots = await readScreenshotFolder(folder)
497
+ if (screenshotsDir === undefined && shots.length === 0) {
498
+ say(`./${SCREENSHOTS_DIR} holds no screenshots; leaving the catalog's as they are (name it with --screenshots to remove them all)`)
499
+ shots = undefined
500
+ if (screenshotsOnly) return 0
501
+ }
502
+ } else if (screenshotsOnly) {
503
+ throw new PublishError(`--screenshots-only, but there is no ./${SCREENSHOTS_DIR} folder; name one with --screenshots`, EXIT_USAGE)
504
+ }
505
+
288
506
  const bytes = await loadBundle(source, { forceName })
289
507
  if (bytes.length > MAX_BUNDLE_BYTES) {
290
508
  throw new PublishError(
@@ -297,6 +515,14 @@ export async function main(argv, { env = process.env, fetch = globalThis.fetch,
297
515
  throw new PublishError(`${MANIFEST_ENTRY} inside the bundle has no '${field}'`)
298
516
  }
299
517
  }
518
+ const syncShots = async () => {
519
+ say(screenshotState(await syncScreenshots({ key: manifest.key, shots, catalog, token }, { fetch, thumbnail })))
520
+ }
521
+ if (screenshotsOnly) {
522
+ await syncShots()
523
+ return 0
524
+ }
525
+
300
526
  say(`publishing ${manifest.key} ${manifest.version}${manifest.name ? ` — ${manifest.name}` : ""} → ${channel} at ${catalog}`)
301
527
 
302
528
  const result = await publish({ bytes, manifest, channel, catalog, token }, { fetch })
@@ -318,6 +544,15 @@ export async function main(argv, { env = process.env, fetch = globalThis.fetch,
318
544
  return EXIT_REFUSED
319
545
  }
320
546
  }
547
+ if (shots !== undefined) {
548
+ try {
549
+ await syncShots()
550
+ } catch (e) {
551
+ if (!(e instanceof PublishError)) throw e
552
+ complain(`warning: ${result.key} ${result.version} is published, but its screenshots were not updated — ${e.message}`)
553
+ return EXIT_REFUSED
554
+ }
555
+ }
321
556
  return 0
322
557
  } catch (e) {
323
558
  if (e instanceof PublishError) {
package/h2kext.mjs CHANGED
@@ -42,8 +42,12 @@ import yazl from "yazl"
42
42
  /// `chasing` hook category; 10 adds the DX cluster connection — the
43
43
  /// `requiresCluster*` grants, the `host.cluster*` calls and the `cluster`
44
44
  /// hook category; 11 adds `n1mm-history:` data-file urls, and
45
- /// `host.sendRadioRaw` with its `requiresRadioRaw` grant.
46
- export const H2KEXT_API_VERSION = 11
45
+ /// `host.sendRadioRaw` with its `requiresRadioRaw` grant; 12 adds scene
46
+ /// controls' `hoverSet`, `icon` layers, room for 512 layers and 256 controls,
47
+ /// knobs' `invertDrag` and scrolls passing through buttons to the knob beneath,
48
+ /// scenes that scroll natively (`scroll`, with `fixed` layers and controls,
49
+ /// and `zoom` strips), `getSpots`' `display`, and `host.suggestQso`.
50
+ export const H2KEXT_API_VERSION = 12
47
51
 
48
52
  /// The first API with `host.webSocket`. A manifest declaring `webSockets`
49
53
  /// under an older one is refused: an older app would install it and fail only
@@ -171,6 +175,7 @@ SHARED_MODULES_BY_API[8] = SHARED_MODULES_BY_API[7]
171
175
  SHARED_MODULES_BY_API[9] = SHARED_MODULES_BY_API[8]
172
176
  SHARED_MODULES_BY_API[10] = SHARED_MODULES_BY_API[9]
173
177
  SHARED_MODULES_BY_API[11] = SHARED_MODULES_BY_API[10]
178
+ SHARED_MODULES_BY_API[12] = SHARED_MODULES_BY_API[11]
174
179
 
175
180
  /// What a scaffolded extension declares on day one: the eight modules the SDK
176
181
  /// barrel itself reaches, so a project that has written no code yet still
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ham2k/extension-tools",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "Build and package extensions for the Ham2K Logger",
5
5
  "keywords": [
6
6
  "ham2k",
@@ -43,6 +43,9 @@
43
43
  "@ham2k/lib-callsigns": "^1.0.2",
44
44
  "yazl": "^3.3.1"
45
45
  },
46
+ "optionalDependencies": {
47
+ "sharp": "^0.34.0"
48
+ },
46
49
  "peerDependencies": {
47
50
  "esbuild": ">=0.20"
48
51
  },