@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.
package/h2kext.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  // Copyright ©️ 2026 Sebastian Delmont <sd@ham2k.com>
2
- // SPDX-License-Identifier: MPL-2.0
2
+ // SPDX-License-Identifier: MIT
3
3
  //
4
4
  // The `.h2kext` bundle format: what one contains, what makes one valid, and how
5
5
  // to write it (HALO-422, docs/extensions/distribution.md).
@@ -16,11 +16,11 @@
16
16
  // one way it can be built WRONG is invisible until the extension is running
17
17
  // inside somebody's app.
18
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'
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
24
 
25
25
  /// The extension API this format describes. A bundle declares the version it
26
26
  /// was built against and the host refuses one it does not speak, so an old app
@@ -36,7 +36,7 @@ export const KEY_PATTERN = /^[a-z0-9]+-[a-z0-9-]*[a-z0-9]$/
36
36
  /// bundle unless it is one of ours: there is no signing yet, so anyone could
37
37
  /// claim it, and a key is what the app shows when it says where a hook came
38
38
  /// from.
39
- export const RESERVED_PREFIX = 'ham2k'
39
+ export const RESERVED_PREFIX = "ham2k"
40
40
 
41
41
  /// Whether [prefix] is a real amateur callsign — the namespace every author of
42
42
  /// this software already has, globally unique, and free.
@@ -66,14 +66,14 @@ export const SAFE_KEY_PATTERN = /^[a-z0-9][a-z0-9-]*$/
66
66
  /// app is where the rule bites, refusing a reserved or colliding key at
67
67
  /// install.
68
68
  export function keyProblem(key, { forceName = false } = {}) {
69
- if (typeof key !== 'string' || !SAFE_KEY_PATTERN.test(key)) {
69
+ if (typeof key !== "string" || !SAFE_KEY_PATTERN.test(key)) {
70
70
  return `key '${key}' must be lowercase, and contain only letters, digits and hyphens`
71
71
  }
72
72
  if (forceName) return null
73
73
  if (!KEY_PATTERN.test(key)) {
74
74
  return `key '${key}' must be '<callsign>-<name>' (e.g. ki2d-my-clock)`
75
75
  }
76
- const prefix = key.slice(0, key.indexOf('-'))
76
+ const prefix = key.slice(0, key.indexOf("-"))
77
77
  if (prefix === RESERVED_PREFIX) {
78
78
  return `key '${key}' uses the '${RESERVED_PREFIX}-' prefix, which is reserved for Ham2K's own extensions`
79
79
  }
@@ -84,9 +84,9 @@ export function keyProblem(key, { forceName = false } = {}) {
84
84
  }
85
85
 
86
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/'
87
+ export const MANIFEST_ENTRY = "manifest.json"
88
+ export const BUNDLE_ENTRY = "index.js"
89
+ export const ASSETS_PREFIX = "assets/"
90
90
 
91
91
  /// The libraries the extension host carries, per API version, so a bundle does
92
92
  /// not have to ship its own copy of each. The kernel holds one instance and
@@ -103,18 +103,18 @@ export const ASSETS_PREFIX = 'assets/'
103
103
  /// live once this file is published on its own.
104
104
  export const SHARED_MODULES_BY_API = {
105
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',
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
118
  ],
119
119
  }
120
120
 
@@ -136,14 +136,14 @@ export const SHARED_MODULES_BY_API = {
136
136
  /// actually holds, so a major bump there fails a build rather than shipping a
137
137
  /// scaffold that cannot install.
138
138
  export const SCAFFOLD_SHARED_DEPENDENCIES = {
139
- '@ham2k/lib-callsigns': '^1.0.0',
140
- '@ham2k/lib-country-files': '^1.0.0',
141
- '@ham2k/lib-dxcc-data': '^1.0.0',
142
- '@ham2k/lib-format-tools': '^1.0.0',
143
- '@ham2k/lib-geo-tools': '^1.0.0',
144
- '@ham2k/lib-operation-data': '^1.0.0',
145
- 'i18next': '^23.0.0',
146
- 'liquidjs': '^10.0.0',
139
+ "@ham2k/lib-callsigns": "^1.0.0",
140
+ "@ham2k/lib-country-files": "^1.0.0",
141
+ "@ham2k/lib-dxcc-data": "^1.0.0",
142
+ "@ham2k/lib-format-tools": "^1.0.0",
143
+ "@ham2k/lib-geo-tools": "^1.0.0",
144
+ "@ham2k/lib-operation-data": "^1.0.0",
145
+ i18next: "^23.0.0",
146
+ liquidjs: "^10.0.0",
147
147
  }
148
148
 
149
149
  /// What the host offers a bundle declaring [api].
@@ -153,18 +153,109 @@ export function sharedModulesFor(api) {
153
153
 
154
154
  export class PackError extends Error {}
155
155
 
156
+ /// The vocabulary of the manifest's `relevance` — what the matcher compares
157
+ /// the operator's own values against, so a value outside it can never match
158
+ /// anyone. Continents are cty.dat's column-4 codes; the regions are the three
159
+ /// the ITU Radio Regulations define.
160
+ export const GEO_CONTINENTS = ["AF", "AS", "EU", "NA", "OC", "SA", "AN"]
161
+ export const GEO_ITU_REGIONS = [1, 2, 3]
162
+
163
+ /// What `relevance.interests` may name: a mode, a band group, or a style of
164
+ /// operating. Closed, and checked, because an interest nothing else spells
165
+ /// the same way matches nobody while looking like it works — `CW`, `cw` and
166
+ /// `morse` would be three interests, and only one of them the one the app
167
+ /// asks for.
168
+ ///
169
+ /// Growing the list is how a new interest arrives. Guessing is not.
170
+ export const RELEVANCE_INTERESTS = ["cw", "phone", "digital", "hf", "vhf", "uhf", "microwave", "satellite", "portable", "qrp"]
171
+
172
+ const RELEVANCE_KEYS = ["entities", "countries", "continents", "ituRegions", "dates", "interests"]
173
+
174
+ /// Everything wrong with a manifest's `relevance`, as a list — empty for a
175
+ /// valid one, and for an absent one, which means worldwide and undated.
176
+ ///
177
+ /// The shape rules are the ones that matter: the matcher reads a key it
178
+ /// cannot parse as "does not gate", so a misspelt key or a string where a
179
+ /// list should be would quietly make a Chilean extension relevant to the
180
+ /// whole world. The vocabulary rules catch the opposite mistake — a value that
181
+ /// can never match anyone, which is a bundle nobody is shown.
182
+ ///
183
+ /// Shared with build-config.mjs's manifest checks, which import it: the
184
+ /// rules are the format's, not the repo's, and this module is the format.
185
+ export function validateRelevance(relevance) {
186
+ if (relevance === undefined) return []
187
+ if (typeof relevance !== "object" || relevance === null || Array.isArray(relevance)) {
188
+ return [`'relevance' must be an object of ${RELEVANCE_KEYS.join(" / ")}`]
189
+ }
190
+ const problems = []
191
+ for (const key of Object.keys(relevance)) {
192
+ if (!RELEVANCE_KEYS.includes(key)) {
193
+ problems.push(`'relevance' has an unknown key '${key}' — only ${RELEVANCE_KEYS.join(", ")} are read`)
194
+ }
195
+ }
196
+ for (const key of RELEVANCE_KEYS) {
197
+ const values = relevance[key]
198
+ if (values === undefined) continue
199
+ if (!Array.isArray(values)) {
200
+ problems.push(`'relevance.${key}' must be a list`)
201
+ continue
202
+ }
203
+ for (const value of values) {
204
+ const problem = relevanceValueProblem(key, value)
205
+ if (problem) problems.push(`'relevance.${key}' has ${JSON.stringify(value)}, which ${problem}`)
206
+ }
207
+ }
208
+ return problems
209
+ }
210
+
211
+ function relevanceValueProblem(key, value) {
212
+ if (key === "ituRegions") {
213
+ return GEO_ITU_REGIONS.includes(value) ? null : `is not an ITU region (${GEO_ITU_REGIONS.join(", ")})`
214
+ }
215
+ if (typeof value !== "string" || value.trim() === "") return "is not a non-empty string"
216
+ if (key === "entities") {
217
+ return /^\*?[A-Z0-9/]+$/.test(value.toUpperCase()) ? null : "is not a DXCC entity prefix (letters, digits and '/', with an optional leading '*')"
218
+ }
219
+ if (key === "countries") {
220
+ return /^[a-z]{2}$/.test(value.toLowerCase()) ? null : "is not a two-letter ISO country code"
221
+ }
222
+ if (key === "dates") return calendarDateProblem(value)
223
+ if (key === "interests") {
224
+ return RELEVANCE_INTERESTS.includes(value) ? null : `is not an interest (${RELEVANCE_INTERESTS.join(" ")})`
225
+ }
226
+ return GEO_CONTINENTS.includes(value.toUpperCase()) ? null : `is not a continent (${GEO_CONTINENTS.join(" ")})`
227
+ }
228
+
229
+ /// Why [value] is not a UTC calendar day, or null.
230
+ ///
231
+ /// The round trip is the whole check: `Date.UTC` rolls 31 February over into
232
+ /// 3 March rather than refusing it, so a pattern match alone accepts a day
233
+ /// that does not exist and then silently means a different one.
234
+ ///
235
+ /// A year under 1000 is refused before that round trip, and the Dart twin
236
+ /// refuses it too. `Date.UTC` reads a year under 100 as 19xx, so `0050-01-01`
237
+ /// would fail the round trip here and pass in Dart — a disagreement between
238
+ /// the two on an input neither should be accepting anyway.
239
+ function calendarDateProblem(value) {
240
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return "is not a YYYY-MM-DD date"
241
+ const [year, month, day] = value.split("-").map(Number)
242
+ if (year < 1000) return "is not a year an event could fall in"
243
+ const at = new Date(Date.UTC(year, month - 1, day))
244
+ return at.getUTCFullYear() === year && at.getUTCMonth() === month - 1 && at.getUTCDate() === day ? null : "is not a day that exists"
245
+ }
246
+
156
247
  /// Everything wrong with a candidate bundle, as a list — an author fixing one
157
248
  /// problem per run is an author running this five times.
158
249
  export function validateManifest(manifest, { source = MANIFEST_ENTRY, forceName = false } = {}) {
159
250
  const problems = []
160
251
  const say = (m) => problems.push(`${source}: ${m}`)
161
252
 
162
- if (typeof manifest !== 'object' || manifest === null || Array.isArray(manifest)) {
253
+ if (typeof manifest !== "object" || manifest === null || Array.isArray(manifest)) {
163
254
  return [`${source}: must be a JSON object`]
164
255
  }
165
256
 
166
- for (const field of ['key', 'name', 'version', 'description']) {
167
- if (typeof manifest[field] !== 'string' || manifest[field].trim() === '') {
257
+ for (const field of ["key", "name", "version", "description"]) {
258
+ if (typeof manifest[field] !== "string" || manifest[field].trim() === "") {
168
259
  say(`missing required field '${field}'`)
169
260
  }
170
261
  }
@@ -186,20 +277,20 @@ export function validateManifest(manifest, { source = MANIFEST_ENTRY, forceName
186
277
  // never listed in the Extensions panel. That is a status the app grants its
187
278
  // own extensions, not one a distributed bundle may claim for itself — a user
188
279
  // could neither see it nor turn it off.
189
- if (typeof manifest.category !== 'string' || manifest.category.trim() === '') {
280
+ if (typeof manifest.category !== "string" || manifest.category.trim() === "") {
190
281
  say("missing 'category' — a bundle with none is treated as core: always on, and invisible in the Extensions panel")
191
282
  }
192
283
 
193
284
  if (manifest.sharedDependencies !== undefined) {
194
285
  const declared = manifest.sharedDependencies
195
- if (typeof declared !== 'object' || declared === null || Array.isArray(declared)) {
286
+ if (typeof declared !== "object" || declared === null || Array.isArray(declared)) {
196
287
  say("'sharedDependencies' must be an object of package name to version range")
197
288
  } else {
198
289
  const offered = sharedModulesFor(manifest.api)
199
290
  for (const [pkg, range] of Object.entries(declared)) {
200
291
  if (!offered.includes(pkg)) {
201
292
  say(`'sharedDependencies' names '${pkg}', which the host does not carry — bundle it into your build instead`)
202
- } else if (typeof range !== 'string' || range.trim() === '') {
293
+ } else if (typeof range !== "string" || range.trim() === "") {
203
294
  say(`'sharedDependencies' entry '${pkg}' needs a version range, e.g. "^1.0.0"`)
204
295
  }
205
296
  }
@@ -213,6 +304,28 @@ export function validateManifest(manifest, { source = MANIFEST_ENTRY, forceName
213
304
  say("'experiments' gates on the app's own experiment catalog and would hide this extension permanently — remove it")
214
305
  }
215
306
 
307
+ // The name `relevance` replaced. Nothing reads `geo`, and nothing here
308
+ // rejects unknown top-level keys, so a manifest still carrying it would
309
+ // pack, publish and install as WORLDWIDE — the author's audience silently
310
+ // discarded rather than refused. It is the one stale key certain to
311
+ // arrive, because it is what every older manifest says.
312
+ if (manifest.geo !== undefined) {
313
+ say("'geo' is now 'relevance' — rename it (docs/extensions/distribution.md)")
314
+ }
315
+
316
+ // `enabledByDefault` is the shipping app's answer for its OWN extensions,
317
+ // which arrive whether the operator wanted them or not. An installed
318
+ // bundle is one the operator went and got, so the answer is always yes and
319
+ // a manifest that says otherwise is describing a state it cannot reach.
320
+ if (manifest.enabledByDefault !== undefined) {
321
+ say("'enabledByDefault' applies to an app's built-in extensions — an installed one is on because the operator installed it")
322
+ }
323
+
324
+ // `relevance` ranks the bundle, in the catalog and the Extensions panel,
325
+ // for the operators it was written for. The matcher reads a key it cannot
326
+ // parse as "does not gate", so a malformed one would ship as worldwide.
327
+ for (const problem of validateRelevance(manifest.relevance)) say(problem)
328
+
216
329
  return problems
217
330
  }
218
331
 
@@ -238,7 +351,7 @@ export function findSharedModuleReferences(source) {
238
351
  export async function readBundleDirectory(dir, { forceName = false } = {}) {
239
352
  let manifestRaw
240
353
  try {
241
- manifestRaw = await readFile(join(dir, MANIFEST_ENTRY), 'utf8')
354
+ manifestRaw = await readFile(join(dir, MANIFEST_ENTRY), "utf8")
242
355
  } catch {
243
356
  throw new PackError(`no ${MANIFEST_ENTRY} in ${dir}`)
244
357
  }
@@ -254,11 +367,11 @@ export async function readBundleDirectory(dir, { forceName = false } = {}) {
254
367
 
255
368
  let bundleSource
256
369
  try {
257
- bundleSource = await readFile(join(dir, BUNDLE_ENTRY), 'utf8')
370
+ bundleSource = await readFile(join(dir, BUNDLE_ENTRY), "utf8")
258
371
  } catch {
259
372
  throw new PackError(`no ${BUNDLE_ENTRY} in ${dir} — build your extension first; this packages, it does not compile`)
260
373
  }
261
- if (bundleSource.trim() === '') problems.push(`${BUNDLE_ENTRY}: is empty`)
374
+ if (bundleSource.trim() === "") problems.push(`${BUNDLE_ENTRY}: is empty`)
262
375
 
263
376
  // Using the host's shared libraries is allowed, and is much the better
264
377
  // option — inlining them costs about ten times the bundle size. What is NOT
@@ -270,12 +383,15 @@ export async function readBundleDirectory(dir, { forceName = false } = {}) {
270
383
  const undeclared = findSharedModuleReferences(bundleSource).filter((pkg) => !(pkg in declaredShared))
271
384
  if (undeclared.length) {
272
385
  problems.push(
273
- `${BUNDLE_ENTRY}: uses the host's shared modules without declaring them (${undeclared.join(', ')}). ` +
386
+ `${BUNDLE_ENTRY}: uses the host's shared modules without declaring them (${undeclared.join(", ")}). ` +
274
387
  `Add them to 'sharedDependencies' in ${MANIFEST_ENTRY} with the version range you work with, ` +
275
- 'e.g. {"' + undeclared[0] + '": "^1.0.0"}, so the host can refuse to load this against a version you never tested.')
388
+ 'e.g. {"' +
389
+ undeclared[0] +
390
+ '": "^1.0.0"}, so the host can refuse to load this against a version you never tested.',
391
+ )
276
392
  }
277
393
 
278
- if (problems.length) throw new PackError(problems.join('\n'))
394
+ if (problems.length) throw new PackError(problems.join("\n"))
279
395
 
280
396
  const files = [
281
397
  { entry: MANIFEST_ENTRY, path: join(dir, MANIFEST_ENTRY) },
@@ -296,7 +412,7 @@ async function collectAssets(dir) {
296
412
  // No assets/ is ordinary. Anything else — unreadable, an I/O error, a
297
413
  // broken symlink — is not, and swallowing it ships a bundle silently
298
414
  // missing files the author believes are in it.
299
- if (e.code === 'ENOENT') return []
415
+ if (e.code === "ENOENT") return []
300
416
  throw new PackError(`could not read ${root}: ${e.message}`)
301
417
  }
302
418
 
@@ -323,9 +439,9 @@ export function writeBundle(files, outPath) {
323
439
  for (const file of files) zip.addFile(file.path, file.entry)
324
440
  const out = createWriteStream(outPath)
325
441
  zip.outputStream.pipe(out)
326
- out.on('close', () => resolve(outPath))
327
- out.on('error', reject)
328
- zip.outputStream.on('error', reject)
442
+ out.on("close", () => resolve(outPath))
443
+ out.on("error", reject)
444
+ zip.outputStream.on("error", reject)
329
445
  zip.end()
330
446
  })
331
447
  }
package/package.json CHANGED
@@ -1,9 +1,16 @@
1
1
  {
2
2
  "name": "@ham2k/extension-tools",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
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",
5
+ "keywords": [
6
+ "ham2k",
7
+ "ham radio",
8
+ "amateur radio",
9
+ "extension",
10
+ "esbuild",
11
+ "h2kext"
12
+ ],
13
+ "license": "MIT",
7
14
  "author": "Sebastian Delmont <sd@ham2k.com>",
8
15
  "homepage": "https://ham2k.com",
9
16
  "repository": {
@@ -18,13 +25,15 @@
18
25
  },
19
26
  "bin": {
20
27
  "h2kext-init": "./h2kext-init.mjs",
21
- "h2kext-pack": "./h2kext-pack.mjs"
28
+ "h2kext-pack": "./h2kext-pack.mjs",
29
+ "h2kext-publish": "./h2kext-publish.mjs"
22
30
  },
23
31
  "files": [
24
32
  "h2kext.mjs",
25
33
  "h2kext-build.mjs",
26
34
  "h2kext-init.mjs",
27
35
  "h2kext-pack.mjs",
36
+ "h2kext-publish.mjs",
28
37
  "README.md"
29
38
  ],
30
39
  "engines": {