@ham2k/extension-tools 0.3.0 → 0.5.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 CHANGED
@@ -17,6 +17,12 @@ script, a tsconfig — into the working directory, and refuses rather than
17
17
  overwrite anything already there. An existing `package.json` is added to, never
18
18
  replaced.
19
19
 
20
+ It also writes a `.prettierrc.json` and the `format` / `format:check` scripts,
21
+ and what it scaffolds is already in that style — so `npm run format:check`
22
+ passes on an untouched project, and the code an agent adds to it lands
23
+ formatted the same way. Configure Prettier already, anywhere it looks, and
24
+ yours is left in charge: no config is written, and the command says so.
25
+
20
26
  It takes the key rather than defaulting one: a placeholder like
21
27
  `n0call-my-extension` validates, so it would ship, which is the one outcome the
22
28
  naming convention exists to stop.
package/h2kext-build.mjs CHANGED
@@ -19,12 +19,12 @@
19
19
  // The repo's own build uses it too, on the same terms, which is what keeps the
20
20
  // path a third party takes from being the untested one.
21
21
 
22
- import { readFileSync } from 'node:fs'
23
- import { copyFile, mkdir, readFile } from 'node:fs/promises'
24
- import { createRequire } from 'node:module'
25
- import { dirname, join, resolve } from 'node:path'
22
+ import { readFileSync } from "node:fs"
23
+ import { copyFile, mkdir, readFile } from "node:fs/promises"
24
+ import { createRequire } from "node:module"
25
+ import { dirname, join, resolve } from "node:path"
26
26
 
27
- import { H2KEXT_API_VERSION, MANIFEST_ENTRY, sharedModulesFor } from './h2kext.mjs'
27
+ import { H2KEXT_API_VERSION, MANIFEST_ENTRY, sharedModulesFor } from "./h2kext.mjs"
28
28
 
29
29
  /// What an extension's own `build.mjs` says, for everything that has to write
30
30
  /// one: `h2kext-init`'s scaffold, and the samples the SDK ships.
@@ -34,12 +34,12 @@ import { H2KEXT_API_VERSION, MANIFEST_ENTRY, sharedModulesFor } from './h2kext.m
34
34
  /// copy is exercised by this repo's own build — the extensions here are built
35
35
  /// through `build-config.mjs`, which takes a different path into the same
36
36
  /// preset.
37
- export const EXAMPLE_BUILD_SCRIPT = `import { build } from 'esbuild'
38
- import { buildExtension } from '@ham2k/extension-tools'
37
+ export const EXAMPLE_BUILD_SCRIPT = `import { build } from "esbuild"
38
+ import { buildExtension } from "@ham2k/extension-tools"
39
39
 
40
40
  await buildExtension(build, { dir: import.meta.dirname })
41
41
 
42
- console.log('built build/index.js')
42
+ console.log("built build/index.js")
43
43
  `
44
44
 
45
45
  export class BuildError extends Error {}
@@ -75,7 +75,7 @@ export class BuildError extends Error {}
75
75
  /// failed build.
76
76
  function installedVersionOf(pkg, fromDir) {
77
77
  try {
78
- const require = createRequire(resolve(fromDir, 'noop.js'))
78
+ const require = createRequire(resolve(fromDir, "noop.js"))
79
79
  // Walked up from the entry point rather than resolved directly: a package
80
80
  // with an `exports` map that does not list `./package.json` — which is
81
81
  // most of them, `@ham2k/lib-qson-cabrillo` included — refuses that path
@@ -84,7 +84,7 @@ function installedVersionOf(pkg, fromDir) {
84
84
  let dir = dirname(require.resolve(pkg))
85
85
  for (let up = 0; up < 6; up += 1) {
86
86
  try {
87
- const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'))
87
+ const manifest = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"))
88
88
  if (manifest.name === pkg) return manifest.version ?? null
89
89
  } catch {
90
90
  // Not this directory's package.json, or not readable — keep climbing.
@@ -109,8 +109,9 @@ export function sharedModulesPlugin({ sharedDependencies = {}, api = H2KEXT_API_
109
109
  const notOffered = declared.filter((pkg) => !offered.includes(pkg))
110
110
  if (notOffered.length) {
111
111
  throw new BuildError(
112
- `${MANIFEST_ENTRY} declares sharedDependencies the host does not carry (${notOffered.join(', ')}). ` +
113
- 'Bundle those into your build instead — only what the host offers can be shared.')
112
+ `${MANIFEST_ENTRY} declares sharedDependencies the host does not carry (${notOffered.join(", ")}). ` +
113
+ "Bundle those into your build instead — only what the host offers can be shared.",
114
+ )
114
115
  }
115
116
 
116
117
  // The two say opposite things about the same package, so neither can be
@@ -118,19 +119,19 @@ export function sharedModulesPlugin({ sharedDependencies = {}, api = H2KEXT_API_
118
119
  const both = declared.filter((pkg) => inline.includes(pkg))
119
120
  if (both.length) {
120
121
  throw new BuildError(
121
- `${both.join(', ')} appears in both sharedDependencies and inline. ` +
122
- "Declare it to use the host's copy, or inline it to ship your own — not both.")
122
+ `${both.join(", ")} appears in both sharedDependencies and inline. ` + "Declare it to use the host's copy, or inline it to ship your own — not both.",
123
+ )
123
124
  }
124
125
 
125
126
  const undeclared = offered.filter((pkg) => !declared.includes(pkg) && !inline.includes(pkg))
126
127
 
127
128
  return {
128
- name: 'ham2k-shared-modules',
129
+ name: "ham2k-shared-modules",
129
130
  setup(build) {
130
131
  if (declared.length) {
131
132
  build.onResolve({ filter: matcherFor(declared) }, (args) => ({
132
133
  path: args.path,
133
- namespace: 'ham2k-shared-module',
134
+ namespace: "ham2k-shared-module",
134
135
  }))
135
136
  }
136
137
 
@@ -144,19 +145,22 @@ export function sharedModulesPlugin({ sharedDependencies = {}, api = H2KEXT_API_
144
145
  // esbuild's pretty printer and nothing else, so a build script that
145
146
  // catches the error — or runs at logLevel 'silent' — shows the
146
147
  // author the complaint with the answer stripped off.
147
- errors: [{
148
- text: `'${args.path}' is carried by the Ham2K extension host, but this bundle does not declare it. ` +
149
- `Add it to "sharedDependencies" in ${MANIFEST_ENTRY} with the version range you work with ` +
150
- `(e.g. {${JSON.stringify(args.path)}: "^${installedVersionOf(args.path, args.resolveDir) ?? '1.0.0'}"}), ` +
151
- `or pass inline: [${JSON.stringify(args.path)}] ` +
152
- 'to ship your own copy — which is how you use a version the host does not carry.',
153
- }],
148
+ errors: [
149
+ {
150
+ text:
151
+ `'${args.path}' is carried by the Ham2K extension host, but this bundle does not declare it. ` +
152
+ `Add it to "sharedDependencies" in ${MANIFEST_ENTRY} with the version range you work with ` +
153
+ `(e.g. {${JSON.stringify(args.path)}: "^${installedVersionOf(args.path, args.resolveDir) ?? "1.0.0"}"}), ` +
154
+ `or pass inline: [${JSON.stringify(args.path)}] ` +
155
+ "to ship your own copy — which is how you use a version the host does not carry.",
156
+ },
157
+ ],
154
158
  }))
155
159
  }
156
160
 
157
- build.onLoad({ filter: /.*/, namespace: 'ham2k-shared-module' }, (args) => ({
161
+ build.onLoad({ filter: /.*/, namespace: "ham2k-shared-module" }, (args) => ({
158
162
  contents: `module.exports = globalThis.__polo.sharedModules[${JSON.stringify(args.path)}];`,
159
- loader: 'js',
163
+ loader: "js",
160
164
  }))
161
165
  },
162
166
  }
@@ -166,7 +170,7 @@ export function sharedModulesPlugin({ sharedDependencies = {}, api = H2KEXT_API_
166
170
  /// names carry regex metacharacters (`@ham2k/lib-qson-adif` has a slash, and a
167
171
  /// dot would be a wildcard), so every one is escaped.
168
172
  function matcherFor(packages) {
169
- const alternatives = packages.map((pkg) => pkg.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')).join('|')
173
+ const alternatives = packages.map((pkg) => pkg.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&")).join("|")
170
174
  return new RegExp(`^(${alternatives})$`)
171
175
  }
172
176
 
@@ -186,14 +190,14 @@ function liquidBrowserBuild(fromDir) {
186
190
  // `dir` is the caller's to pass — a relative one lands in the catch below
187
191
  // and loses the alias, which is precisely the failure this function exists
188
192
  // to prevent, arriving with nothing to point at the cause.
189
- const require = createRequire(resolve(fromDir, 'noop.js'))
190
- return join(dirname(require.resolve('liquidjs/package.json')), 'dist/liquid.browser.mjs')
193
+ const require = createRequire(resolve(fromDir, "noop.js"))
194
+ return join(dirname(require.resolve("liquidjs/package.json")), "dist/liquid.browser.mjs")
191
195
  } catch (e) {
192
196
  // Not installed is the normal case for an extension that neither inlines
193
197
  // liquidjs nor uses it, and there is nothing to alias. Anything else is a
194
198
  // real fault, and swallowing it would hand the author five unresolvable
195
199
  // node built-ins instead.
196
- if (e.code === 'MODULE_NOT_FOUND') return null
200
+ if (e.code === "MODULE_NOT_FOUND") return null
197
201
  throw e
198
202
  }
199
203
  }
@@ -210,20 +214,17 @@ export function buildOptionsFor({ entry, outfile, manifest = {}, dir = process.c
210
214
  entryPoints: [entry],
211
215
  ...(outfile ? { outfile } : { write: false }),
212
216
  bundle: true,
213
- format: 'iife',
214
- platform: 'neutral',
215
- mainFields: ['module', 'main'],
216
- target: 'es2020',
217
- logLevel: 'warning',
217
+ format: "iife",
218
+ platform: "neutral",
219
+ mainFields: ["module", "main"],
220
+ target: "es2020",
221
+ logLevel: "warning",
218
222
  ...rest,
219
223
  alias: {
220
224
  ...(liquid ? { liquidjs: liquid } : {}),
221
225
  ...alias,
222
226
  },
223
- plugins: [
224
- sharedModulesPlugin({ sharedDependencies: manifest.sharedDependencies, api: manifest.api, inline }),
225
- ...(rest.plugins ?? []),
226
- ],
227
+ plugins: [sharedModulesPlugin({ sharedDependencies: manifest.sharedDependencies, api: manifest.api, inline }), ...(rest.plugins ?? [])],
227
228
  }
228
229
  }
229
230
 
@@ -236,12 +237,12 @@ export function buildOptionsFor({ entry, outfile, manifest = {}, dir = process.c
236
237
  ///
237
238
  /// [esbuild] is the caller's `build` function, passed in rather than imported
238
239
  /// so this module needs no copy of its own.
239
- export async function buildExtension(esbuild, { dir, entry = 'src/index.ts', outDir = 'build', ...rest } = {}) {
240
- if (!dir) throw new BuildError('buildExtension needs `dir`: the extension directory holding manifest.json')
240
+ export async function buildExtension(esbuild, { dir, entry = "src/index.ts", outDir = "build", ...rest } = {}) {
241
+ if (!dir) throw new BuildError("buildExtension needs `dir`: the extension directory holding manifest.json")
241
242
 
242
243
  let manifest
243
244
  try {
244
- manifest = JSON.parse(await readFile(join(dir, MANIFEST_ENTRY), 'utf8'))
245
+ manifest = JSON.parse(await readFile(join(dir, MANIFEST_ENTRY), "utf8"))
245
246
  } catch (e) {
246
247
  throw new BuildError(`could not read ${join(dir, MANIFEST_ENTRY)}: ${e.message}`)
247
248
  }
@@ -249,13 +250,15 @@ export async function buildExtension(esbuild, { dir, entry = 'src/index.ts', out
249
250
  const out = join(dir, outDir)
250
251
  await mkdir(out, { recursive: true })
251
252
 
252
- await esbuild(buildOptionsFor({
253
- entry: join(dir, entry),
254
- outfile: join(out, 'index.js'),
255
- manifest,
256
- dir,
257
- ...rest,
258
- }))
253
+ await esbuild(
254
+ buildOptionsFor({
255
+ entry: join(dir, entry),
256
+ outfile: join(out, "index.js"),
257
+ manifest,
258
+ dir,
259
+ ...rest,
260
+ }),
261
+ )
259
262
 
260
263
  // The packer reads both from one directory, so the manifest the bundle was
261
264
  // built against is the manifest that ships with it. Copied rather than left
package/h2kext-init.mjs CHANGED
@@ -16,17 +16,17 @@
16
16
  // `n0call-my-extension` VALIDATES — N0CALL is a well-formed callsign — so it
17
17
  // would ship, which is the one outcome the naming convention exists to stop.
18
18
 
19
- import { readFileSync } from 'node:fs'
20
- import { access, mkdir, readFile, writeFile } from 'node:fs/promises'
21
- import { dirname, join, relative, resolve } from 'node:path'
22
- import { fileURLToPath } from 'node:url'
19
+ import { readFileSync } from "node:fs"
20
+ import { access, mkdir, readFile, writeFile } from "node:fs/promises"
21
+ import { dirname, join, relative, resolve } from "node:path"
22
+ import { fileURLToPath } from "node:url"
23
23
 
24
- import { EXAMPLE_BUILD_SCRIPT } from './h2kext-build.mjs'
25
- import { H2KEXT_API_VERSION, SCAFFOLD_SHARED_DEPENDENCIES, keyProblem } from './h2kext.mjs'
24
+ import { EXAMPLE_BUILD_SCRIPT } from "./h2kext-build.mjs"
25
+ import { H2KEXT_API_VERSION, SCAFFOLD_SHARED_DEPENDENCIES, keyProblem } from "./h2kext.mjs"
26
26
 
27
27
  const args = process.argv.slice(2)
28
28
 
29
- if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
29
+ if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
30
30
  console.log(`Scaffold a Ham2K Logger extension.
31
31
 
32
32
  h2kext-init <key> [--dir <path>] [--name "<display name>"]
@@ -35,10 +35,11 @@ The key is '<yourcallsign>-<name>' — e.g. ki2d-my-clock. The callsign is a
35
35
  namespace you already have, globally unique, so two authors who never met
36
36
  cannot collide. It also namespaces your extension's storage.
37
37
 
38
- Writes manifest.json, src/index.ts, build.mjs, tsconfig.json, AGENTS.md and
39
- .gitignore into <path> (default: the working directory), refusing rather than
40
- overwrite anything already there. An existing package.json is added to — the
41
- scripts and dependencies this needs, nothing of yours replaced.`)
38
+ Writes manifest.json, src/index.ts, build.mjs, tsconfig.json, AGENTS.md,
39
+ .prettierrc.json and .gitignore into <path> (default: the working directory),
40
+ refusing rather than overwrite anything already there. An existing
41
+ package.json is added to — the scripts and dependencies this needs, nothing of
42
+ yours replaced.`)
42
43
  process.exit(args.length === 0 ? 1 : 0)
43
44
  }
44
45
 
@@ -48,9 +49,9 @@ let displayName
48
49
  for (let i = 0; i < args.length; i++) {
49
50
  // `resolve('')` is the working directory, so a bare `--dir` would scaffold
50
51
  // wherever the author happened to be standing.
51
- if (args[i] === '--dir') dir = args[++i] ? resolve(args[i]) : undefined
52
- else if (args[i] === '--name') displayName = args[++i]
53
- else if (!args[i].startsWith('-') && key === undefined) key = args[i]
52
+ if (args[i] === "--dir") dir = args[++i] ? resolve(args[i]) : undefined
53
+ else if (args[i] === "--name") displayName = args[++i]
54
+ else if (!args[i].startsWith("-") && key === undefined) key = args[i]
54
55
  }
55
56
 
56
57
  const problem = keyProblem(key)
@@ -60,16 +61,21 @@ if (problem) {
60
61
  }
61
62
 
62
63
  if (dir === undefined) {
63
- console.error('h2kext-init: --dir needs the directory to scaffold into')
64
+ console.error("h2kext-init: --dir needs the directory to scaffold into")
64
65
  process.exit(1)
65
66
  }
66
67
 
67
- const shortKey = key.slice(key.indexOf('-') + 1)
68
- const name = displayName ??
69
- shortKey.split('-').filter(Boolean).map((w) => w[0].toUpperCase() + w.slice(1)).join(' ')
68
+ const shortKey = key.slice(key.indexOf("-") + 1)
69
+ const name =
70
+ displayName ??
71
+ shortKey
72
+ .split("-")
73
+ .filter(Boolean)
74
+ .map((w) => w[0].toUpperCase() + w.slice(1))
75
+ .join(" ")
70
76
 
71
77
  const here = dirname(fileURLToPath(import.meta.url))
72
- const { version: toolsVersion } = JSON.parse(await readFile(join(here, 'package.json'), 'utf8'))
78
+ const { version: toolsVersion } = JSON.parse(await readFile(join(here, "package.json"), "utf8"))
73
79
 
74
80
  /// The SDK is a SEPARATE package on its own version line, so this cannot be
75
81
  /// answered with the version of the tools that happen to be running: a
@@ -93,8 +99,8 @@ function sdkRange(projectDir) {
93
99
  for (const start of [projectDir, here]) {
94
100
  for (let dir = start; ; dir = dirname(dir)) {
95
101
  try {
96
- const path = join(dir, 'node_modules/@ham2k/extension-sdk/package.json')
97
- const { version } = JSON.parse(readFileSync(path, 'utf8'))
102
+ const path = join(dir, "node_modules/@ham2k/extension-sdk/package.json")
103
+ const { version } = JSON.parse(readFileSync(path, "utf8"))
98
104
  if (version) return `^${version}`
99
105
  } catch {
100
106
  // Not here; walk up to the next node_modules.
@@ -102,63 +108,91 @@ function sdkRange(projectDir) {
102
108
  if (dirname(dir) === dir) break
103
109
  }
104
110
  }
105
- return 'latest'
111
+ return "latest"
112
+ }
113
+
114
+ /// One object of a JSON file, indented as Prettier indents it. Prettier keeps
115
+ /// an object expanded when it was written expanded, so nesting one of these
116
+ /// inside a template is stable at any width; an ARRAY it re-decides by width,
117
+ /// which is why every array below is written inline by hand rather than
118
+ /// stringified. Held by a test that formats everything the scaffold writes
119
+ /// and compares it with what Prettier would have produced.
120
+ function nestedJson(value, indent) {
121
+ return JSON.stringify(value, null, 2).replaceAll("\n", `\n${indent}`)
106
122
  }
107
123
 
108
124
  /// What the project's package.json has to say for the build, the packer and
109
125
  /// the typecheck to run.
110
126
  const projectPackage = {
111
127
  name: key,
112
- version: '0.1.0',
128
+ version: "0.1.0",
113
129
  private: true,
114
- type: 'module',
130
+ type: "module",
115
131
  scripts: {
116
- build: 'node build.mjs',
117
- pack: 'h2kext-pack build',
118
- typecheck: 'tsc --noEmit',
132
+ build: "node build.mjs",
133
+ pack: "h2kext-pack build",
134
+ typecheck: "tsc --noEmit",
135
+ format: "prettier --write .",
136
+ "format:check": "prettier --check .",
119
137
  },
120
138
  dependencies: {
121
- '@ham2k/extension-sdk': sdkRange(dir),
139
+ "@ham2k/extension-sdk": sdkRange(dir),
122
140
  },
123
141
  devDependencies: {
124
- '@ham2k/extension-tools': `^${toolsVersion}`,
125
- esbuild: '^0.25.0',
126
- typescript: '^5.6.0',
142
+ "@ham2k/extension-tools": `^${toolsVersion}`,
143
+ esbuild: "^0.25.0",
144
+ // A patch range, not a caret: Prettier changes its output in a MINOR, and
145
+ // the templates below are checked against one version here. A caret would
146
+ // let an author install a 3.x that formats differently and fail the first
147
+ // `format:check` on files they never touched.
148
+ prettier: "~3.9.0",
149
+ typescript: "^5.6.0",
127
150
  },
128
151
  }
129
152
 
130
153
  const files = {
131
- 'manifest.json': `${JSON.stringify({
132
- key,
133
- name,
134
- version: '0.1.0',
135
- description: `${name}, an extension for the Ham2K Logger`,
136
- category: 'dashboard',
137
- api: H2KEXT_API_VERSION,
138
- icon: 'hand-wave-outline',
139
- // The Extensions list reads this without evaluating any bundle, and the
140
- // Add-Panel route shows only what declares `panel`. Undeclared, a panel
141
- // extension installs and then cannot be found or switched on.
142
- hooks: ['panel'],
143
- sharedDependencies: SCAFFOLD_SHARED_DEPENDENCIES,
144
- }, null, 2)}\n`,
145
-
146
- 'build.mjs': EXAMPLE_BUILD_SCRIPT,
147
-
148
- 'src/index.ts': `// ${name} — a panel that says hello.
154
+ // `hooks` is what the Extensions list reads without evaluating any bundle,
155
+ // and the Add-Panel route shows only what declares `panel`. Undeclared, a
156
+ // panel extension installs and then cannot be found or switched on.
157
+ "manifest.json": `{
158
+ "key": ${JSON.stringify(key)},
159
+ "name": ${JSON.stringify(name)},
160
+ "version": "0.1.0",
161
+ "description": ${JSON.stringify(`${name}, an extension for the Ham2K Logger`)},
162
+ "category": "dashboard",
163
+ "api": ${JSON.stringify(H2KEXT_API_VERSION)},
164
+ "icon": "hand-wave-outline",
165
+ "hooks": ["panel"],
166
+ "sharedDependencies": ${nestedJson(SCAFFOLD_SHARED_DEPENDENCIES, " ")}
167
+ }
168
+ `,
169
+
170
+ "build.mjs": EXAMPLE_BUILD_SCRIPT,
171
+
172
+ "src/index.ts": `// ${name} — a panel that says hello.
149
173
  //
150
174
  // Everything here is the minimum: one hook, one descriptor, one render. See
151
175
  // node_modules/@ham2k/extension-sdk/AGENTS.md for what to read next.
152
176
 
153
- import { defineExtension } from '@ham2k/extension-sdk'
154
- import type { HookContext, PanelContent, PanelDescriptor, PanelHook, PanelRenderArgs } from '@ham2k/extension-sdk'
177
+ import { defineExtension } from "@ham2k/extension-sdk"
178
+ import type { HookContext, PanelContent, PanelDescriptor, PanelHook, PanelRenderArgs } from "@ham2k/extension-sdk"
155
179
 
156
- import manifest from '../manifest.json'
180
+ import manifest from "../manifest.json"
157
181
 
158
182
  const HelloPanel: PanelHook = {
159
183
  /// What the operator sees when choosing a panel to place.
160
184
  async getPanels(_args: Record<string, never>, _ctx: HookContext): Promise<PanelDescriptor[]> {
161
- return [{ key: 'hello', title: ${JSON.stringify(name)}, description: 'A panel that says hello', icon: 'hand-wave-outline' }]
185
+ // Written a field per line, and left that way by the formatter: on one
186
+ // line a long enough --name would push it past the width and the
187
+ // scaffold's own \`format:check\` would fail on untouched code.
188
+ return [
189
+ {
190
+ key: "hello",
191
+ title: ${JSON.stringify(name)},
192
+ description: "A panel that says hello",
193
+ icon: "hand-wave-outline",
194
+ },
195
+ ]
162
196
  },
163
197
 
164
198
  /// What the pane shows. A panel returns CONTENT, never widgets — which is
@@ -168,37 +202,38 @@ const HelloPanel: PanelHook = {
168
202
  /// its registered key and then calls this by name, so a near-miss is a panel
169
203
  /// that installs, activates, and then renders nothing.
170
204
  async render(_args: PanelRenderArgs, _ctx: HookContext): Promise<PanelContent> {
171
- return { kind: 'markdown', content: '# Hello, World!' }
205
+ return { kind: "markdown", content: "# Hello, World!" }
172
206
  },
173
207
  }
174
208
 
175
209
  defineExtension({
176
210
  ...manifest,
177
211
  onActivation({ registerHook }) {
178
- registerHook('panel', { key: '${key}', hook: HelloPanel })
212
+ registerHook("panel", { key: "${key}", hook: HelloPanel })
179
213
  },
180
214
  })
181
215
  `,
182
216
 
183
- 'tsconfig.json': `${JSON.stringify({
184
- compilerOptions: {
185
- target: 'ES2020',
186
- module: 'ESNext',
187
- moduleResolution: 'bundler',
188
- lib: ['ES2020'],
189
- strict: true,
190
- noEmit: true,
191
- skipLibCheck: true,
192
- resolveJsonModule: true,
193
- },
194
- include: ['src/**/*.ts'],
195
- }, null, 2)}\n`,
217
+ "tsconfig.json": `{
218
+ "compilerOptions": {
219
+ "target": "ES2020",
220
+ "module": "ESNext",
221
+ "moduleResolution": "bundler",
222
+ "lib": ["ES2020"],
223
+ "strict": true,
224
+ "noEmit": true,
225
+ "skipLibCheck": true,
226
+ "resolveJsonModule": true
227
+ },
228
+ "include": ["src/**/*.ts"]
229
+ }
230
+ `,
196
231
 
197
232
  // The whole point of the file. Claude Code and its siblings read a project's
198
233
  // AGENTS.md before they touch anything, so this is what carries the hook
199
234
  // reference, the sandbox's limits and the manifest's shape into the session
200
235
  // — none of which anyone would think to go looking for in node_modules.
201
- 'AGENTS.md': `# ${name}
236
+ "AGENTS.md": `# ${name}
202
237
 
203
238
  An extension for the Ham2K Logger. How to write one, what the hooks are, and
204
239
  what the sandbox will and will not let you do:
@@ -206,14 +241,24 @@ what the sandbox will and will not let you do:
206
241
  @node_modules/@ham2k/extension-sdk/AGENTS.md
207
242
  `,
208
243
 
209
- '.gitignore': 'node_modules/\nbuild/\n*.h2kext\n',
244
+ // The style the scaffolded files are already in, so `npm run format:check`
245
+ // passes on an untouched project. Prettier reads the project's .gitignore
246
+ // from its working directory, so build/ and the packed .h2kext are already
247
+ // out of its way and no .prettierignore is needed.
248
+ ".prettierrc.json": `{
249
+ "printWidth": 160,
250
+ "semi": false
251
+ }
252
+ `,
253
+
254
+ ".gitignore": "node_modules/\nbuild/\n*.h2kext\n",
210
255
  }
211
256
 
212
257
  // Reported together rather than one per run: a scaffold that stops at the
213
258
  // first collision has already written half a project, and the second attempt
214
259
  // then collides with its own output.
215
- const packagePath = join(dir, 'package.json')
216
- const currentPackage = await readFile(packagePath, 'utf8').then(
260
+ const packagePath = join(dir, "package.json")
261
+ const currentPackage = await readFile(packagePath, "utf8").then(
217
262
  (text) => {
218
263
  try {
219
264
  return JSON.parse(text)
@@ -225,27 +270,71 @@ const currentPackage = await readFile(packagePath, 'utf8').then(
225
270
  process.exit(1)
226
271
  }
227
272
  },
228
- () => ({}))
273
+ () => ({}),
274
+ )
229
275
 
230
276
  // `null`, `[]` and `"text"` all parse, and none of them is something to merge
231
277
  // into — reached at the end, each is a raw TypeError over six written files.
232
- if (currentPackage === null || typeof currentPackage !== 'object' || Array.isArray(currentPackage)) {
278
+ if (currentPackage === null || typeof currentPackage !== "object" || Array.isArray(currentPackage)) {
233
279
  console.error(`h2kext-init: ${packagePath} is not a package description — nothing written`)
234
280
  process.exit(1)
235
281
  }
236
282
 
283
+ // Prettier reads the FIRST config it finds, and a `prettier` key in the
284
+ // package.json already here outranks a `.prettierrc.json` sitting beside it —
285
+ // so writing ours would leave a file that is never read, under templates
286
+ // formatted to a style the author's own config contradicts. Their config
287
+ // wins; this one is dropped, and said out loud, because the alternative is a
288
+ // project whose first `npm run format` rewrites every scaffolded file.
289
+ const PRETTIER_CONFIG_FILES = [
290
+ ".prettierrc",
291
+ ".prettierrc.json",
292
+ ".prettierrc.json5",
293
+ ".prettierrc.yaml",
294
+ ".prettierrc.yml",
295
+ ".prettierrc.js",
296
+ ".prettierrc.mjs",
297
+ ".prettierrc.cjs",
298
+ ".prettierrc.toml",
299
+ "prettier.config.js",
300
+ "prettier.config.mjs",
301
+ "prettier.config.cjs",
302
+ ]
303
+ const foundAt = async (path) =>
304
+ await access(join(dir, path)).then(
305
+ () => true,
306
+ () => false,
307
+ )
308
+ let foreignPrettierConfig
309
+ if (currentPackage.prettier !== undefined) foreignPrettierConfig = "the `prettier` key in package.json"
310
+ else
311
+ for (const path of PRETTIER_CONFIG_FILES) {
312
+ if (path !== ".prettierrc.json" && (await foundAt(path))) {
313
+ foreignPrettierConfig = path
314
+ break
315
+ }
316
+ }
317
+ if (foreignPrettierConfig) delete files[".prettierrc.json"]
318
+
237
319
  // `access`, not a read: a directory where a file should go, or one this
238
320
  // process cannot read, is something in the way — treated as absent, it becomes
239
321
  // a write loop that fails half-way through.
240
322
  const existing = []
241
323
  for (const path of Object.keys(files)) {
242
- if (await access(join(dir, path)).then(() => true, () => false)) existing.push(path)
324
+ if (
325
+ await access(join(dir, path)).then(
326
+ () => true,
327
+ () => false,
328
+ )
329
+ )
330
+ existing.push(path)
243
331
  }
244
332
  if (existing.length) {
245
333
  const where = relative(process.cwd(), dir)
246
334
  console.error(
247
- `h2kext-init: ${where.startsWith('..') ? dir : where || '.'} already holds ${existing.join(', ')} — ` +
248
- 'nothing written. Scaffold into an empty directory with --dir.')
335
+ `h2kext-init: ${where.startsWith("..") ? dir : where || "."} already holds ${existing.join(", ")} — ` +
336
+ "nothing written. Scaffold into an empty directory with --dir.",
337
+ )
249
338
  process.exit(1)
250
339
  }
251
340
 
@@ -262,15 +351,24 @@ for (const [path, content] of Object.entries(files)) {
262
351
  // Filled in key by key, never overwritten: what the author already declared —
263
352
  // their own name for the project, a version of esbuild they chose — is theirs.
264
353
  for (const [field, value] of Object.entries(projectPackage)) {
265
- if (typeof value !== 'object') currentPackage[field] ??= value
354
+ if (typeof value !== "object") currentPackage[field] ??= value
266
355
  else for (const [name, entry] of Object.entries(value)) (currentPackage[field] ??= {})[name] ??= entry
267
356
  }
268
357
  await writeFile(packagePath, `${JSON.stringify(currentPackage, null, 2)}\n`)
269
358
 
270
359
  console.log(`${key} — ${name}
271
360
 
272
- ${[...Object.keys(files), 'package.json'].join('\n ')}
273
-
361
+ ${[...Object.keys(files), "package.json"].join("\n ")}
362
+ ${
363
+ foreignPrettierConfig
364
+ ? `
365
+ No .prettierrc.json written — ${foreignPrettierConfig} already configures
366
+ Prettier here, and it is the one Prettier reads. The scaffolded files are in
367
+ the style this command's own config describes (print width 160, no
368
+ semicolons); \`npm run format\` will restyle them to yours.
369
+ `
370
+ : ""
371
+ }
274
372
  Next:
275
373
  npm install
276
374
  npm run build && npm run pack`)
package/h2kext-pack.mjs CHANGED
@@ -10,11 +10,11 @@
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 } from "./h2kext.mjs"
14
14
 
15
15
  const args = process.argv.slice(2)
16
16
 
17
- if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
17
+ if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
18
18
  console.log(`Package an extension directory into a .h2kext bundle.
19
19
 
20
20
  h2kext-pack <directory> [-o <out.h2kext>] [--force-name]
@@ -37,19 +37,19 @@ Defaults to <key>-<version>.h2kext in the working directory.`)
37
37
 
38
38
  // Walked by index rather than matched by value: `args.indexOf(a)` finds the
39
39
  // FIRST occurrence, so `-o foo foo` mistook the output path for the directory.
40
- const forceName = args.includes('--force-name')
40
+ const forceName = args.includes("--force-name")
41
41
  let dir
42
42
  let outPath
43
43
  for (let i = 0; i < args.length; i++) {
44
- if (args[i] === '-o') {
44
+ if (args[i] === "-o") {
45
45
  outPath = args[++i]
46
- } else if (!args[i].startsWith('-') && dir === undefined) {
46
+ } else if (!args[i].startsWith("-") && dir === undefined) {
47
47
  dir = args[i]
48
48
  }
49
49
  }
50
50
 
51
51
  if (!dir) {
52
- console.error('h2kext-pack: name the directory to package')
52
+ console.error("h2kext-pack: name the directory to package")
53
53
  process.exit(1)
54
54
  }
55
55
 
@@ -58,11 +58,14 @@ try {
58
58
  const assets = result.files.length - 2
59
59
  console.log(`${result.outPath}`)
60
60
  console.log(` ${result.manifest.key} ${result.manifest.version} — ${result.manifest.name}`)
61
- console.log(` ${assets} asset${assets === 1 ? '' : 's'}`)
61
+ console.log(` ${assets} asset${assets === 1 ? "" : "s"}`)
62
62
  if (result.manifest.domains?.length) {
63
63
  // Named on the way out, because this is the one part of the manifest the
64
64
  // person installing it will be asked to approve.
65
- console.log(` may reach: ${result.manifest.domains.join(', ')}`)
65
+ console.log(` may reach: ${result.manifest.domains.join(", ")}`)
66
+ }
67
+ if (result.manifest.webSockets?.length) {
68
+ console.log(` may hold connections to: ${result.manifest.webSockets.join(", ")}`)
66
69
  }
67
70
  } catch (e) {
68
71
  if (e instanceof PackError) {
@@ -16,27 +16,27 @@
16
16
  // never written anywhere — not to a config file, not to the terminal, not into
17
17
  // an error message.
18
18
 
19
- import { realpathSync } from 'node:fs'
20
- import { mkdtemp, readFile, rm, stat } from 'node:fs/promises'
21
- import { tmpdir } from 'node:os'
22
- import { join } from 'node:path'
23
- import { pathToFileURL } from 'node:url'
24
- import { inflateRawSync } from 'node:zlib'
19
+ import { realpathSync } from "node:fs"
20
+ import { mkdtemp, readFile, rm, stat } from "node:fs/promises"
21
+ import { tmpdir } from "node:os"
22
+ import { join } from "node:path"
23
+ import { pathToFileURL } from "node:url"
24
+ import { inflateRawSync } from "node:zlib"
25
25
 
26
- import { MANIFEST_ENTRY, pack, PackError } from './h2kext.mjs'
26
+ import { MANIFEST_ENTRY, pack, PackError } from "./h2kext.mjs"
27
27
 
28
- export const DEFAULT_CATALOG = 'https://catalog.ham2k.net'
29
- export const PUBLISH_PATH = '/api/v1/publish'
30
- export const SUBMISSIONS_PATH = '/api/v1/submissions'
31
- export const TOKEN_ENV = 'H2K_CATALOG_TOKEN'
28
+ export const DEFAULT_CATALOG = "https://catalog.ham2k.net"
29
+ export const PUBLISH_PATH = "/api/v1/publish"
30
+ export const SUBMISSIONS_PATH = "/api/v1/submissions"
31
+ export const TOKEN_ENV = "H2K_CATALOG_TOKEN"
32
32
  /// How finished a release is, steadiest first — NOT an app edition. The
33
33
  /// catalog cascades, so a release on `stable` is visible to everyone and one
34
34
  /// on `bleeding` only to somebody who asked for it.
35
- export const CHANNELS = ['stable', 'unstable', 'bleeding']
35
+ export const CHANNELS = ["stable", "unstable", "bleeding"]
36
36
 
37
37
  /// What `--channel` means when it is not given. Publishing without saying is
38
38
  /// publishing something finished; anything rougher is a choice worth typing.
39
- export const DEFAULT_CHANNEL = 'stable'
39
+ export const DEFAULT_CHANNEL = "stable"
40
40
 
41
41
  /// The catalog refuses a larger upload before reading it (§5.1 step 2),
42
42
  /// because the app never downloads more than this (`kMaxDownloadBytes`).
@@ -51,7 +51,7 @@ export const EXIT_USAGE = 2
51
51
  /// How a submission status reads on the result line. `pending` means a
52
52
  /// reviewer has not looked yet; saying "review" is what stops an author
53
53
  /// waiting for the extension to appear.
54
- const STATUS_WORDS = { pending: 'pending review' }
54
+ const STATUS_WORDS = { pending: "pending review" }
55
55
 
56
56
  export class PublishError extends Error {
57
57
  constructor(message, exitCode = EXIT_REFUSED) {
@@ -94,7 +94,7 @@ or cannot be reached, 2 for a usage problem.`
94
94
  export function readManifestFromBundle(bytes) {
95
95
  const buf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes)
96
96
  if (buf.length < 22 || buf.readUInt32LE(0) !== 0x04034b50) {
97
- throw new PublishError('not a .h2kext: not a zip')
97
+ throw new PublishError("not a .h2kext: not a zip")
98
98
  }
99
99
  // The end-of-central-directory record is the last 22 bytes plus a comment of
100
100
  // up to 64 KiB, so the search walks backwards over that much and no more.
@@ -105,13 +105,13 @@ export function readManifestFromBundle(bytes) {
105
105
  break
106
106
  }
107
107
  }
108
- if (eocd < 0) throw new PublishError('not a .h2kext: no zip central directory')
108
+ if (eocd < 0) throw new PublishError("not a .h2kext: no zip central directory")
109
109
 
110
110
  const count = buf.readUInt16LE(eocd + 10)
111
111
  let offset = buf.readUInt32LE(eocd + 16)
112
112
  for (let n = 0; n < count; n++) {
113
113
  if (offset + 46 > buf.length || buf.readUInt32LE(offset) !== 0x02014b50) {
114
- throw new PublishError('not a .h2kext: damaged zip central directory')
114
+ throw new PublishError("not a .h2kext: damaged zip central directory")
115
115
  }
116
116
  const method = buf.readUInt16LE(offset + 10)
117
117
  const compressedSize = buf.readUInt32LE(offset + 20)
@@ -119,7 +119,7 @@ export function readManifestFromBundle(bytes) {
119
119
  const extraLength = buf.readUInt16LE(offset + 30)
120
120
  const commentLength = buf.readUInt16LE(offset + 32)
121
121
  const localOffset = buf.readUInt32LE(offset + 42)
122
- const name = buf.toString('utf8', offset + 46, offset + 46 + nameLength)
122
+ const name = buf.toString("utf8", offset + 46, offset + 46 + nameLength)
123
123
  if (name === MANIFEST_ENTRY) {
124
124
  // Every offset here comes from the file itself, so a truncated or
125
125
  // doctored bundle can point past its own end: refused as a bundle,
@@ -139,7 +139,7 @@ export function readManifestFromBundle(bytes) {
139
139
  }
140
140
  } else throw new PublishError(`not a .h2kext: ${MANIFEST_ENTRY} uses zip compression method ${method}`)
141
141
  try {
142
- return JSON.parse(raw.toString('utf8'))
142
+ return JSON.parse(raw.toString("utf8"))
143
143
  } catch (e) {
144
144
  throw new PublishError(`${MANIFEST_ENTRY} inside the bundle is not valid JSON: ${e.message}`)
145
145
  }
@@ -157,13 +157,13 @@ async function loadBundle(source, { forceName }) {
157
157
  try {
158
158
  info = await stat(source)
159
159
  } catch (e) {
160
- throw new PublishError(`${source}: ${e.code === 'ENOENT' ? 'no such file or directory' : e.message}`)
160
+ throw new PublishError(`${source}: ${e.code === "ENOENT" ? "no such file or directory" : e.message}`)
161
161
  }
162
162
  if (!info.isDirectory()) return await readFile(source)
163
163
 
164
- const tmp = await mkdtemp(join(tmpdir(), 'h2kext-publish-'))
164
+ const tmp = await mkdtemp(join(tmpdir(), "h2kext-publish-"))
165
165
  try {
166
- const { outPath } = await pack(source, { outPath: join(tmp, 'bundle.h2kext'), forceName })
166
+ const { outPath } = await pack(source, { outPath: join(tmp, "bundle.h2kext"), forceName })
167
167
  return await readFile(outPath)
168
168
  } catch (e) {
169
169
  if (e instanceof PackError) throw new PublishError(`${source} is not ready to publish:\n${e.message}`)
@@ -195,9 +195,7 @@ async function call(fetch, catalog, path, init) {
195
195
  /// What a refusal says: the server's `error` and `code`, or its raw body when
196
196
  /// it sent no JSON — a proxy's HTML is still worth seeing.
197
197
  function refusal({ response, text, body }) {
198
- return body?.error
199
- ? `${body.error}${body.code ? ` (${body.code})` : ''}`
200
- : text.trim() || response.statusText || 'no response body'
198
+ return body?.error ? `${body.error}${body.code ? ` (${body.code})` : ""}` : text.trim() || response.statusText || "no response body"
201
199
  }
202
200
 
203
201
  /// Uploads [bytes] as the raw request body (§5.1: no multipart) and
@@ -208,8 +206,8 @@ function refusal({ response, text, body }) {
208
206
  /// failure, not the catalog's.
209
207
  export async function publish({ bytes, manifest, channel, catalog, token }, { fetch = globalThis.fetch } = {}) {
210
208
  const answer = await call(fetch, catalog, `${PUBLISH_PATH}?channel=${channel}`, {
211
- method: 'POST',
212
- headers: { authorization: `Bearer ${token}`, 'content-type': 'application/zip' },
209
+ method: "POST",
210
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/zip" },
213
211
  body: bytes,
214
212
  })
215
213
  if (answer.response.status === 201 && answer.body) return answer.body
@@ -220,8 +218,8 @@ export async function publish({ bytes, manifest, channel, catalog, token }, { fe
220
218
  /// outside 2xx is a [PublishError]; the submission it was for stands.
221
219
  export async function setReleaseNotes({ submissionId, notes, catalog, token }, { fetch = globalThis.fetch } = {}) {
222
220
  const answer = await call(fetch, catalog, `${SUBMISSIONS_PATH}/${encodeURIComponent(submissionId)}/notes`, {
223
- method: 'PUT',
224
- headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
221
+ method: "PUT",
222
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
225
223
  body: JSON.stringify({ releaseNotes: notes }),
226
224
  })
227
225
  if (answer.response.ok) return
@@ -231,7 +229,7 @@ export async function setReleaseNotes({ submissionId, notes, catalog, token }, {
231
229
  /// The command. Returns the exit code rather than exiting, so the same code
232
230
  /// runs under a test with a fake `fetch` and captured output.
233
231
  export async function main(argv, { env = process.env, fetch = globalThis.fetch, stdout = process.stdout, stderr = process.stderr } = {}) {
234
- if (argv.length === 0 || argv.includes('--help') || argv.includes('-h')) {
232
+ if (argv.length === 0 || argv.includes("--help") || argv.includes("-h")) {
235
233
  stdout.write(`${USAGE}\n`)
236
234
  return argv.length === 0 ? EXIT_USAGE : 0
237
235
  }
@@ -240,7 +238,7 @@ export async function main(argv, { env = process.env, fetch = globalThis.fetch,
240
238
 
241
239
  // Every line out goes through this, so the token cannot reach a terminal or
242
240
  // a CI log by way of an error that happened to quote a header or a URL.
243
- const redact = (s) => (token ? String(s).split(token).join('<token>') : String(s))
241
+ const redact = (s) => (token ? String(s).split(token).join("<token>") : String(s))
244
242
  const say = (s) => stdout.write(`${redact(s)}\n`)
245
243
  const complain = (s) => stderr.write(`h2kext-publish: ${redact(s)}\n`)
246
244
 
@@ -264,40 +262,42 @@ export async function main(argv, { env = process.env, fetch = globalThis.fetch,
264
262
  }
265
263
  for (let i = 0; i < argv.length; i++) {
266
264
  const arg = argv[i]
267
- if (arg === '--channel') channel = valueOf(i++)
268
- else if (arg === '--notes') notes = valueOf(i++)
269
- else if (arg === '--catalog') catalog = valueOf(i++)
270
- else if (arg === '--force-name') forceName = true
271
- else if (arg === '--token') throw new PublishError(`there is no --token option; set ${TOKEN_ENV} instead`, EXIT_USAGE)
272
- else if (arg.startsWith('-')) throw new PublishError(`unknown option '${arg}'`, EXIT_USAGE)
265
+ if (arg === "--channel") channel = valueOf(i++)
266
+ else if (arg === "--notes") notes = valueOf(i++)
267
+ else if (arg === "--catalog") catalog = valueOf(i++)
268
+ else if (arg === "--force-name") forceName = true
269
+ else if (arg === "--token") throw new PublishError(`there is no --token option; set ${TOKEN_ENV} instead`, EXIT_USAGE)
270
+ else if (arg.startsWith("-")) throw new PublishError(`unknown option '${arg}'`, EXIT_USAGE)
273
271
  else if (source === undefined) source = arg
274
272
  }
275
273
 
276
- if (!source) throw new PublishError('name the bundle or directory to publish', EXIT_USAGE)
274
+ if (!source) throw new PublishError("name the bundle or directory to publish", EXIT_USAGE)
277
275
  channel ??= DEFAULT_CHANNEL
278
276
  if (!CHANNELS.includes(channel)) {
279
- throw new PublishError(`--channel must be one of ${CHANNELS.join(', ')}${channel ? `, not '${channel}'` : ''}`, EXIT_USAGE)
277
+ throw new PublishError(`--channel must be one of ${CHANNELS.join(", ")}${channel ? `, not '${channel}'` : ""}`, EXIT_USAGE)
280
278
  }
281
279
  if (!token) {
282
280
  throw new PublishError(`no token — set ${TOKEN_ENV} (get one at ${catalog}/publish)`, EXIT_USAGE)
283
281
  }
284
282
  try {
285
- catalog = new URL(catalog).href.replace(/\/$/, '')
283
+ catalog = new URL(catalog).href.replace(/\/$/, "")
286
284
  } catch {
287
285
  throw new PublishError(`--catalog must be a URL, not '${catalog}'`, EXIT_USAGE)
288
286
  }
289
287
 
290
288
  const bytes = await loadBundle(source, { forceName })
291
289
  if (bytes.length > MAX_BUNDLE_BYTES) {
292
- throw new PublishError(`${source} is ${bytes.length} bytes; the catalog takes at most 16 MiB (${MAX_BUNDLE_BYTES} bytes), because the app downloads no more than that`)
290
+ throw new PublishError(
291
+ `${source} is ${bytes.length} bytes; the catalog takes at most 16 MiB (${MAX_BUNDLE_BYTES} bytes), because the app downloads no more than that`,
292
+ )
293
293
  }
294
294
  const manifest = readManifestFromBundle(bytes)
295
- for (const field of ['key', 'version']) {
296
- if (typeof manifest[field] !== 'string' || manifest[field].trim() === '') {
295
+ for (const field of ["key", "version"]) {
296
+ if (typeof manifest[field] !== "string" || manifest[field].trim() === "") {
297
297
  throw new PublishError(`${MANIFEST_ENTRY} inside the bundle has no '${field}'`)
298
298
  }
299
299
  }
300
- say(`publishing ${manifest.key} ${manifest.version}${manifest.name ? ` — ${manifest.name}` : ''} → ${channel} at ${catalog}`)
300
+ say(`publishing ${manifest.key} ${manifest.version}${manifest.name ? ` — ${manifest.name}` : ""} → ${channel} at ${catalog}`)
301
301
 
302
302
  const result = await publish({ bytes, manifest, channel, catalog, token }, { fetch })
303
303
  say(`published ${result.key} ${result.version} → ${result.channel} (${STATUS_WORDS[result.status] ?? result.status})`)
package/h2kext.mjs CHANGED
@@ -16,16 +16,25 @@
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
27
27
  /// meeting a newer bundle says so instead of failing at a call site.
28
- export const H2KEXT_API_VERSION = 1
28
+ ///
29
+ /// Raised when a bundle using something new could not work at all on an app
30
+ /// without it — not for every addition. 2 adds `host.webSocket` and the
31
+ /// manifest's `webSockets` grant.
32
+ export const H2KEXT_API_VERSION = 2
33
+
34
+ /// The first API with `host.webSocket`. A manifest declaring `webSockets`
35
+ /// under an older one is refused: an older app would install it and fail only
36
+ /// when it tried to connect.
37
+ export const WEB_SOCKETS_API_VERSION = 2
29
38
 
30
39
  /// `<prefix>-<name>`, all lowercase. The shape is what the kernel's
31
40
  /// `bundleMayDefine` prefix rule can reason about; the PREFIX is what keeps two
@@ -36,7 +45,7 @@ export const KEY_PATTERN = /^[a-z0-9]+-[a-z0-9-]*[a-z0-9]$/
36
45
  /// bundle unless it is one of ours: there is no signing yet, so anyone could
37
46
  /// claim it, and a key is what the app shows when it says where a hook came
38
47
  /// from.
39
- export const RESERVED_PREFIX = 'ham2k'
48
+ export const RESERVED_PREFIX = "ham2k"
40
49
 
41
50
  /// Whether [prefix] is a real amateur callsign — the namespace every author of
42
51
  /// this software already has, globally unique, and free.
@@ -66,14 +75,14 @@ export const SAFE_KEY_PATTERN = /^[a-z0-9][a-z0-9-]*$/
66
75
  /// app is where the rule bites, refusing a reserved or colliding key at
67
76
  /// install.
68
77
  export function keyProblem(key, { forceName = false } = {}) {
69
- if (typeof key !== 'string' || !SAFE_KEY_PATTERN.test(key)) {
78
+ if (typeof key !== "string" || !SAFE_KEY_PATTERN.test(key)) {
70
79
  return `key '${key}' must be lowercase, and contain only letters, digits and hyphens`
71
80
  }
72
81
  if (forceName) return null
73
82
  if (!KEY_PATTERN.test(key)) {
74
83
  return `key '${key}' must be '<callsign>-<name>' (e.g. ki2d-my-clock)`
75
84
  }
76
- const prefix = key.slice(0, key.indexOf('-'))
85
+ const prefix = key.slice(0, key.indexOf("-"))
77
86
  if (prefix === RESERVED_PREFIX) {
78
87
  return `key '${key}' uses the '${RESERVED_PREFIX}-' prefix, which is reserved for Ham2K's own extensions`
79
88
  }
@@ -84,9 +93,9 @@ export function keyProblem(key, { forceName = false } = {}) {
84
93
  }
85
94
 
86
95
  /// 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/'
96
+ export const MANIFEST_ENTRY = "manifest.json"
97
+ export const BUNDLE_ENTRY = "index.js"
98
+ export const ASSETS_PREFIX = "assets/"
90
99
 
91
100
  /// The libraries the extension host carries, per API version, so a bundle does
92
101
  /// not have to ship its own copy of each. The kernel holds one instance and
@@ -103,20 +112,21 @@ export const ASSETS_PREFIX = 'assets/'
103
112
  /// live once this file is published on its own.
104
113
  export const SHARED_MODULES_BY_API = {
105
114
  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',
115
+ "@ham2k/lib-callsigns",
116
+ "@ham2k/lib-country-files",
117
+ "@ham2k/lib-cqmag-data",
118
+ "@ham2k/lib-dxcc-data",
119
+ "@ham2k/lib-format-tools",
120
+ "@ham2k/lib-geo-tools",
121
+ "@ham2k/lib-operation-data",
122
+ "@ham2k/lib-qson-adif",
123
+ "@ham2k/lib-qson-cabrillo",
124
+ "@ham2k/lib-qson-tools",
125
+ "i18next",
126
+ "liquidjs",
118
127
  ],
119
128
  }
129
+ SHARED_MODULES_BY_API[2] = SHARED_MODULES_BY_API[1]
120
130
 
121
131
  /// What a scaffolded extension declares on day one: the eight modules the SDK
122
132
  /// barrel itself reaches, so a project that has written no code yet still
@@ -136,14 +146,14 @@ export const SHARED_MODULES_BY_API = {
136
146
  /// actually holds, so a major bump there fails a build rather than shipping a
137
147
  /// scaffold that cannot install.
138
148
  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',
149
+ "@ham2k/lib-callsigns": "^1.0.0",
150
+ "@ham2k/lib-country-files": "^1.0.0",
151
+ "@ham2k/lib-dxcc-data": "^1.0.0",
152
+ "@ham2k/lib-format-tools": "^1.0.0",
153
+ "@ham2k/lib-geo-tools": "^1.0.0",
154
+ "@ham2k/lib-operation-data": "^1.0.0",
155
+ i18next: "^23.0.0",
156
+ liquidjs: "^10.0.0",
147
157
  }
148
158
 
149
159
  /// What the host offers a bundle declaring [api].
@@ -157,7 +167,7 @@ export class PackError extends Error {}
157
167
  /// the operator's own values against, so a value outside it can never match
158
168
  /// anyone. Continents are cty.dat's column-4 codes; the regions are the three
159
169
  /// the ITU Radio Regulations define.
160
- export const GEO_CONTINENTS = ['AF', 'AS', 'EU', 'NA', 'OC', 'SA', 'AN']
170
+ export const GEO_CONTINENTS = ["AF", "AS", "EU", "NA", "OC", "SA", "AN"]
161
171
  export const GEO_ITU_REGIONS = [1, 2, 3]
162
172
 
163
173
  /// What `relevance.interests` may name: a mode, a band group, or a style of
@@ -167,13 +177,9 @@ export const GEO_ITU_REGIONS = [1, 2, 3]
167
177
  /// asks for.
168
178
  ///
169
179
  /// Growing the list is how a new interest arrives. Guessing is not.
170
- export const RELEVANCE_INTERESTS = [
171
- 'cw', 'phone', 'digital',
172
- 'hf', 'vhf', 'uhf', 'microwave',
173
- 'satellite', 'portable', 'qrp',
174
- ]
180
+ export const RELEVANCE_INTERESTS = ["cw", "phone", "digital", "hf", "vhf", "uhf", "microwave", "satellite", "portable", "qrp"]
175
181
 
176
- const RELEVANCE_KEYS = ['entities', 'countries', 'continents', 'ituRegions', 'dates', 'interests']
182
+ const RELEVANCE_KEYS = ["entities", "countries", "continents", "ituRegions", "dates", "interests"]
177
183
 
178
184
  /// Everything wrong with a manifest's `relevance`, as a list — empty for a
179
185
  /// valid one, and for an absent one, which means worldwide and undated.
@@ -188,13 +194,13 @@ const RELEVANCE_KEYS = ['entities', 'countries', 'continents', 'ituRegions', 'da
188
194
  /// rules are the format's, not the repo's, and this module is the format.
189
195
  export function validateRelevance(relevance) {
190
196
  if (relevance === undefined) return []
191
- if (typeof relevance !== 'object' || relevance === null || Array.isArray(relevance)) {
192
- return [`'relevance' must be an object of ${RELEVANCE_KEYS.join(' / ')}`]
197
+ if (typeof relevance !== "object" || relevance === null || Array.isArray(relevance)) {
198
+ return [`'relevance' must be an object of ${RELEVANCE_KEYS.join(" / ")}`]
193
199
  }
194
200
  const problems = []
195
201
  for (const key of Object.keys(relevance)) {
196
202
  if (!RELEVANCE_KEYS.includes(key)) {
197
- problems.push(`'relevance' has an unknown key '${key}' — only ${RELEVANCE_KEYS.join(', ')} are read`)
203
+ problems.push(`'relevance' has an unknown key '${key}' — only ${RELEVANCE_KEYS.join(", ")} are read`)
198
204
  }
199
205
  }
200
206
  for (const key of RELEVANCE_KEYS) {
@@ -213,25 +219,21 @@ export function validateRelevance(relevance) {
213
219
  }
214
220
 
215
221
  function relevanceValueProblem(key, value) {
216
- if (key === 'ituRegions') {
217
- return GEO_ITU_REGIONS.includes(value) ? null : `is not an ITU region (${GEO_ITU_REGIONS.join(', ')})`
222
+ if (key === "ituRegions") {
223
+ return GEO_ITU_REGIONS.includes(value) ? null : `is not an ITU region (${GEO_ITU_REGIONS.join(", ")})`
218
224
  }
219
- if (typeof value !== 'string' || value.trim() === '') return 'is not a non-empty string'
220
- if (key === 'entities') {
221
- return /^\*?[A-Z0-9/]+$/.test(value.toUpperCase())
222
- ? null
223
- : "is not a DXCC entity prefix (letters, digits and '/', with an optional leading '*')"
225
+ if (typeof value !== "string" || value.trim() === "") return "is not a non-empty string"
226
+ if (key === "entities") {
227
+ return /^\*?[A-Z0-9/]+$/.test(value.toUpperCase()) ? null : "is not a DXCC entity prefix (letters, digits and '/', with an optional leading '*')"
224
228
  }
225
- if (key === 'countries') {
226
- return /^[a-z]{2}$/.test(value.toLowerCase()) ? null : 'is not a two-letter ISO country code'
229
+ if (key === "countries") {
230
+ return /^[a-z]{2}$/.test(value.toLowerCase()) ? null : "is not a two-letter ISO country code"
227
231
  }
228
- if (key === 'dates') return calendarDateProblem(value)
229
- if (key === 'interests') {
230
- return RELEVANCE_INTERESTS.includes(value)
231
- ? null
232
- : `is not an interest (${RELEVANCE_INTERESTS.join(' ')})`
232
+ if (key === "dates") return calendarDateProblem(value)
233
+ if (key === "interests") {
234
+ return RELEVANCE_INTERESTS.includes(value) ? null : `is not an interest (${RELEVANCE_INTERESTS.join(" ")})`
233
235
  }
234
- return GEO_CONTINENTS.includes(value.toUpperCase()) ? null : `is not a continent (${GEO_CONTINENTS.join(' ')})`
236
+ return GEO_CONTINENTS.includes(value.toUpperCase()) ? null : `is not a continent (${GEO_CONTINENTS.join(" ")})`
235
237
  }
236
238
 
237
239
  /// Why [value] is not a UTC calendar day, or null.
@@ -245,13 +247,11 @@ function relevanceValueProblem(key, value) {
245
247
  /// would fail the round trip here and pass in Dart — a disagreement between
246
248
  /// the two on an input neither should be accepting anyway.
247
249
  function calendarDateProblem(value) {
248
- if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return 'is not a YYYY-MM-DD date'
249
- const [year, month, day] = value.split('-').map(Number)
250
- if (year < 1000) return 'is not a year an event could fall in'
250
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return "is not a YYYY-MM-DD date"
251
+ const [year, month, day] = value.split("-").map(Number)
252
+ if (year < 1000) return "is not a year an event could fall in"
251
253
  const at = new Date(Date.UTC(year, month - 1, day))
252
- return at.getUTCFullYear() === year && at.getUTCMonth() === month - 1 && at.getUTCDate() === day
253
- ? null
254
- : 'is not a day that exists'
254
+ return at.getUTCFullYear() === year && at.getUTCMonth() === month - 1 && at.getUTCDate() === day ? null : "is not a day that exists"
255
255
  }
256
256
 
257
257
  /// Everything wrong with a candidate bundle, as a list — an author fixing one
@@ -260,12 +260,12 @@ export function validateManifest(manifest, { source = MANIFEST_ENTRY, forceName
260
260
  const problems = []
261
261
  const say = (m) => problems.push(`${source}: ${m}`)
262
262
 
263
- if (typeof manifest !== 'object' || manifest === null || Array.isArray(manifest)) {
263
+ if (typeof manifest !== "object" || manifest === null || Array.isArray(manifest)) {
264
264
  return [`${source}: must be a JSON object`]
265
265
  }
266
266
 
267
- for (const field of ['key', 'name', 'version', 'description']) {
268
- if (typeof manifest[field] !== 'string' || manifest[field].trim() === '') {
267
+ for (const field of ["key", "name", "version", "description"]) {
268
+ if (typeof manifest[field] !== "string" || manifest[field].trim() === "") {
269
269
  say(`missing required field '${field}'`)
270
270
  }
271
271
  }
@@ -283,24 +283,32 @@ export function validateManifest(manifest, { source = MANIFEST_ENTRY, forceName
283
283
  say(`'api' is ${manifest.api}, but this packer only knows ${H2KEXT_API_VERSION}`)
284
284
  }
285
285
 
286
+ if (manifest.webSockets !== undefined) {
287
+ if (!Array.isArray(manifest.webSockets) || manifest.webSockets.some((host) => typeof host !== "string")) {
288
+ say("'webSockets' must be a list of hosts")
289
+ } else if (manifest.webSockets.length && Number.isInteger(manifest.api) && manifest.api < WEB_SOCKETS_API_VERSION) {
290
+ say(`'webSockets' needs 'api' ${WEB_SOCKETS_API_VERSION} or later — an app older than that would install this and fail only when it connects`)
291
+ }
292
+ }
293
+
286
294
  // An extension with no category is CORE to the host: always enabled, and
287
295
  // never listed in the Extensions panel. That is a status the app grants its
288
296
  // own extensions, not one a distributed bundle may claim for itself — a user
289
297
  // could neither see it nor turn it off.
290
- if (typeof manifest.category !== 'string' || manifest.category.trim() === '') {
298
+ if (typeof manifest.category !== "string" || manifest.category.trim() === "") {
291
299
  say("missing 'category' — a bundle with none is treated as core: always on, and invisible in the Extensions panel")
292
300
  }
293
301
 
294
302
  if (manifest.sharedDependencies !== undefined) {
295
303
  const declared = manifest.sharedDependencies
296
- if (typeof declared !== 'object' || declared === null || Array.isArray(declared)) {
304
+ if (typeof declared !== "object" || declared === null || Array.isArray(declared)) {
297
305
  say("'sharedDependencies' must be an object of package name to version range")
298
306
  } else {
299
307
  const offered = sharedModulesFor(manifest.api)
300
308
  for (const [pkg, range] of Object.entries(declared)) {
301
309
  if (!offered.includes(pkg)) {
302
310
  say(`'sharedDependencies' names '${pkg}', which the host does not carry — bundle it into your build instead`)
303
- } else if (typeof range !== 'string' || range.trim() === '') {
311
+ } else if (typeof range !== "string" || range.trim() === "") {
304
312
  say(`'sharedDependencies' entry '${pkg}' needs a version range, e.g. "^1.0.0"`)
305
313
  }
306
314
  }
@@ -361,7 +369,7 @@ export function findSharedModuleReferences(source) {
361
369
  export async function readBundleDirectory(dir, { forceName = false } = {}) {
362
370
  let manifestRaw
363
371
  try {
364
- manifestRaw = await readFile(join(dir, MANIFEST_ENTRY), 'utf8')
372
+ manifestRaw = await readFile(join(dir, MANIFEST_ENTRY), "utf8")
365
373
  } catch {
366
374
  throw new PackError(`no ${MANIFEST_ENTRY} in ${dir}`)
367
375
  }
@@ -377,11 +385,11 @@ export async function readBundleDirectory(dir, { forceName = false } = {}) {
377
385
 
378
386
  let bundleSource
379
387
  try {
380
- bundleSource = await readFile(join(dir, BUNDLE_ENTRY), 'utf8')
388
+ bundleSource = await readFile(join(dir, BUNDLE_ENTRY), "utf8")
381
389
  } catch {
382
390
  throw new PackError(`no ${BUNDLE_ENTRY} in ${dir} — build your extension first; this packages, it does not compile`)
383
391
  }
384
- if (bundleSource.trim() === '') problems.push(`${BUNDLE_ENTRY}: is empty`)
392
+ if (bundleSource.trim() === "") problems.push(`${BUNDLE_ENTRY}: is empty`)
385
393
 
386
394
  // Using the host's shared libraries is allowed, and is much the better
387
395
  // option — inlining them costs about ten times the bundle size. What is NOT
@@ -393,12 +401,15 @@ export async function readBundleDirectory(dir, { forceName = false } = {}) {
393
401
  const undeclared = findSharedModuleReferences(bundleSource).filter((pkg) => !(pkg in declaredShared))
394
402
  if (undeclared.length) {
395
403
  problems.push(
396
- `${BUNDLE_ENTRY}: uses the host's shared modules without declaring them (${undeclared.join(', ')}). ` +
404
+ `${BUNDLE_ENTRY}: uses the host's shared modules without declaring them (${undeclared.join(", ")}). ` +
397
405
  `Add them to 'sharedDependencies' in ${MANIFEST_ENTRY} with the version range you work with, ` +
398
- 'e.g. {"' + undeclared[0] + '": "^1.0.0"}, so the host can refuse to load this against a version you never tested.')
406
+ 'e.g. {"' +
407
+ undeclared[0] +
408
+ '": "^1.0.0"}, so the host can refuse to load this against a version you never tested.',
409
+ )
399
410
  }
400
411
 
401
- if (problems.length) throw new PackError(problems.join('\n'))
412
+ if (problems.length) throw new PackError(problems.join("\n"))
402
413
 
403
414
  const files = [
404
415
  { entry: MANIFEST_ENTRY, path: join(dir, MANIFEST_ENTRY) },
@@ -419,7 +430,7 @@ async function collectAssets(dir) {
419
430
  // No assets/ is ordinary. Anything else — unreadable, an I/O error, a
420
431
  // broken symlink — is not, and swallowing it ships a bundle silently
421
432
  // missing files the author believes are in it.
422
- if (e.code === 'ENOENT') return []
433
+ if (e.code === "ENOENT") return []
423
434
  throw new PackError(`could not read ${root}: ${e.message}`)
424
435
  }
425
436
 
@@ -446,9 +457,9 @@ export function writeBundle(files, outPath) {
446
457
  for (const file of files) zip.addFile(file.path, file.entry)
447
458
  const out = createWriteStream(outPath)
448
459
  zip.outputStream.pipe(out)
449
- out.on('close', () => resolve(outPath))
450
- out.on('error', reject)
451
- zip.outputStream.on('error', reject)
460
+ out.on("close", () => resolve(outPath))
461
+ out.on("error", reject)
462
+ zip.outputStream.on("error", reject)
452
463
  zip.end()
453
464
  })
454
465
  }
package/package.json CHANGED
@@ -1,8 +1,15 @@
1
1
  {
2
2
  "name": "@ham2k/extension-tools",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Build and package extensions for the Ham2K Logger",
5
- "keywords": ["ham2k", "ham radio", "amateur radio", "extension", "esbuild", "h2kext"],
5
+ "keywords": [
6
+ "ham2k",
7
+ "ham radio",
8
+ "amateur radio",
9
+ "extension",
10
+ "esbuild",
11
+ "h2kext"
12
+ ],
6
13
  "license": "MIT",
7
14
  "author": "Sebastian Delmont <sd@ham2k.com>",
8
15
  "homepage": "https://ham2k.com",