@ham2k/extension-tools 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/h2kext-init.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  // Copyright ©️ 2026 Sebastian Delmont <sd@ham2k.com>
3
- // SPDX-License-Identifier: MPL-2.0
3
+ // SPDX-License-Identifier: MIT
4
4
  //
5
5
  // Scaffolds an extension project (HALO-452, docs/extensions/distribution.md).
6
6
  //
@@ -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
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  // Copyright ©️ 2026 Sebastian Delmont <sd@ham2k.com>
3
- // SPDX-License-Identifier: MPL-2.0
3
+ // SPDX-License-Identifier: MIT
4
4
  //
5
5
  // Packages an extension into a `.h2kext` a user can install
6
6
  // (HALO-422, docs/extensions/distribution.md).
@@ -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,11 @@ 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
66
  }
67
67
  } catch (e) {
68
68
  if (e instanceof PackError) {