@ham2k/extension-tools 0.15.0 → 1.12.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
@@ -6,6 +6,9 @@ Build and package extensions for the [Ham2K Logger](https://ham2k.com).
6
6
  npm install --save-dev @ham2k/extension-tools esbuild
7
7
  ```
8
8
 
9
+ The minor version is the newest extension API the packer accepts — 1.12 packs
10
+ bundles up to `"api": 12` — and matches `@ham2k/extension-sdk`'s.
11
+
9
12
  ## Starting one
10
13
 
11
14
  ```sh
@@ -57,6 +60,12 @@ size. Using one **without** declaring it fails the build rather than quietly
57
60
  shipping a private copy; pass `inline: ['liquidjs']` if that copy is what you
58
61
  want, which is how you use a version the host does not carry.
59
62
 
63
+ It then activates the built bundle against a stand-in host, and fails the build
64
+ if the bundle registers a settings panel that your manifest's `hooks` does not
65
+ list: the app does not show an undeclared panel. If activation needs more than
66
+ the stand-in provides, or waits on a timer or the host, the build succeeds and
67
+ prints a warning that this check did not run.
68
+
60
69
  ## Packaging
61
70
 
62
71
  ```sh
package/h2kext-build.mjs CHANGED
@@ -23,6 +23,7 @@ import { readFileSync } from "node:fs"
23
23
  import { copyFile, mkdir, readFile } from "node:fs/promises"
24
24
  import { createRequire } from "node:module"
25
25
  import { dirname, join, resolve } from "node:path"
26
+ import vm from "node:vm"
26
27
 
27
28
  import { H2KEXT_API_VERSION, MANIFEST_ENTRY, TIMERS_API_VERSION, sharedModulesFor } from "./h2kext.mjs"
28
29
 
@@ -345,6 +346,8 @@ export async function buildExtension(esbuild, { dir, entry = "src/index.ts", out
345
346
  }),
346
347
  )
347
348
 
349
+ await checkSettingsPanelDeclared({ bundle: join(out, "index.js"), manifest, dir })
350
+
348
351
  // The packer reads both from one directory, so the manifest the bundle was
349
352
  // built against is the manifest that ships with it. Copied rather than left
350
353
  // for the author to remember: a stale manifest beside a fresh bundle is a
@@ -353,3 +356,102 @@ export async function buildExtension(esbuild, { dir, entry = "src/index.ts", out
353
356
 
354
357
  return { outDir: out, manifest }
355
358
  }
359
+
360
+ /// Fails the build when the bundle registers a `settingsPanel` hook its
361
+ /// manifest's `hooks` does not list.
362
+ ///
363
+ /// The app shows an installed extension's settings panel only when its
364
+ /// manifest declares the hook (docs/extensions/settings.md), so the panel
365
+ /// would build, pack, publish and install cleanly and then never appear —
366
+ /// with nothing but a line in the app's console to say why.
367
+ ///
368
+ /// Asked of the code rather than the source: the bundle is evaluated against
369
+ /// a recording stand-in for the kernel and activated, so a panel registered
370
+ /// from a loop or behind an await counts the same as a literal one. The
371
+ /// libraries the host carries come from the author's own node_modules.
372
+ ///
373
+ /// An extension whose activation needs more than the stand-in answers — or
374
+ /// does not finish within [ACTIVATION_DEADLINE_MS] — is not failed for it,
375
+ /// since that would be the check's shortcoming, not the author's, but it is
376
+ /// told the check did not run.
377
+ const ACTIVATION_DEADLINE_MS = 2000
378
+
379
+ async function checkSettingsPanelDeclared({ bundle, manifest, dir }) {
380
+ const require = createRequire(resolve(dir, "noop.js"))
381
+ const sharedModules = new Proxy(
382
+ {},
383
+ {
384
+ get(_, pkg) {
385
+ if (typeof pkg !== "string") return undefined
386
+ try {
387
+ return require(pkg)
388
+ } catch {
389
+ // Left undefined, as on a host without it: a bundle that touches it
390
+ // while activating fails there, and the warning below says so.
391
+ return undefined
392
+ }
393
+ },
394
+ },
395
+ )
396
+ const hostCall = async (method) => (method === "getSettings" ? {} : method.startsWith("dbLookup") ? [] : null)
397
+ const definitions = []
398
+ const sandbox = {
399
+ console: { log() {}, info() {}, warn() {}, error() {}, debug() {} },
400
+ sendMessage: () => {},
401
+ __polo: {
402
+ defineExtension: (definition) => definitions.push(definition),
403
+ hostCall,
404
+ log: () => {},
405
+ invokeLocal: async () => [],
406
+ invokeLocalSequential: async () => [],
407
+ updateInterpretation: () => {},
408
+ getAccountKvKey: (extensionKey) => extensionKey,
409
+ registerDynamicForm: () => {},
410
+ unregisterDynamicForm: () => {},
411
+ host: { showForm: async () => null },
412
+ sharedModules,
413
+ sharedVersions: {},
414
+ },
415
+ }
416
+ sandbox.globalThis = sandbox
417
+
418
+ const registered = new Set()
419
+ let deadline
420
+ try {
421
+ vm.runInContext(await readFile(bundle, "utf8"), vm.createContext(sandbox), { filename: bundle, timeout: ACTIVATION_DEADLINE_MS })
422
+ const activation = (async () => {
423
+ for (const definition of definitions) {
424
+ await definition.onActivation?.({
425
+ hostCall,
426
+ timers: { set: () => 0, clear: () => {} },
427
+ registerHook: (category) => registered.add(category),
428
+ })
429
+ }
430
+ })()
431
+ // The stand-in's timers never fire, so an activation that waits on one
432
+ // never settles — and a pending promise does not keep Node running, so
433
+ // the build would end right here, silently, without copying the manifest.
434
+ await Promise.race([
435
+ activation,
436
+ new Promise((_, reject) => {
437
+ deadline = setTimeout(
438
+ () => reject(new Error(`activation had not finished after ${ACTIVATION_DEADLINE_MS / 1000}s, waiting on a timer or the host`)),
439
+ ACTIVATION_DEADLINE_MS,
440
+ )
441
+ }),
442
+ ])
443
+ } catch (e) {
444
+ console.warn(`warning: could not activate the built bundle to check its "hooks" against what it registers, so that check did not run — ${e?.message ?? e}`)
445
+ return
446
+ } finally {
447
+ clearTimeout(deadline)
448
+ }
449
+
450
+ if (registered.has("settingsPanel") && !(manifest.hooks ?? []).includes("settingsPanel")) {
451
+ throw new BuildError(
452
+ `the extension registers a settings panel, but ${MANIFEST_ENTRY} does not list "settingsPanel" in "hooks" — ` +
453
+ `the app would never show the panel. Add it to "hooks", and say where it goes with "settings" ` +
454
+ `(docs/extensions/settings.md).`,
455
+ )
456
+ }
457
+ }
package/h2kext-pack.mjs CHANGED
@@ -10,7 +10,7 @@
10
10
  // The directory holds a built `index.js`, a `manifest.json`, and optionally an
11
11
  // `assets/` folder. Nothing here compiles anything — see h2kext.mjs.
12
12
 
13
- import { pack, PackError, H2KEXT_API_VERSION, RESERVED_PREFIX } from "./h2kext.mjs"
13
+ import { pack, PackError, H2KEXT_API_VERSION, RESERVED_PREFIX, settingsReviewReasons } from "./h2kext.mjs"
14
14
 
15
15
  const args = process.argv.slice(2)
16
16
 
@@ -67,6 +67,11 @@ try {
67
67
  if (result.manifest.webSockets?.length) {
68
68
  console.log(` may hold connections to: ${result.manifest.webSockets.join(", ")}`)
69
69
  }
70
+ // Not a refusal: placements beyond the usual are allowed, once an admin has
71
+ // looked. Said now so a publish that waits in the queue is no surprise.
72
+ for (const reason of settingsReviewReasons(result.manifest)) {
73
+ console.log(` held for review on the catalog: ${reason}`)
74
+ }
70
75
  } catch (e) {
71
76
  if (e instanceof PackError) {
72
77
  console.error(`h2kext-pack: ${dir} is not ready to package:\n${e.message}`)
@@ -528,6 +528,15 @@ export async function main(
528
528
  const result = await publish({ bytes, manifest, channel, catalog, token }, { fetch })
529
529
  say(`published ${result.key} ${result.version} → ${result.channel} (${STATUS_WORDS[result.status] ?? result.status})`)
530
530
  if (result.url) say(result.url)
531
+ // The catalog names what about the settings placement an admin looks at
532
+ // (docs/extensions/settings.md), or which earlier version's identical
533
+ // placement one already approved.
534
+ const review = result.settingsReview
535
+ if (review?.acceptedIn) {
536
+ say(`settings placement as approved in ${review.acceptedIn}`)
537
+ } else {
538
+ for (const reason of review?.reasons ?? []) say(`settings placement for review: ${reason.message ?? reason.code}`)
539
+ }
531
540
 
532
541
  if (notes !== undefined) {
533
542
  try {
package/h2kext.mjs CHANGED
@@ -26,6 +26,10 @@ import yazl from "yazl"
26
26
  /// was built against and the host refuses one it does not speak, so an old app
27
27
  /// meeting a newer bundle says so instead of failing at a call site.
28
28
  ///
29
+ /// It is also the minor version of this package and of `@ham2k/extension-sdk`,
30
+ /// whose `API_VERSION` repeats it: raising it is a minor release of both
31
+ /// (sdk/src/apiVersion.test.ts holds the three together).
32
+ ///
29
33
  /// Raised when a bundle using something new could not work at all on an app
30
34
  /// without it — not for every addition. 2 adds `host.webSocket` and the
31
35
  /// manifest's `webSockets` grant; 3 adds timers and `onShow`/`onHide`; 4 lets
@@ -328,6 +332,63 @@ export function validateAdifFieldsScope(manifest) {
328
332
  ]
329
333
  }
330
334
 
335
+ /// The settings sections a manifest's `settings.section` may name without
336
+ /// review — keys of the app's own settings groups. `preferred` is where a
337
+ /// panel usually belongs, by the extension's main purpose; `other` suits one
338
+ /// that tunes something the app already has a section for. The app holds the
339
+ /// same list (`extensionSettingsSections`, app/lib/services/extensions/
340
+ /// settings_placement.dart) and the catalog its own copy; see
341
+ /// docs/extensions/settings.md.
342
+ export const SETTINGS_SECTIONS = {
343
+ preferred: ["activities", "hunting", "contests", "lookups", "spotting", "dashboard"],
344
+ other: ["logging", "radio", "map", "exports"],
345
+ }
346
+
347
+ const SETTINGS_KEYS = ["section", "sidebar", "common"]
348
+
349
+ /// Everything wrong with the SHAPE of a manifest's `settings`, as a list —
350
+ /// empty for a valid one and for an absent one. What it places is not
351
+ /// checked here: a section outside [SETTINGS_SECTIONS], a sidebar entry or
352
+ /// Common Preferences rows are allowed, and reviewed (see
353
+ /// [settingsReviewReasons]).
354
+ export function validateSettingsPlacement(settings) {
355
+ if (settings === undefined) return []
356
+ if (typeof settings !== "object" || settings === null || Array.isArray(settings)) {
357
+ return [`'settings' must be an object of ${SETTINGS_KEYS.join(" / ")}`]
358
+ }
359
+ const problems = []
360
+ for (const key of Object.keys(settings)) {
361
+ if (!SETTINGS_KEYS.includes(key)) problems.push(`'settings' has an unknown key '${key}' — only ${SETTINGS_KEYS.join(", ")} are read`)
362
+ }
363
+ if (settings.section !== undefined && (typeof settings.section !== "string" || !/^[a-z][a-z0-9-]*$/.test(settings.section))) {
364
+ problems.push(`'settings.section' must be a settings section's key, such as "${SETTINGS_SECTIONS.preferred[0]}"`)
365
+ }
366
+ for (const key of ["sidebar", "common"]) {
367
+ if (settings[key] !== undefined && typeof settings[key] !== "boolean") problems.push(`'settings.${key}' must be true or false`)
368
+ }
369
+ return problems
370
+ }
371
+
372
+ /// Why the catalog would hold this manifest's settings for an admin's review,
373
+ /// as a list — empty when they go where any extension's may. The app shows
374
+ /// a placement the manifest does not declare as if it were not asked for, so
375
+ /// the manifest is the whole of what is reviewed.
376
+ export function settingsReviewReasons(manifest) {
377
+ const settings = manifest?.settings
378
+ const hasPanel = Array.isArray(manifest?.hooks) && manifest.hooks.includes("settingsPanel")
379
+ const declared = typeof settings === "object" && settings !== null && !Array.isArray(settings) ? settings : {}
380
+ const reasons = []
381
+ const allowed = [...SETTINGS_SECTIONS.preferred, ...SETTINGS_SECTIONS.other]
382
+ if (typeof declared.section === "string") {
383
+ if (!allowed.includes(declared.section)) reasons.push(`'settings.section' is "${declared.section}", which is not one of ${allowed.join(", ")}`)
384
+ } else if (hasPanel) {
385
+ reasons.push(`'hooks' lists 'settingsPanel' but 'settings.section' names no section, so the panel would be a category of its own`)
386
+ }
387
+ if (declared.sidebar === true) reasons.push(`'settings.sidebar' asks for an entry in the Settings sidebar`)
388
+ if (declared.common === true) reasons.push(`'settings.common' asks to add fields to Common Preferences`)
389
+ return reasons
390
+ }
391
+
331
392
  /// Everything wrong with a candidate bundle, as a list — an author fixing one
332
393
  /// problem per run is an author running this five times.
333
394
  export function validateManifest(manifest, { source = MANIFEST_ENTRY, forceName = false } = {}) {
@@ -435,6 +496,8 @@ export function validateManifest(manifest, { source = MANIFEST_ENTRY, forceName
435
496
 
436
497
  for (const problem of validateAdifFieldsScope(manifest)) say(problem)
437
498
 
499
+ for (const problem of validateSettingsPlacement(manifest.settings)) say(problem)
500
+
438
501
  return problems
439
502
  }
440
503
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ham2k/extension-tools",
3
- "version": "0.15.0",
3
+ "version": "1.12.1",
4
4
  "description": "Build and package extensions for the Ham2K Logger",
5
5
  "keywords": [
6
6
  "ham2k",