@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 +9 -0
- package/h2kext-build.mjs +102 -0
- package/h2kext-pack.mjs +6 -1
- package/h2kext-publish.mjs +9 -0
- package/h2kext.mjs +63 -0
- package/package.json +1 -1
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}`)
|
package/h2kext-publish.mjs
CHANGED
|
@@ -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
|
|