@ham2k/extension-tools 0.1.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 ADDED
@@ -0,0 +1,49 @@
1
+ # @ham2k/extension-tools
2
+
3
+ Build and package extensions for the [Ham2K Logger](https://ham2k.com).
4
+
5
+ ```sh
6
+ npm install --save-dev @ham2k/extension-tools esbuild
7
+ ```
8
+
9
+ ## Building
10
+
11
+ ```js
12
+ import { build } from 'esbuild'
13
+ import { buildExtension } from '@ham2k/extension-tools'
14
+
15
+ await buildExtension(build, { dir: import.meta.dirname })
16
+ ```
17
+
18
+ Reads your `manifest.json`, compiles `src/index.ts` into `build/index.js`, and
19
+ copies the manifest alongside it. The esbuild settings it applies are
20
+ requirements of the runtime rather than preferences: one IIFE because the
21
+ sandbox evaluates a script and has no module loader, `neutral` because there is
22
+ no Node and no DOM, and `es2020` because that is what its engine speaks.
23
+
24
+ esbuild is yours to bring — the preset takes your `build` function rather than
25
+ importing its own, so there is only ever one copy of it.
26
+
27
+ Everything your manifest's `sharedDependencies` names becomes a lookup on the
28
+ library the host already holds, which is worth roughly thirty times the bundle
29
+ size. Using one **without** declaring it fails the build rather than quietly
30
+ shipping a private copy; pass `inline: ['liquidjs']` if that copy is what you
31
+ want, which is how you use a version the host does not carry.
32
+
33
+ ## Packaging
34
+
35
+ ```sh
36
+ npx h2kext-pack build -o my-extension.h2kext
37
+ ```
38
+
39
+ Packages a directory holding a built `index.js`, a `manifest.json` and an
40
+ optional `assets/`. It packages; it does not compile.
41
+
42
+ It reports every problem at once, and refuses what would install cleanly and
43
+ then misbehave where you cannot see it. Extension keys are `<callsign>-<name>`
44
+ — the namespace every author of this software already holds, so two people who
45
+ never met cannot ship the same key.
46
+
47
+ ## License
48
+
49
+ MPL-2.0
@@ -0,0 +1,208 @@
1
+ // Copyright ©️ 2026 Sebastian Delmont <sd@ham2k.com>
2
+ // SPDX-License-Identifier: MPL-2.0
3
+ //
4
+ // Building an extension: the esbuild settings a Ham2K bundle needs, and the
5
+ // plugin that takes the host up on the libraries it carries (HALO-442,
6
+ // docs/extensions/development.md).
7
+ //
8
+ // Standalone in the same way h2kext.mjs is, and for the same reason — that
9
+ // module is its only import, because its whole purpose is to work for someone
10
+ // with none of this repo. esbuild it does NOT import: the caller passes their
11
+ // own `build` in, so an author's toolchain and this preset cannot end up
12
+ // running two copies of it.
13
+ //
14
+ // Ships beside the packer, in `@ham2k/extension-tools`, because both read the
15
+ // FORMAT — the API version, the manifest's entry names, the list of libraries
16
+ // the host carries. Neither depends on the SDK an extension imports, so the
17
+ // runtime package stays free of any build-time surface.
18
+ //
19
+ // The repo's own build uses it too, on the same terms, which is what keeps the
20
+ // path a third party takes from being the untested one.
21
+
22
+ import { copyFile, mkdir, readFile } from 'node:fs/promises'
23
+ import { createRequire } from 'node:module'
24
+ import { dirname, join, resolve } from 'node:path'
25
+
26
+ import { H2KEXT_API_VERSION, MANIFEST_ENTRY, sharedModulesFor } from './h2kext.mjs'
27
+
28
+ export class BuildError extends Error {}
29
+
30
+ /// Rewrites imports of the libraries the host carries into lookups on the
31
+ /// kernel's single instance, so a bundle need not ship its own copy.
32
+ ///
33
+ /// Only what the manifest DECLARES is rewritten. Declaring is what lets the
34
+ /// host check, before it loads anything, that the version it holds is one the
35
+ /// bundle can work with — an undeclared lookup is a bundle that loads happily
36
+ /// and then dies at the first call into a library that turned out to be a
37
+ /// different major, in someone else's app, far from the cause.
38
+ ///
39
+ /// So using an offered library without declaring it FAILS the build rather
40
+ /// than quietly inlining a private copy. Inlining is a legitimate choice — it
41
+ /// is how you use a version the host does not carry — and [inline] is how to
42
+ /// make it, but it is a choice worth stating, because the silent version costs
43
+ /// roughly thirty times the bundle size and nothing anywhere says so.
44
+ export function sharedModulesPlugin({ sharedDependencies = {}, api = H2KEXT_API_VERSION, inline = [] } = {}) {
45
+ const offered = sharedModulesFor(api)
46
+ const declared = Object.keys(sharedDependencies)
47
+
48
+ // Refused rather than ignored. Externalizing a name the host has no instance
49
+ // of emits `sharedModules["lodash"]`, which is `undefined` at the first call
50
+ // — a build that succeeded and a bundle that cannot work.
51
+ const notOffered = declared.filter((pkg) => !offered.includes(pkg))
52
+ if (notOffered.length) {
53
+ throw new BuildError(
54
+ `${MANIFEST_ENTRY} declares sharedDependencies the host does not carry (${notOffered.join(', ')}). ` +
55
+ 'Bundle those into your build instead — only what the host offers can be shared.')
56
+ }
57
+
58
+ // The two say opposite things about the same package, so neither can be
59
+ // honoured silently: whichever won, the other statement would be a lie.
60
+ const both = declared.filter((pkg) => inline.includes(pkg))
61
+ if (both.length) {
62
+ throw new BuildError(
63
+ `${both.join(', ')} appears in both sharedDependencies and inline. ` +
64
+ "Declare it to use the host's copy, or inline it to ship your own — not both.")
65
+ }
66
+
67
+ const undeclared = offered.filter((pkg) => !declared.includes(pkg) && !inline.includes(pkg))
68
+
69
+ return {
70
+ name: 'ham2k-shared-modules',
71
+ setup(build) {
72
+ if (declared.length) {
73
+ build.onResolve({ filter: matcherFor(declared) }, (args) => ({
74
+ path: args.path,
75
+ namespace: 'ham2k-shared-module',
76
+ }))
77
+ }
78
+
79
+ // A separate resolver rather than one branch inside the first: esbuild
80
+ // consults filters in order, and a plugin that resolved a package only
81
+ // to return null would hand it back to the very resolution this exists
82
+ // to intercept.
83
+ if (undeclared.length) {
84
+ build.onResolve({ filter: matcherFor(undeclared) }, (args) => ({
85
+ // The fix goes in `text`, not in a `notes` entry: a note reaches
86
+ // esbuild's pretty printer and nothing else, so a build script that
87
+ // catches the error — or runs at logLevel 'silent' — shows the
88
+ // author the complaint with the answer stripped off.
89
+ errors: [{
90
+ text: `'${args.path}' is carried by the Ham2K extension host, but this bundle does not declare it. ` +
91
+ `Add it to "sharedDependencies" in ${MANIFEST_ENTRY} with the version range you work with ` +
92
+ `(e.g. {${JSON.stringify(args.path)}: "^1.0.0"}), or pass inline: [${JSON.stringify(args.path)}] ` +
93
+ 'to ship your own copy — which is how you use a version the host does not carry.',
94
+ }],
95
+ }))
96
+ }
97
+
98
+ build.onLoad({ filter: /.*/, namespace: 'ham2k-shared-module' }, (args) => ({
99
+ contents: `module.exports = globalThis.__polo.sharedModules[${JSON.stringify(args.path)}];`,
100
+ loader: 'js',
101
+ }))
102
+ },
103
+ }
104
+ }
105
+
106
+ /// An esbuild filter matching exactly [packages] and nothing else. Package
107
+ /// names carry regex metacharacters (`@ham2k/lib-qson-adif` has a slash, and a
108
+ /// dot would be a wildcard), so every one is escaped.
109
+ function matcherFor(packages) {
110
+ const alternatives = packages.map((pkg) => pkg.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')).join('|')
111
+ return new RegExp(`^(${alternatives})$`)
112
+ }
113
+
114
+ /// Where liquidjs's BROWSER build lives, or null if liquidjs is not installed.
115
+ ///
116
+ /// Every author who inlines liquidjs hits this: its `main`/`module` are the
117
+ /// NODE builds, which import fs, path and stream, and `platform: 'neutral'`
118
+ /// ignores the `browser` field that would have picked the right one. Without
119
+ /// the alias the build fails on unresolvable node built-ins before tree-shaking
120
+ /// gets a chance to drop the half that wanted them (HALO-443).
121
+ ///
122
+ /// Resolved from the CALLER's directory, not this file's — the point is to find
123
+ /// the author's copy.
124
+ function liquidBrowserBuild(fromDir) {
125
+ try {
126
+ // `resolve` because createRequire rejects a relative path outright, and
127
+ // `dir` is the caller's to pass — a relative one lands in the catch below
128
+ // and loses the alias, which is precisely the failure this function exists
129
+ // to prevent, arriving with nothing to point at the cause.
130
+ const require = createRequire(resolve(fromDir, 'noop.js'))
131
+ return join(dirname(require.resolve('liquidjs/package.json')), 'dist/liquid.browser.mjs')
132
+ } catch (e) {
133
+ // Not installed is the normal case for an extension that neither inlines
134
+ // liquidjs nor uses it, and there is nothing to alias. Anything else is a
135
+ // real fault, and swallowing it would hand the author five unresolvable
136
+ // node built-ins instead.
137
+ if (e.code === 'MODULE_NOT_FOUND') return null
138
+ throw e
139
+ }
140
+ }
141
+
142
+ /// The esbuild options a Ham2K extension bundle is built with.
143
+ ///
144
+ /// Everything here is a requirement of the runtime rather than a preference:
145
+ /// one IIFE because the sandbox evaluates a script and has no module loader;
146
+ /// `platform: 'neutral'` because there is no node and no DOM; es2020 because
147
+ /// that is what QuickJS-NG speaks.
148
+ export function buildOptionsFor({ entry, outfile, manifest = {}, dir = process.cwd(), alias = {}, inline = [], ...rest } = {}) {
149
+ const liquid = liquidBrowserBuild(dir)
150
+ return {
151
+ entryPoints: [entry],
152
+ ...(outfile ? { outfile } : { write: false }),
153
+ bundle: true,
154
+ format: 'iife',
155
+ platform: 'neutral',
156
+ mainFields: ['module', 'main'],
157
+ target: 'es2020',
158
+ logLevel: 'warning',
159
+ ...rest,
160
+ alias: {
161
+ ...(liquid ? { liquidjs: liquid } : {}),
162
+ ...alias,
163
+ },
164
+ plugins: [
165
+ sharedModulesPlugin({ sharedDependencies: manifest.sharedDependencies, api: manifest.api, inline }),
166
+ ...(rest.plugins ?? []),
167
+ ],
168
+ }
169
+ }
170
+
171
+ /// Builds one extension directory into something `h2kext-pack` can package:
172
+ /// a `manifest.json` and an `index.js`, side by side.
173
+ ///
174
+ /// import { build } from 'esbuild'
175
+ /// import { buildExtension } from '@ham2k/extension-tools'
176
+ /// await buildExtension(build, { dir: import.meta.dirname })
177
+ ///
178
+ /// [esbuild] is the caller's `build` function, passed in rather than imported
179
+ /// so this module needs no copy of its own.
180
+ export async function buildExtension(esbuild, { dir, entry = 'src/index.ts', outDir = 'build', ...rest } = {}) {
181
+ if (!dir) throw new BuildError('buildExtension needs `dir`: the extension directory holding manifest.json')
182
+
183
+ let manifest
184
+ try {
185
+ manifest = JSON.parse(await readFile(join(dir, MANIFEST_ENTRY), 'utf8'))
186
+ } catch (e) {
187
+ throw new BuildError(`could not read ${join(dir, MANIFEST_ENTRY)}: ${e.message}`)
188
+ }
189
+
190
+ const out = join(dir, outDir)
191
+ await mkdir(out, { recursive: true })
192
+
193
+ await esbuild(buildOptionsFor({
194
+ entry: join(dir, entry),
195
+ outfile: join(out, 'index.js'),
196
+ manifest,
197
+ dir,
198
+ ...rest,
199
+ }))
200
+
201
+ // The packer reads both from one directory, so the manifest the bundle was
202
+ // built against is the manifest that ships with it. Copied rather than left
203
+ // for the author to remember: a stale manifest beside a fresh bundle is a
204
+ // declared dependency list that no longer describes the code.
205
+ await copyFile(join(dir, MANIFEST_ENTRY), join(out, MANIFEST_ENTRY))
206
+
207
+ return { outDir: out, manifest }
208
+ }
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env node
2
+ // Copyright ©️ 2026 Sebastian Delmont <sd@ham2k.com>
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ //
5
+ // Packages an extension into a `.h2kext` a user can install
6
+ // (HALO-422, docs/extensions/distribution.md).
7
+ //
8
+ // node tools/h2kext-pack.mjs <directory> [-o out.h2kext]
9
+ //
10
+ // The directory holds a built `index.js`, a `manifest.json`, and optionally an
11
+ // `assets/` folder. Nothing here compiles anything — see h2kext.mjs.
12
+
13
+ import { pack, PackError, H2KEXT_API_VERSION, RESERVED_PREFIX } from './h2kext.mjs'
14
+
15
+ const args = process.argv.slice(2)
16
+
17
+ if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
18
+ console.log(`Package an extension directory into a .h2kext bundle.
19
+
20
+ h2kext-pack <directory> [-o <out.h2kext>] [--force-name]
21
+
22
+ The directory must contain:
23
+ manifest.json key, name, version, description, category, api (${H2KEXT_API_VERSION})
24
+ index.js your extension, built
25
+ assets/ optional
26
+
27
+ The key is '<yourcallsign>-<name>' — e.g. ki2d-my-clock. The callsign is a
28
+ namespace you already have, globally unique, so two authors who never met
29
+ cannot collide.
30
+
31
+ --force-name waive the naming convention (Ham2K's own builds, which
32
+ ship bare keys and the reserved '${RESERVED_PREFIX}-' prefix)
33
+
34
+ Defaults to <key>-<version>.h2kext in the working directory.`)
35
+ process.exit(args.length === 0 ? 1 : 0)
36
+ }
37
+
38
+ // Walked by index rather than matched by value: `args.indexOf(a)` finds the
39
+ // FIRST occurrence, so `-o foo foo` mistook the output path for the directory.
40
+ const forceName = args.includes('--force-name')
41
+ let dir
42
+ let outPath
43
+ for (let i = 0; i < args.length; i++) {
44
+ if (args[i] === '-o') {
45
+ outPath = args[++i]
46
+ } else if (!args[i].startsWith('-') && dir === undefined) {
47
+ dir = args[i]
48
+ }
49
+ }
50
+
51
+ if (!dir) {
52
+ console.error('h2kext-pack: name the directory to package')
53
+ process.exit(1)
54
+ }
55
+
56
+ try {
57
+ const result = await pack(dir, { outPath, forceName })
58
+ const assets = result.files.length - 2
59
+ console.log(`${result.outPath}`)
60
+ console.log(` ${result.manifest.key} ${result.manifest.version} — ${result.manifest.name}`)
61
+ console.log(` ${assets} asset${assets === 1 ? '' : 's'}`)
62
+ if (result.manifest.domains?.length) {
63
+ // Named on the way out, because this is the one part of the manifest the
64
+ // person installing it will be asked to approve.
65
+ console.log(` may reach: ${result.manifest.domains.join(', ')}`)
66
+ }
67
+ } catch (e) {
68
+ if (e instanceof PackError) {
69
+ console.error(`h2kext-pack: ${dir} is not ready to package:\n${e.message}`)
70
+ process.exit(1)
71
+ }
72
+ throw e
73
+ }
package/h2kext.mjs ADDED
@@ -0,0 +1,311 @@
1
+ // Copyright ©️ 2026 Sebastian Delmont <sd@ham2k.com>
2
+ // SPDX-License-Identifier: MPL-2.0
3
+ //
4
+ // The `.h2kext` bundle format: what one contains, what makes one valid, and how
5
+ // to write it (HALO-422, docs/extensions/distribution.md).
6
+ //
7
+ // Deliberately standalone. It imports nothing from this repo — not the bundle
8
+ // list, not the esbuild options, not the SDK — because its whole purpose is to
9
+ // work for someone who has none of that. Its only dependency is a zip writer.
10
+ // Lifting this file and its CLI into a published npm package should be a
11
+ // packaging change, not a rewrite.
12
+ //
13
+ // It PACKAGES; it does not compile. The input is a directory that already
14
+ // holds a built `index.js` and a `manifest.json`. How that JS got built is the
15
+ // author's business — which is exactly why the checks below matter, since the
16
+ // one way it can be built WRONG is invisible until the extension is running
17
+ // inside somebody's app.
18
+
19
+ import { createWriteStream } from 'node:fs'
20
+ import { readFile, readdir, stat } from 'node:fs/promises'
21
+ import { join, posix, relative, sep } from 'node:path'
22
+ import { parseCallsign } from '@ham2k/lib-callsigns'
23
+ import yazl from 'yazl'
24
+
25
+ /// The extension API this format describes. A bundle declares the version it
26
+ /// was built against and the host refuses one it does not speak, so an old app
27
+ /// meeting a newer bundle says so instead of failing at a call site.
28
+ export const H2KEXT_API_VERSION = 1
29
+
30
+ /// `<prefix>-<name>`, all lowercase. The shape is what the kernel's
31
+ /// `bundleMayDefine` prefix rule can reason about; the PREFIX is what keeps two
32
+ /// authors who never met from choosing the same key.
33
+ export const KEY_PATTERN = /^[a-z0-9]+-[a-z0-9-]*[a-z0-9]$/
34
+
35
+ /// The prefix reserved for Ham2K's own extensions. Refused to a distributed
36
+ /// bundle unless it is one of ours: there is no signing yet, so anyone could
37
+ /// claim it, and a key is what the app shows when it says where a hook came
38
+ /// from.
39
+ export const RESERVED_PREFIX = 'ham2k'
40
+
41
+ /// Whether [prefix] is a real amateur callsign — the namespace every author of
42
+ /// this software already has, globally unique, and free.
43
+ export function isCallsignPrefix(prefix) {
44
+ const parsed = parseCallsign(prefix.toUpperCase())
45
+ return !!parsed.baseCall && parsed.baseCall === prefix.toUpperCase()
46
+ }
47
+
48
+ /// The shape a key must have whatever else is waived: lowercase, letters
49
+ /// digits and hyphens. Not a naming convention — the key namespaces KV
50
+ /// storage, scopes secrets, and identifies hooks, so anything outside this
51
+ /// breaks the runtime rather than merely looking wrong.
52
+ ///
53
+ /// A leading DIGIT is legitimate and must stay allowed: plenty of callsigns
54
+ /// begin with one (2E0ABC, 9A1AA, 4X4XYZ), and the convention below builds a
55
+ /// key out of exactly that.
56
+ export const SAFE_KEY_PATTERN = /^[a-z0-9][a-z0-9-]*$/
57
+
58
+ /// Why [key] is not a usable extension key, or null if it is.
59
+ ///
60
+ /// [forceName] waives the naming CONVENTION — the callsign prefix and the
61
+ /// reserved `ham2k-` — for Ham2K's own packaging, which builds the extensions
62
+ /// that ship inside the app under bare keys (`pota`, `sota`) and the reserved
63
+ /// prefix. It does not waive [SAFE_KEY_PATTERN], which is not a convention.
64
+ ///
65
+ /// A guardrail, not a gate: a standalone tool cannot enforce who runs it. The
66
+ /// app is where the rule bites, refusing a reserved or colliding key at
67
+ /// install.
68
+ export function keyProblem(key, { forceName = false } = {}) {
69
+ if (typeof key !== 'string' || !SAFE_KEY_PATTERN.test(key)) {
70
+ return `key '${key}' must be lowercase, and contain only letters, digits and hyphens`
71
+ }
72
+ if (forceName) return null
73
+ if (!KEY_PATTERN.test(key)) {
74
+ return `key '${key}' must be '<callsign>-<name>' (e.g. ki2d-my-clock)`
75
+ }
76
+ const prefix = key.slice(0, key.indexOf('-'))
77
+ if (prefix === RESERVED_PREFIX) {
78
+ return `key '${key}' uses the '${RESERVED_PREFIX}-' prefix, which is reserved for Ham2K's own extensions`
79
+ }
80
+ if (!isCallsignPrefix(prefix)) {
81
+ return `key '${key}' must start with your callsign — '${prefix}' is not one (e.g. ki2d-my-clock)`
82
+ }
83
+ return null
84
+ }
85
+
86
+ /// The only entries a bundle may contain, beyond `assets/`.
87
+ export const MANIFEST_ENTRY = 'manifest.json'
88
+ export const BUNDLE_ENTRY = 'index.js'
89
+ export const ASSETS_PREFIX = 'assets/'
90
+
91
+ /// The libraries the extension host carries, per API version, so a bundle does
92
+ /// not have to ship its own copy of each. The kernel holds one instance and
93
+ /// hands it to every extension through `__polo.sharedModules`.
94
+ ///
95
+ /// NAMES only. The VERSION a given app carries is that build's business, which
96
+ /// is the whole reason a bundle declares a RANGE in `sharedDependencies` and
97
+ /// the host checks it at load: a list of names is stable across releases,
98
+ /// a list of versions would be a lie the moment either side shipped.
99
+ ///
100
+ /// Deliberately duplicated from build-config.mjs's `sharedPackages` rather than
101
+ /// imported — this module has to work with none of this repo present. A test
102
+ /// in the repo asserts the two agree, which is the only place that check can
103
+ /// live once this file is published on its own.
104
+ export const SHARED_MODULES_BY_API = {
105
+ 1: [
106
+ '@ham2k/lib-callsigns',
107
+ '@ham2k/lib-country-files',
108
+ '@ham2k/lib-cqmag-data',
109
+ '@ham2k/lib-dxcc-data',
110
+ '@ham2k/lib-format-tools',
111
+ '@ham2k/lib-geo-tools',
112
+ '@ham2k/lib-operation-data',
113
+ '@ham2k/lib-qson-adif',
114
+ '@ham2k/lib-qson-cabrillo',
115
+ '@ham2k/lib-qson-tools',
116
+ 'i18next',
117
+ 'liquidjs',
118
+ ],
119
+ }
120
+
121
+ /// What the host offers a bundle declaring [api].
122
+ export function sharedModulesFor(api) {
123
+ return SHARED_MODULES_BY_API[api] ?? SHARED_MODULES_BY_API[H2KEXT_API_VERSION]
124
+ }
125
+
126
+ export class PackError extends Error {}
127
+
128
+ /// Everything wrong with a candidate bundle, as a list — an author fixing one
129
+ /// problem per run is an author running this five times.
130
+ export function validateManifest(manifest, { source = MANIFEST_ENTRY, forceName = false } = {}) {
131
+ const problems = []
132
+ const say = (m) => problems.push(`${source}: ${m}`)
133
+
134
+ if (typeof manifest !== 'object' || manifest === null || Array.isArray(manifest)) {
135
+ return [`${source}: must be a JSON object`]
136
+ }
137
+
138
+ for (const field of ['key', 'name', 'version', 'description']) {
139
+ if (typeof manifest[field] !== 'string' || manifest[field].trim() === '') {
140
+ say(`missing required field '${field}'`)
141
+ }
142
+ }
143
+
144
+ if (manifest.key !== undefined) {
145
+ const problem = keyProblem(manifest.key, { forceName })
146
+ if (problem) say(problem)
147
+ }
148
+
149
+ if (manifest.api === undefined) {
150
+ say(`missing 'api' — declare the extension API this was built against (currently ${H2KEXT_API_VERSION})`)
151
+ } else if (!Number.isInteger(manifest.api)) {
152
+ say(`'api' must be an integer, not ${JSON.stringify(manifest.api)}`)
153
+ } else if (manifest.api > H2KEXT_API_VERSION) {
154
+ say(`'api' is ${manifest.api}, but this packer only knows ${H2KEXT_API_VERSION}`)
155
+ }
156
+
157
+ // An extension with no category is CORE to the host: always enabled, and
158
+ // never listed in the Extensions panel. That is a status the app grants its
159
+ // own extensions, not one a distributed bundle may claim for itself — a user
160
+ // could neither see it nor turn it off.
161
+ if (typeof manifest.category !== 'string' || manifest.category.trim() === '') {
162
+ say("missing 'category' — a bundle with none is treated as core: always on, and invisible in the Extensions panel")
163
+ }
164
+
165
+ if (manifest.sharedDependencies !== undefined) {
166
+ const declared = manifest.sharedDependencies
167
+ if (typeof declared !== 'object' || declared === null || Array.isArray(declared)) {
168
+ say("'sharedDependencies' must be an object of package name to version range")
169
+ } else {
170
+ const offered = sharedModulesFor(manifest.api)
171
+ for (const [pkg, range] of Object.entries(declared)) {
172
+ if (!offered.includes(pkg)) {
173
+ say(`'sharedDependencies' names '${pkg}', which the host does not carry — bundle it into your build instead`)
174
+ } else if (typeof range !== 'string' || range.trim() === '') {
175
+ say(`'sharedDependencies' entry '${pkg}' needs a version range, e.g. "^1.0.0"`)
176
+ }
177
+ }
178
+ }
179
+ }
180
+
181
+ // `experiments` gates availability against the APP's experiment catalog. A
182
+ // key the host does not define hides the extension forever, with nothing in
183
+ // the UI to explain it, so a distributed bundle must not carry one.
184
+ if (manifest.experiments !== undefined) {
185
+ say("'experiments' gates on the app's own experiment catalog and would hide this extension permanently — remove it")
186
+ }
187
+
188
+ return problems
189
+ }
190
+
191
+ /// Every library a bundle takes from the host rather than carrying its own
192
+ /// copy — the `globalThis.__polo.sharedModules[...]` lookups its build emitted.
193
+ ///
194
+ /// Taking them is the supported path and worth roughly thirty times the bundle
195
+ /// size. What the caller checks is that each one was DECLARED: the host carries
196
+ /// whatever version its build shipped with and makes no promise, so an
197
+ /// undeclared lookup is a bundle the app cannot version-check before loading.
198
+ /// It loads happily and then dies at the first call into a library that turned
199
+ /// out to be absent or a different major — far from the cause, and in someone
200
+ /// else's app.
201
+ export function findSharedModuleReferences(source) {
202
+ const hits = []
203
+ const pattern = /__polo\s*\.\s*sharedModules\s*\[\s*["']([^"']+)["']\s*\]/g
204
+ for (const match of source.matchAll(pattern)) hits.push(match[1])
205
+ return [...new Set(hits)]
206
+ }
207
+
208
+ /// Reads a bundle directory and returns `{manifest, files}` ready to write, or
209
+ /// throws a [PackError] naming everything wrong with it.
210
+ export async function readBundleDirectory(dir, { forceName = false } = {}) {
211
+ let manifestRaw
212
+ try {
213
+ manifestRaw = await readFile(join(dir, MANIFEST_ENTRY), 'utf8')
214
+ } catch {
215
+ throw new PackError(`no ${MANIFEST_ENTRY} in ${dir}`)
216
+ }
217
+
218
+ let manifest
219
+ try {
220
+ manifest = JSON.parse(manifestRaw)
221
+ } catch (e) {
222
+ throw new PackError(`${MANIFEST_ENTRY} is not valid JSON: ${e.message}`)
223
+ }
224
+
225
+ const problems = validateManifest(manifest, { forceName })
226
+
227
+ let bundleSource
228
+ try {
229
+ bundleSource = await readFile(join(dir, BUNDLE_ENTRY), 'utf8')
230
+ } catch {
231
+ throw new PackError(`no ${BUNDLE_ENTRY} in ${dir} — build your extension first; this packages, it does not compile`)
232
+ }
233
+ if (bundleSource.trim() === '') problems.push(`${BUNDLE_ENTRY}: is empty`)
234
+
235
+ // Using the host's shared libraries is allowed, and is much the better
236
+ // option — inlining them costs about ten times the bundle size. What is NOT
237
+ // allowed is using one silently: the host carries whatever version that
238
+ // build shipped with, so a bundle has to say what it can work with, and the
239
+ // host checks it before loading. Undeclared, it loads happily and then dies
240
+ // at the first call into a library that turned out to be a different major.
241
+ const declaredShared = manifest?.sharedDependencies ?? {}
242
+ const undeclared = findSharedModuleReferences(bundleSource).filter((pkg) => !(pkg in declaredShared))
243
+ if (undeclared.length) {
244
+ problems.push(
245
+ `${BUNDLE_ENTRY}: uses the host's shared modules without declaring them (${undeclared.join(', ')}). ` +
246
+ `Add them to 'sharedDependencies' in ${MANIFEST_ENTRY} with the version range you work with, ` +
247
+ 'e.g. {"' + undeclared[0] + '": "^1.0.0"}, so the host can refuse to load this against a version you never tested.')
248
+ }
249
+
250
+ if (problems.length) throw new PackError(problems.join('\n'))
251
+
252
+ const files = [
253
+ { entry: MANIFEST_ENTRY, path: join(dir, MANIFEST_ENTRY) },
254
+ { entry: BUNDLE_ENTRY, path: join(dir, BUNDLE_ENTRY) },
255
+ ...(await collectAssets(dir)),
256
+ ]
257
+ return { manifest, files }
258
+ }
259
+
260
+ /// Everything under `assets/`, as forward-slashed entry names. Zip entry names
261
+ /// are `/`-separated by specification, so a Windows author must not produce a
262
+ /// bundle whose paths only their own machine can read.
263
+ async function collectAssets(dir) {
264
+ const root = join(dir, ASSETS_PREFIX.slice(0, -1))
265
+ try {
266
+ if (!(await stat(root)).isDirectory()) return []
267
+ } catch (e) {
268
+ // No assets/ is ordinary. Anything else — unreadable, an I/O error, a
269
+ // broken symlink — is not, and swallowing it ships a bundle silently
270
+ // missing files the author believes are in it.
271
+ if (e.code === 'ENOENT') return []
272
+ throw new PackError(`could not read ${root}: ${e.message}`)
273
+ }
274
+
275
+ const found = []
276
+ const walk = async (current) => {
277
+ for (const item of await readdir(current, { withFileTypes: true })) {
278
+ const full = join(current, item.name)
279
+ if (item.isDirectory()) await walk(full)
280
+ else if (item.isFile()) {
281
+ found.push({ entry: ASSETS_PREFIX + relative(root, full).split(sep).join(posix.sep), path: full })
282
+ }
283
+ }
284
+ }
285
+ await walk(root)
286
+ return found.sort((a, b) => a.entry.localeCompare(b.entry))
287
+ }
288
+
289
+ /// Writes the `.h2kext` at [outPath]. Resolves once the file is closed —
290
+ /// yazl's `end()` only starts the write, and a caller that reported success
291
+ /// before then would be describing a file that is still being produced.
292
+ export function writeBundle(files, outPath) {
293
+ return new Promise((resolve, reject) => {
294
+ const zip = new yazl.ZipFile()
295
+ for (const file of files) zip.addFile(file.path, file.entry)
296
+ const out = createWriteStream(outPath)
297
+ zip.outputStream.pipe(out)
298
+ out.on('close', () => resolve(outPath))
299
+ out.on('error', reject)
300
+ zip.outputStream.on('error', reject)
301
+ zip.end()
302
+ })
303
+ }
304
+
305
+ /// Validate a directory and write its `.h2kext`. Returns `{manifest, outPath}`.
306
+ export async function pack(dir, { outPath, forceName = false } = {}) {
307
+ const { manifest, files } = await readBundleDirectory(dir, { forceName })
308
+ const target = outPath ?? join(process.cwd(), `${manifest.key}-${manifest.version}.h2kext`)
309
+ await writeBundle(files, target)
310
+ return { manifest, outPath: target, files }
311
+ }
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@ham2k/extension-tools",
3
+ "version": "0.1.0",
4
+ "description": "Build and package extensions for the Ham2K Logger",
5
+ "keywords": ["ham2k", "ham radio", "amateur radio", "extension", "esbuild", "h2kext"],
6
+ "license": "MPL-2.0",
7
+ "author": "Sebastian Delmont <sd@ham2k.com>",
8
+ "homepage": "https://ham2k.com",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/ham2k/halo.git",
12
+ "directory": "extensions/tools"
13
+ },
14
+ "type": "module",
15
+ "exports": {
16
+ ".": "./h2kext-build.mjs",
17
+ "./format": "./h2kext.mjs"
18
+ },
19
+ "bin": {
20
+ "h2kext-pack": "./h2kext-pack.mjs"
21
+ },
22
+ "files": [
23
+ "h2kext.mjs",
24
+ "h2kext-build.mjs",
25
+ "h2kext-pack.mjs",
26
+ "README.md"
27
+ ],
28
+ "engines": {
29
+ "node": ">=20"
30
+ },
31
+ "dependencies": {
32
+ "@ham2k/lib-callsigns": "^1.0.2",
33
+ "yazl": "^3.3.1"
34
+ },
35
+ "peerDependencies": {
36
+ "esbuild": ">=0.20"
37
+ },
38
+ "peerDependenciesMeta": {
39
+ "esbuild": {
40
+ "optional": true
41
+ }
42
+ }
43
+ }