@ham2k/extension-tools 1.12.1 → 1.13.1

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
@@ -66,6 +66,26 @@ list: the app does not show an undeclared panel. If activation needs more than
66
66
  the stand-in provides, or waits on a timer or the host, the build succeeds and
67
67
  prints a warning that this check did not run.
68
68
 
69
+ ## Developing
70
+
71
+ ```sh
72
+ npx h2kext-dev
73
+ ```
74
+
75
+ Builds the extension in memory, serves it to the app and rebuilds as you save.
76
+ In the app, **Settings → Developer → Extension Dev Server**, enter one of the
77
+ addresses it prints, connect: the app swaps that one extension in place on
78
+ every rebuild, without an install, and what the extension logs comes back to
79
+ this terminal. A save that does not build is reported here and the app keeps
80
+ the last version that did. The scaffold writes it as `npm run dev`.
81
+
82
+ It binds `0.0.0.0` so a phone on the LAN can reach it; `--host 127.0.0.1`
83
+ keeps it to this machine, `--port` moves it off 8787. `--entry` and `--inline`
84
+ take the same values `buildExtension` does; pass whatever your `build.mjs`
85
+ passes, or the dev build and the shipping build differ. The manifest is held to
86
+ what the packer accepts, so a manifest that would fail `h2kext-pack` is a
87
+ build error here rather than a bundle the app installs without the checks.
88
+
69
89
  ## Packaging
70
90
 
71
91
  ```sh
package/h2kext-build.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  //
4
4
  // Building an extension: the esbuild settings a Ham2K bundle needs, and the
5
5
  // plugin that takes the host up on the libraries it carries (HALO-442,
6
- // docs/extensions/development.md).
6
+ // docs/extensions/in-repo.md#the-dev-server).
7
7
  //
8
8
  // Standalone in the same way h2kext.mjs is, and for the same reason — that
9
9
  // module is its only import, because its whole purpose is to work for someone
package/h2kext-dev.mjs ADDED
@@ -0,0 +1,536 @@
1
+ #!/usr/bin/env node
2
+ // Copyright ©️ 2026 Sebastian Delmont <sd@ham2k.com>
3
+ // SPDX-License-Identifier: MIT
4
+ //
5
+ // The dev loop for an extension built outside the Ham2K Logger repository.
6
+ //
7
+ // npx h2kext-dev # the extension in the current directory
8
+ // npx h2kext-dev --port 9000 # default 8787
9
+ // npx h2kext-dev --host 127.0.0.1
10
+ //
11
+ // Builds the extension in memory through the same preset `build.mjs` uses,
12
+ // rebuilds as files are saved, and serves the result to the app's
13
+ // Settings → Developer → Extension Dev Server, which swaps that one extension
14
+ // in a running app without an install. What the extension logs comes back
15
+ // here, so an author working against a phone reads their own lines in this
16
+ // terminal.
17
+ //
18
+ // It speaks the one protocol the app's dev client knows, shared with the
19
+ // repository's own dev server (extensions/dev-server.mjs), which imports the
20
+ // constants and helpers below rather than keeping a copy: `/ping`,
21
+ // `/index.json`, `/<key>.js`, `/events` as Server-Sent Events, `POST /logs`.
22
+ // Server-Sent Events plus a plain POST rather than a WebSocket, so neither
23
+ // side needs a dependency: Node has no WebSocket server built in, and a reload
24
+ // notification is one-way.
25
+ //
26
+ // The app is pointed at a LAN address, not a local path, because the app is
27
+ // usually on a phone. 0.0.0.0 by default for that reason.
28
+ //
29
+ // The manifest is held to what the packer would accept. A dev-served bundle
30
+ // is installed with none of the packer's checks, so a manifest the packer
31
+ // refuses is reported here as a build error rather than handed to the app,
32
+ // where it would run and then fail to pack.
33
+
34
+ import { realpathSync } from "node:fs"
35
+ import { readFile, watch } from "node:fs/promises"
36
+ import { createServer } from "node:http"
37
+ import { createRequire } from "node:module"
38
+ import { networkInterfaces } from "node:os"
39
+ import { join, resolve } from "node:path"
40
+ import { pathToFileURL } from "node:url"
41
+
42
+ import { buildOptionsFor } from "./h2kext-build.mjs"
43
+ import { indexEntryFor, MANIFEST_ENTRY, validateManifest } from "./h2kext.mjs"
44
+
45
+ /// The protocol the app's dev client speaks. Bumped when the shape below
46
+ /// changes, so an app built against another says so instead of failing
47
+ /// obscurely; it moves together with the repository's dev server (which
48
+ /// imports it) and the app's client.
49
+ export const PROTOCOL = 1
50
+
51
+ /// What `/ping` answers as `server`. The app's client refuses any other
52
+ /// value, so the repository's dev server and this one answer the same name.
53
+ export const SERVER_NAME = "halo-ext-dev"
54
+
55
+ export const DEFAULT_PORT = 8787
56
+
57
+ /// What `/<key>.js` serves while there is no good build yet: an extension the
58
+ /// app can load that registers nothing. Serving it, rather than a 404, is what
59
+ /// lets the app connect at all — its client fetches every listed bundle before
60
+ /// it subscribes to events, and a bundle it cannot fetch ends the connection
61
+ /// before the error backlog is read.
62
+ export const EMPTY_BUNDLE = "// h2kext-dev: nothing has built yet; the error is in the terminal and on the app's dev server screen\n"
63
+
64
+ /// A usage error: the author typed something this tool cannot act on.
65
+ export class DevServerError extends Error {}
66
+
67
+ /// What the command line asked for. Throws [DevServerError] for a flag it does
68
+ /// not know or a port it cannot listen on.
69
+ export function parseArgs(argv) {
70
+ const options = { dir: process.cwd(), host: "0.0.0.0", port: DEFAULT_PORT, entry: "src/index.ts", inline: [] }
71
+ for (let i = 0; i < argv.length; i += 1) {
72
+ const arg = argv[i]
73
+ const value = () => {
74
+ if (i + 1 >= argv.length) throw new DevServerError(`${arg} needs a value`)
75
+ i += 1
76
+ return argv[i]
77
+ }
78
+ switch (arg) {
79
+ case "--port": {
80
+ const raw = value()
81
+ const port = Number(raw)
82
+ // Checked rather than trusted: `server.listen(NaN)` does not throw, it
83
+ // binds an ephemeral port, and the first thing anyone does with this
84
+ // tool is read a URL off the screen that would then name a port
85
+ // nothing is listening on.
86
+ if (!/^\d+$/.test(raw) || !Number.isInteger(port) || port < 1 || port > 65535) {
87
+ throw new DevServerError(`--port must be a number between 1 and 65535, not "${raw}"`)
88
+ }
89
+ options.port = port
90
+ break
91
+ }
92
+ case "--host":
93
+ options.host = value()
94
+ break
95
+ case "--entry":
96
+ options.entry = value()
97
+ break
98
+ case "--inline":
99
+ options.inline = value()
100
+ .split(",")
101
+ .map((s) => s.trim())
102
+ .filter(Boolean)
103
+ break
104
+ case "--dir":
105
+ options.dir = resolve(value())
106
+ break
107
+ case "--help":
108
+ case "-h":
109
+ options.help = true
110
+ break
111
+ default:
112
+ throw new DevServerError(`unknown option ${arg}`)
113
+ }
114
+ }
115
+ return options
116
+ }
117
+
118
+ export const USAGE = `usage: h2kext-dev [--port <n>] [--host <address>] [--entry <file>] [--inline <pkg,…>] [--dir <path>]
119
+
120
+ Serves the extension in the current directory to the app's Extension Dev
121
+ Server (Settings → Developer), rebuilding it as you save and printing what it
122
+ logs. Point the app at one of the addresses it prints.
123
+
124
+ --port the port to listen on (default ${DEFAULT_PORT})
125
+ --host the address to bind (default 0.0.0.0, so a phone on the LAN can connect)
126
+ --entry the entry point (default src/index.ts); pass what build.mjs passes
127
+ --inline shared libraries to bundle rather than take from the app; pass what build.mjs passes
128
+ --dir the extension directory (default the current directory)`
129
+
130
+ /// The author's own esbuild, resolved from their project rather than from
131
+ /// wherever this file is installed: the preset is written to use the caller's
132
+ /// copy, and a second one would be a second version with its own opinions.
133
+ export async function loadEsbuild(dir) {
134
+ const require = createRequire(resolve(dir, "noop.js"))
135
+ let path
136
+ try {
137
+ path = require.resolve("esbuild")
138
+ } catch {
139
+ throw new DevServerError("esbuild is not installed in this project — `npm install --save-dev esbuild`")
140
+ }
141
+ return import(pathToFileURL(path).href)
142
+ }
143
+
144
+ /// The two manifest fields the build itself depends on, beyond what the
145
+ /// bundle imports: `sharedDependencies` decides which imports become lookups
146
+ /// on the app's copy, `api` whether the timer globals are injected. A change
147
+ /// to either needs a new build context; everything else travels with a
148
+ /// reload.
149
+ function buildShape(manifest) {
150
+ return JSON.stringify({ api: manifest.api ?? null, sharedDependencies: manifest.sharedDependencies ?? null })
151
+ }
152
+
153
+ /// Serves one extension directory to the app. [esbuild] is the author's
154
+ /// module (its `context` is what is used); [log] and [warn] receive every line
155
+ /// this would print, so a caller may route them.
156
+ ///
157
+ /// Resolves once the first build has settled, built or failed, with the
158
+ /// listening `port`, a `url` on localhost, the LAN `addresses` to print, and
159
+ /// `close()`, which stops watching and listening.
160
+ export async function createDevServer({
161
+ dir,
162
+ esbuild,
163
+ host = "0.0.0.0",
164
+ port = DEFAULT_PORT,
165
+ entry = "src/index.ts",
166
+ inline = [],
167
+ log = console.log,
168
+ warn = console.error,
169
+ }) {
170
+ const manifestPath = join(dir, MANIFEST_ENTRY)
171
+ let startManifestText
172
+ try {
173
+ startManifestText = await readFile(manifestPath, "utf8")
174
+ } catch (e) {
175
+ throw new DevServerError(`could not read ${manifestPath}: ${e.message}`)
176
+ }
177
+ let startManifest
178
+ try {
179
+ startManifest = JSON.parse(startManifestText)
180
+ } catch (e) {
181
+ throw new DevServerError(`${MANIFEST_ENTRY} is not JSON: ${e.message}`)
182
+ }
183
+ // The key names the bundle on every route, so a manifest without a usable
184
+ // one has nothing this tool can serve under. Every other problem is a build
185
+ // error the app is told about, below.
186
+ const key = typeof startManifest.key === "string" && /^[a-z0-9][a-z0-9-]*$/.test(startManifest.key) ? startManifest.key : null
187
+ if (!key) throw new DevServerError(`${MANIFEST_ENTRY}: 'key' must be lowercase letters, digits and hyphens`)
188
+
189
+ /// The built bundle, the manifest it was built against, and the last build
190
+ /// error. A failed build keeps serving the previous bundle: the app asked
191
+ /// for what builds, and a half-written file mid-keystroke is no reason to
192
+ /// take the extension away. Until there is a good build, the index lists
193
+ /// the extension from the manifest as it stands and the bundle route serves
194
+ /// [EMPTY_BUNDLE], so the app connects and hears the error.
195
+ let source = null
196
+ let servedManifest = startManifest
197
+ let servedManifestText = null
198
+ let buildError = null
199
+ const clients = new Set()
200
+ let watching = false
201
+
202
+ function fail(message) {
203
+ buildError = message
204
+ if (watching) {
205
+ warn(`${stamp()} ✗ ${key}\n${message}`)
206
+ broadcast(clients, { type: "buildError", key, message })
207
+ }
208
+ }
209
+
210
+ /// Re-reads the manifest after a rebuild. The scaffold's entry imports it,
211
+ /// so editing it rebuilds; a project that does not import it is covered by
212
+ /// the directory watch below. Validated as the packer validates, so
213
+ /// `domains` travels with the reload and a manifest the packer would refuse
214
+ /// never reaches the app.
215
+ async function currentManifest() {
216
+ const text = await readFile(manifestPath, "utf8")
217
+ let parsed
218
+ try {
219
+ parsed = JSON.parse(text)
220
+ } catch (e) {
221
+ throw new Error(`${MANIFEST_ENTRY}: ${e.message}`)
222
+ }
223
+ const problems = validateManifest(parsed)
224
+ if (problems.length) throw new Error(problems.join("\n"))
225
+ if (parsed.key !== key) throw new Error(`${MANIFEST_ENTRY}: 'key' changed from ${key} to ${parsed.key}; restart h2kext-dev to serve it`)
226
+ return { parsed, text }
227
+ }
228
+
229
+ /// The esbuild context, rebuilt from scratch when the manifest changes in
230
+ /// a way the build options depend on (see [buildShape]); `ctx` is always
231
+ /// the live one.
232
+ let ctx = null
233
+ let ctxShape = null
234
+ let settleBuild = null
235
+ const nextBuild = () =>
236
+ new Promise((r) => {
237
+ settleBuild = r
238
+ })
239
+
240
+ const plugin = {
241
+ name: "h2kext-dev-serve",
242
+ setup(build) {
243
+ build.onEnd(async (result) => {
244
+ try {
245
+ let current = null
246
+ let manifestProblem = null
247
+ try {
248
+ current = await currentManifest()
249
+ } catch (e) {
250
+ manifestProblem = e.message
251
+ }
252
+ if (current && buildShape(current.parsed) !== ctxShape) {
253
+ // This context was built with options from a manifest that no
254
+ // longer describes the bundle: a newly declared library is still
255
+ // inlined (or still refused as undeclared), a raised `api` still
256
+ // lacks its timers. Whatever this build produced, errors included,
257
+ // is not the answer; a new context builds it right.
258
+ servedManifest = current.parsed
259
+ queueMicrotask(() => restartContext(current.parsed))
260
+ return
261
+ }
262
+ if (result.errors.length) {
263
+ fail(result.errors.map((e) => `${e.location ? `${e.location.file}:${e.location.line}: ` : ""}${e.text}`).join("\n"))
264
+ return
265
+ }
266
+ if (manifestProblem !== null) {
267
+ // The bundle that just built embeds the OLD manifest, so serving
268
+ // it would quietly disagree with the file on disk.
269
+ fail(manifestProblem)
270
+ return
271
+ }
272
+ // Recovering from a build error is a reload even when the bytes are
273
+ // the last good ones, which is what undoing a typo produces: the app
274
+ // clears the error only when a reload arrives.
275
+ const hadError = buildError !== null
276
+ buildError = null
277
+ const built = result.outputFiles[0].text
278
+ const unchanged = source === built && servedManifestText === current.text && !hadError
279
+ source = built
280
+ servedManifest = current.parsed
281
+ servedManifestText = current.text
282
+ if (!watching || unchanged) return
283
+ log(`${stamp()} ✓ ${key} — ${(result.outputFiles[0].contents.byteLength / 1024).toFixed(1)} KB`)
284
+ broadcast(clients, { type: "reload", key, ...indexEntryFor(key, servedManifest) })
285
+ } finally {
286
+ settleBuild?.()
287
+ }
288
+ })
289
+ },
290
+ }
291
+
292
+ async function startContext(manifest) {
293
+ const options = buildOptionsFor({
294
+ entry: join(dir, entry),
295
+ manifest,
296
+ dir,
297
+ inline,
298
+ absWorkingDir: dir,
299
+ // Errors must not abort the loop: an author saves broken code constantly,
300
+ // and the job is to report it and stay up.
301
+ logLevel: "silent",
302
+ plugins: [plugin],
303
+ })
304
+ ctxShape = buildShape(manifest)
305
+ const built = nextBuild()
306
+ ctx = await esbuild.context(options)
307
+ await ctx.watch()
308
+ await built
309
+ }
310
+
311
+ let restarting = Promise.resolve()
312
+ function restartContext(manifest) {
313
+ restarting = restarting.then(async () => {
314
+ const old = ctx
315
+ ctx = null
316
+ await old?.dispose()
317
+ await startContext(manifest)
318
+ })
319
+ return restarting
320
+ }
321
+
322
+ await startContext(startManifest)
323
+ await restarting
324
+ watching = true
325
+
326
+ // The manifest may change without changing any bundle input (an entry that
327
+ // does not import it), and an editor that saves by writing a temporary file
328
+ // and renaming it replaces the file's inode, which a watch on the file alone
329
+ // would lose. So the directory is watched, for that one name.
330
+ const manifestWatch = new AbortController()
331
+ ;(async () => {
332
+ try {
333
+ for await (const event of watch(dir, { signal: manifestWatch.signal })) {
334
+ if (event.filename !== MANIFEST_ENTRY) continue
335
+ await restarting
336
+ await ctx?.rebuild().catch(() => {})
337
+ }
338
+ } catch (e) {
339
+ if (e.name !== "AbortError") warn(`${stamp()} ! stopped watching ${MANIFEST_ENTRY}: ${e.message}`)
340
+ }
341
+ })()
342
+
343
+ const server = createServer(async (req, res) => {
344
+ const url = new URL(req.url, `http://${req.headers.host ?? "localhost"}`)
345
+ if (handlePreflight(req, res)) return
346
+
347
+ if (url.pathname === "/ping") return json(res, { server: SERVER_NAME, protocol: PROTOCOL, extensions: [key] })
348
+
349
+ if (url.pathname === "/index.json") return json(res, { extensions: [indexEntryFor(key, servedManifest)] })
350
+
351
+ if (url.pathname === "/events") {
352
+ res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" })
353
+ // The backlog: an extension whose first build failed is being served as
354
+ // [EMPTY_BUNDLE], and this is how the app learns why.
355
+ res.write(`data: ${JSON.stringify({ type: "hello", protocol: PROTOCOL, buildErrors: buildError === null ? {} : { [key]: buildError } })}\n\n`)
356
+ clients.add(res)
357
+ log(`${stamp()} ▸ app connected (${clients.size} connected)`)
358
+ req.on("close", () => {
359
+ clients.delete(res)
360
+ log(`${stamp()} ▪ app disconnected (${clients.size} connected)`)
361
+ })
362
+ return
363
+ }
364
+
365
+ if (url.pathname === "/logs" && req.method === "POST") {
366
+ const body = await readBody(req)
367
+ try {
368
+ for (const line of JSON.parse(body).lines ?? []) log(`${stamp()} │ ${line}`)
369
+ } catch {
370
+ warn(`${stamp()} ! unreadable log payload`)
371
+ }
372
+ res.writeHead(204).end()
373
+ return
374
+ }
375
+
376
+ if (url.pathname === `/${key}.js`) {
377
+ res.writeHead(200, { "content-type": "application/javascript; charset=utf-8" })
378
+ res.end(source ?? EMPTY_BUNDLE)
379
+ return
380
+ }
381
+
382
+ res.writeHead(404, { "content-type": "text/plain" })
383
+ res.end("not found")
384
+ })
385
+
386
+ await new Promise((resolveListen, rejectListen) => {
387
+ server.once("error", rejectListen)
388
+ server.listen(port, host, () => {
389
+ server.off("error", rejectListen)
390
+ resolveListen()
391
+ })
392
+ })
393
+ const listening = server.address().port
394
+
395
+ return {
396
+ key,
397
+ port: listening,
398
+ url: `http://localhost:${listening}`,
399
+ addresses: lanAddresses().map((address) => `http://${address}:${listening}`),
400
+ get buildError() {
401
+ return buildError
402
+ },
403
+ async close() {
404
+ manifestWatch.abort()
405
+ await restarting
406
+ await ctx?.dispose()
407
+ for (const client of clients) client.end()
408
+ clients.clear()
409
+ await new Promise((r) => server.close(() => r()))
410
+ },
411
+ }
412
+ }
413
+
414
+ // ---------------------------------------------------------------------------
415
+ // The protocol's shared parts: the repository's dev server imports these.
416
+
417
+ /// The time of day, for every line the loop prints.
418
+ export function stamp() {
419
+ return new Date().toTimeString().slice(0, 8)
420
+ }
421
+
422
+ /// One Server-Sent Event to every connected app; a client that cannot be
423
+ /// written to is dropped.
424
+ export function broadcast(clients, event) {
425
+ const payload = `data: ${JSON.stringify(event)}\n\n`
426
+ for (const client of clients) {
427
+ try {
428
+ client.write(payload)
429
+ } catch {
430
+ clients.delete(client)
431
+ }
432
+ }
433
+ }
434
+
435
+ /// Answers a CORS preflight and allows any origin on every response; returns
436
+ /// true when the request was the preflight and needs nothing more. Any
437
+ /// origin, because the app is not a browser page and this is a development
438
+ /// tool bound to a LAN by choice. The web build preflights `POST /logs`
439
+ /// (`content-type: application/json` is not CORS-safelisted): without this
440
+ /// answer that preflight falls through to a 404 and log forwarding dies
441
+ /// silently, since the app swallows a failed flush.
442
+ export function handlePreflight(req, res) {
443
+ res.setHeader("Access-Control-Allow-Origin", "*")
444
+ if (req.method !== "OPTIONS") return false
445
+ res.writeHead(204, {
446
+ "access-control-allow-methods": "GET, POST, OPTIONS",
447
+ "access-control-allow-headers": "content-type",
448
+ "access-control-max-age": "86400",
449
+ })
450
+ res.end()
451
+ return true
452
+ }
453
+
454
+ export function json(res, body) {
455
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" })
456
+ res.end(JSON.stringify(body))
457
+ }
458
+
459
+ /// Reads a request body, capped and error-terminated, so a client that aborts
460
+ /// mid-POST cannot leave the handler pending.
461
+ export function readBody(req, limit = 1 << 20) {
462
+ return new Promise((resolveBody) => {
463
+ let data = ""
464
+ req.on("data", (chunk) => {
465
+ data += chunk
466
+ if (data.length > limit) {
467
+ data = data.slice(0, limit)
468
+ req.destroy()
469
+ resolveBody(data)
470
+ }
471
+ })
472
+ req.on("end", () => resolveBody(data))
473
+ req.on("error", () => resolveBody(""))
474
+ req.on("aborted", () => resolveBody(""))
475
+ })
476
+ }
477
+
478
+ /// Every IPv4 address a phone on the LAN could reach, localhost last: right
479
+ /// for a desktop build on this machine, useless for the phone this exists for,
480
+ /// so not the first thing read off the screen.
481
+ export function lanAddresses() {
482
+ const found = []
483
+ for (const addresses of Object.values(networkInterfaces())) {
484
+ for (const address of addresses ?? []) {
485
+ if (address.family === "IPv4" && !address.internal) found.push(address.address)
486
+ }
487
+ }
488
+ return [...found, "localhost"]
489
+ }
490
+
491
+ /// The command line. Returns an exit code only for a usage problem or a
492
+ /// start-up failure; otherwise it serves until SIGINT or SIGTERM, then exits 0.
493
+ export async function main(argv) {
494
+ let options
495
+ try {
496
+ options = parseArgs(argv)
497
+ } catch (e) {
498
+ console.error(`h2kext-dev: ${e.message}\n\n${USAGE}`)
499
+ return 2
500
+ }
501
+ if (options.help) {
502
+ console.log(USAGE)
503
+ return 0
504
+ }
505
+ let dev
506
+ try {
507
+ const esbuild = await loadEsbuild(options.dir)
508
+ dev = await createDevServer({ ...options, esbuild })
509
+ } catch (e) {
510
+ console.error(`h2kext-dev: ${e.message}`)
511
+ return e instanceof DevServerError ? 2 : 1
512
+ }
513
+
514
+ if (dev.buildError !== null) console.error(`\n✗ ${dev.key}\n${dev.buildError}`)
515
+ console.log(`\nh2kext-dev — ${dev.key} ${dev.buildError === null ? "ready" : "is not building yet; fix the error above and save, and the app picks it up"}`)
516
+ console.log("\nPoint the app at (Settings → Developer → Extension Dev Server):")
517
+ for (const address of dev.addresses) console.log(` ${address}`)
518
+ console.log("\nWatching for changes. Install from a file before you publish; the packer's checks run there.\n")
519
+
520
+ await new Promise((resolveExit) => {
521
+ for (const signal of ["SIGINT", "SIGTERM"]) {
522
+ process.on(signal, async () => {
523
+ await dev.close()
524
+ resolveExit()
525
+ })
526
+ }
527
+ })
528
+ return 0
529
+ }
530
+
531
+ // Run only when invoked as the bin, never when imported by a test or by the
532
+ // repository's dev server. npm's bin shim is a symlink while `import.meta.url`
533
+ // resolves to the real file, so the argument is resolved before comparing.
534
+ if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
535
+ process.exit(await main(process.argv.slice(2)))
536
+ }
package/h2kext-init.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  // Copyright ©️ 2026 Sebastian Delmont <sd@ham2k.com>
3
3
  // SPDX-License-Identifier: MIT
4
4
  //
5
- // Scaffolds an extension project (HALO-452, docs/extensions/distribution.md).
5
+ // Scaffolds an extension project (HALO-452, docs/extensions/packaging.md).
6
6
  //
7
7
  // npx h2kext-init <yourcallsign>-<name>
8
8
  //
@@ -130,6 +130,7 @@ const projectPackage = {
130
130
  type: "module",
131
131
  scripts: {
132
132
  build: "node build.mjs",
133
+ dev: "h2kext-dev",
133
134
  pack: "h2kext-pack build",
134
135
  typecheck: "tsc --noEmit",
135
136
  format: "prettier --write .",
package/h2kext-pack.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  // SPDX-License-Identifier: MIT
4
4
  //
5
5
  // Packages an extension into a `.h2kext` a user can install
6
- // (HALO-422, docs/extensions/distribution.md).
6
+ // (HALO-422, docs/extensions/packaging.md).
7
7
  //
8
8
  // node tools/h2kext-pack.mjs <directory> [-o out.h2kext]
9
9
  //
package/h2kext.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  // SPDX-License-Identifier: MIT
3
3
  //
4
4
  // The `.h2kext` bundle format: what one contains, what makes one valid, and how
5
- // to write it (HALO-422, docs/extensions/distribution.md).
5
+ // to write it (HALO-422, docs/extensions/packaging.md).
6
6
  //
7
7
  // Deliberately standalone. It imports nothing from this repo — not the bundle
8
8
  // list, not the esbuild options, not the SDK — because its whole purpose is to
@@ -50,8 +50,12 @@ import yazl from "yazl"
50
50
  /// controls' `hoverSet`, `icon` layers, room for 512 layers and 256 controls,
51
51
  /// knobs' `invertDrag` and scrolls passing through buttons to the knob beneath,
52
52
  /// scenes that scroll natively (`scroll`, with `fixed` layers and controls,
53
- /// and `zoom` strips), `getSpots`' `display`, and `host.suggestQso`.
54
- export const H2KEXT_API_VERSION = 12
53
+ /// and `zoom` strips), `getSpots`' `display`, and `host.suggestQso`; 13 adds
54
+ /// the radio's dial to a scene's live `radio.` values — frequency, passband,
55
+ /// power, PTT, connection, and the words `radio.mode`, `radio.tuner` and
56
+ /// `radio.status` — `when` on layers and controls, and the `radio` panel
57
+ /// trigger.
58
+ export const H2KEXT_API_VERSION = 13
55
59
 
56
60
  /// The first API with `host.webSocket`. A manifest declaring `webSockets`
57
61
  /// under an older one is refused: an older app would install it and fail only
@@ -180,6 +184,7 @@ SHARED_MODULES_BY_API[9] = SHARED_MODULES_BY_API[8]
180
184
  SHARED_MODULES_BY_API[10] = SHARED_MODULES_BY_API[9]
181
185
  SHARED_MODULES_BY_API[11] = SHARED_MODULES_BY_API[10]
182
186
  SHARED_MODULES_BY_API[12] = SHARED_MODULES_BY_API[11]
187
+ SHARED_MODULES_BY_API[13] = SHARED_MODULES_BY_API[12]
183
188
 
184
189
  /// What a scaffolded extension declares on day one: the eight modules the SDK
185
190
  /// barrel itself reaches, so a project that has written no code yet still
@@ -478,7 +483,7 @@ export function validateManifest(manifest, { source = MANIFEST_ENTRY, forceName
478
483
  // discarded rather than refused. It is the one stale key certain to
479
484
  // arrive, because it is what every older manifest says.
480
485
  if (manifest.geo !== undefined) {
481
- say("'geo' is now 'relevance' — rename it (docs/extensions/distribution.md)")
486
+ say("'geo' is now 'relevance' — rename it (docs/extensions/manifest.md)")
482
487
  }
483
488
 
484
489
  // `enabledByDefault` is the shipping app's answer for its OWN extensions,
@@ -625,3 +630,53 @@ export async function pack(dir, { outPath, forceName = false } = {}) {
625
630
  await writeBundle(files, target)
626
631
  return { manifest, outPath: target, files }
627
632
  }
633
+
634
+ /// The entry the app reads for one extension in a bundle index: the
635
+ /// `index.json` the app's own build writes, and what a dev server (the
636
+ /// repository's, or `h2kext-dev` for a project outside it) answers at
637
+ /// `/index.json` and carries on a `reload` event. The app reads it without
638
+ /// evaluating the bundle, so everything it decides before loading one is here:
639
+ /// the grants it shows at install, where the settings panel goes, whether the
640
+ /// extension is core (no category) and how it is listed when it is not.
641
+ export function indexEntryFor(key, m) {
642
+ return {
643
+ key,
644
+ file: `${key}.js`,
645
+ version: m.version,
646
+ // A built-in declares none and is at the app's own level; a dev-served
647
+ // catalog extension declares the level it was built for.
648
+ ...(m.api ? { api: m.api } : {}),
649
+ domains: m.domains ?? [],
650
+ ...(m.webSockets?.length ? { webSockets: m.webSockets } : {}),
651
+ ...(m.allowUserAgentOverride ? { allowUserAgentOverride: true } : {}),
652
+ ...(m.requiresLocation ? { requiresLocation: true } : {}),
653
+ ...(m.requiresRadioRead ? { requiresRadioRead: true } : {}),
654
+ ...(m.requiresRadioWrite ? { requiresRadioWrite: true } : {}),
655
+ ...(m.requiresRadioRaw ? { requiresRadioRaw: true } : {}),
656
+ ...(m.requiresClusterRead ? { requiresClusterRead: true } : {}),
657
+ ...(m.requiresClusterSpot ? { requiresClusterSpot: true } : {}),
658
+ ...(m.requiresClusterCommand ? { requiresClusterCommand: true } : {}),
659
+ ...(m.requiresSettingsWrite ? { requiresSettingsWrite: true } : {}),
660
+ ...(m.experiments?.length ? { experiments: m.experiments } : {}),
661
+ ...(m.relevance ? { relevance: m.relevance } : {}),
662
+ // Read by the host to place the extension's settings panels; without it
663
+ // a dev-served extension's panel would be placed as though it declared
664
+ // nothing, which is not how it will install.
665
+ ...(m.settings ? { settings: m.settings } : {}),
666
+ core: !m.category,
667
+ ...(m.category
668
+ ? {
669
+ name: m.name ?? key,
670
+ ...(m.shortName ? { shortName: m.shortName } : {}),
671
+ category: m.category,
672
+ enabledByDefault: m.enabledByDefault ?? true,
673
+ ...(m.icon ? { icon: m.icon } : {}),
674
+ ...(m.description ? { description: m.description } : {}),
675
+ ...(m.accentColor ? { accentColor: m.accentColor } : {}),
676
+ ...(m.keywords ? { keywords: m.keywords } : {}),
677
+ ...(m.hooks ? { hooks: m.hooks } : {}),
678
+ ...(m.translations ? { translations: m.translations } : {}),
679
+ }
680
+ : {}),
681
+ }
682
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ham2k/extension-tools",
3
- "version": "1.12.1",
3
+ "version": "1.13.1",
4
4
  "description": "Build and package extensions for the Ham2K Logger",
5
5
  "keywords": [
6
6
  "ham2k",
@@ -25,12 +25,14 @@
25
25
  },
26
26
  "bin": {
27
27
  "h2kext-init": "./h2kext-init.mjs",
28
+ "h2kext-dev": "./h2kext-dev.mjs",
28
29
  "h2kext-pack": "./h2kext-pack.mjs",
29
30
  "h2kext-publish": "./h2kext-publish.mjs"
30
31
  },
31
32
  "files": [
32
33
  "h2kext.mjs",
33
34
  "h2kext-build.mjs",
35
+ "h2kext-dev.mjs",
34
36
  "h2kext-init.mjs",
35
37
  "h2kext-pack.mjs",
36
38
  "h2kext-publish.mjs",