@ham2k/extension-tools 0.1.0 → 0.2.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
@@ -6,6 +6,27 @@ 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
+ ## Starting one
10
+
11
+ ```sh
12
+ npx h2kext-init w7abc-notes
13
+ ```
14
+
15
+ Writes a whole small extension — manifest, a panel that says hello, the build
16
+ script, a tsconfig — into the working directory, and refuses rather than
17
+ overwrite anything already there. An existing `package.json` is added to, never
18
+ replaced.
19
+
20
+ It takes the key rather than defaulting one: a placeholder like
21
+ `n0call-my-extension` validates, so it would ship, which is the one outcome the
22
+ naming convention exists to stop.
23
+
24
+ The `AGENTS.md` it writes points at `@ham2k/extension-sdk`'s own — the hook
25
+ reference, the sandbox's limits, and what to read for what. That matters most
26
+ if something other than you is writing the code: an agent reads a project's
27
+ `AGENTS.md` unprompted, and otherwise never learns the reference is sitting in
28
+ `node_modules`.
29
+
9
30
  ## Building
10
31
 
11
32
  ```js
package/h2kext-build.mjs CHANGED
@@ -25,6 +25,22 @@ import { dirname, join, resolve } from 'node:path'
25
25
 
26
26
  import { H2KEXT_API_VERSION, MANIFEST_ENTRY, sharedModulesFor } from './h2kext.mjs'
27
27
 
28
+ /// What an extension's own `build.mjs` says, for everything that has to write
29
+ /// one: `h2kext-init`'s scaffold, and the samples the SDK ships.
30
+ ///
31
+ /// Here rather than in either of them because two copies of a file whose whole
32
+ /// job is to be correct on someone else's machine would drift, and neither
33
+ /// copy is exercised by this repo's own build — the extensions here are built
34
+ /// through `build-config.mjs`, which takes a different path into the same
35
+ /// preset.
36
+ export const EXAMPLE_BUILD_SCRIPT = `import { build } from 'esbuild'
37
+ import { buildExtension } from '@ham2k/extension-tools'
38
+
39
+ await buildExtension(build, { dir: import.meta.dirname })
40
+
41
+ console.log('built build/index.js')
42
+ `
43
+
28
44
  export class BuildError extends Error {}
29
45
 
30
46
  /// Rewrites imports of the libraries the host carries into lookups on the
@@ -0,0 +1,276 @@
1
+ #!/usr/bin/env node
2
+ // Copyright ©️ 2026 Sebastian Delmont <sd@ham2k.com>
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ //
5
+ // Scaffolds an extension project (HALO-452, docs/extensions/distribution.md).
6
+ //
7
+ // npx h2kext-init <yourcallsign>-<name>
8
+ //
9
+ // Writes the smallest whole extension — a panel that says hello — plus the
10
+ // `AGENTS.md` that points whatever is writing the code at the reference inside
11
+ // `@ham2k/extension-sdk`. That one line is the whole reason this command
12
+ // exists: an agent reads a project's AGENTS.md unprompted, and otherwise never
13
+ // learns the docs are sitting in node_modules and guesses the manifest instead.
14
+ //
15
+ // It takes the key rather than defaulting one. A placeholder like
16
+ // `n0call-my-extension` VALIDATES — N0CALL is a well-formed callsign — so it
17
+ // would ship, which is the one outcome the naming convention exists to stop.
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'
23
+
24
+ import { EXAMPLE_BUILD_SCRIPT } from './h2kext-build.mjs'
25
+ import { H2KEXT_API_VERSION, SCAFFOLD_SHARED_DEPENDENCIES, keyProblem } from './h2kext.mjs'
26
+
27
+ const args = process.argv.slice(2)
28
+
29
+ if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
30
+ console.log(`Scaffold a Ham2K Logger extension.
31
+
32
+ h2kext-init <key> [--dir <path>] [--name "<display name>"]
33
+
34
+ The key is '<yourcallsign>-<name>' — e.g. ki2d-my-clock. The callsign is a
35
+ namespace you already have, globally unique, so two authors who never met
36
+ cannot collide. It also namespaces your extension's storage.
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.`)
42
+ process.exit(args.length === 0 ? 1 : 0)
43
+ }
44
+
45
+ let key
46
+ let dir = process.cwd()
47
+ let displayName
48
+ for (let i = 0; i < args.length; i++) {
49
+ // `resolve('')` is the working directory, so a bare `--dir` would scaffold
50
+ // 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]
54
+ }
55
+
56
+ const problem = keyProblem(key)
57
+ if (problem) {
58
+ console.error(`h2kext-init: ${problem}`)
59
+ process.exit(1)
60
+ }
61
+
62
+ if (dir === undefined) {
63
+ console.error('h2kext-init: --dir needs the directory to scaffold into')
64
+ process.exit(1)
65
+ }
66
+
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(' ')
70
+
71
+ const here = dirname(fileURLToPath(import.meta.url))
72
+ const { version: toolsVersion } = JSON.parse(await readFile(join(here, 'package.json'), 'utf8'))
73
+
74
+ /// The SDK is a SEPARATE package on its own version line, so this cannot be
75
+ /// answered with the version of the tools that happen to be running: a
76
+ /// tools-only patch would scaffold `^0.1.1` of an SDK that has no such
77
+ /// release, and `npm install` — the very next line this command prints —
78
+ /// fails with ETARGET. The other direction is worse, because it is quiet: an
79
+ /// SDK a minor ahead, and the author installs the old one while reading the
80
+ /// new one's docs.
81
+ ///
82
+ /// Resolved from what is actually installed where the project is being made,
83
+ /// which is right whenever the documented `npm i -D @ham2k/extension-tools`
84
+ /// route pulled the SDK in too. With nothing installed there is nothing to
85
+ /// read, and `latest` is the honest answer — the author's `npm install` then
86
+ /// fetches whatever the current SDK is, which is what a new project wants.
87
+ function sdkRange(projectDir) {
88
+ // READ the file; do not `require` it. A package's own package.json is not a
89
+ // subpath its `exports` map declares, and Node refuses an undeclared subpath
90
+ // — `createRequire(...)('@ham2k/extension-sdk/package.json')` throws
91
+ // ERR_PACKAGE_PATH_NOT_EXPORTED for a package sitting right there in
92
+ // node_modules, so every scaffold would silently fall through to `latest`.
93
+ for (const start of [projectDir, here]) {
94
+ for (let dir = start; ; dir = dirname(dir)) {
95
+ try {
96
+ const path = join(dir, 'node_modules/@ham2k/extension-sdk/package.json')
97
+ const { version } = JSON.parse(readFileSync(path, 'utf8'))
98
+ if (version) return `^${version}`
99
+ } catch {
100
+ // Not here; walk up to the next node_modules.
101
+ }
102
+ if (dirname(dir) === dir) break
103
+ }
104
+ }
105
+ return 'latest'
106
+ }
107
+
108
+ /// What the project's package.json has to say for the build, the packer and
109
+ /// the typecheck to run.
110
+ const projectPackage = {
111
+ name: key,
112
+ version: '0.1.0',
113
+ private: true,
114
+ type: 'module',
115
+ scripts: {
116
+ build: 'node build.mjs',
117
+ pack: 'h2kext-pack build',
118
+ typecheck: 'tsc --noEmit',
119
+ },
120
+ dependencies: {
121
+ '@ham2k/extension-sdk': sdkRange(dir),
122
+ },
123
+ devDependencies: {
124
+ '@ham2k/extension-tools': `^${toolsVersion}`,
125
+ esbuild: '^0.25.0',
126
+ typescript: '^5.6.0',
127
+ },
128
+ }
129
+
130
+ 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.
149
+ //
150
+ // Everything here is the minimum: one hook, one descriptor, one render. See
151
+ // node_modules/@ham2k/extension-sdk/AGENTS.md for what to read next.
152
+
153
+ import { defineExtension } from '@ham2k/extension-sdk'
154
+ import type { HookContext, PanelContent, PanelDescriptor, PanelHook, PanelRenderArgs } from '@ham2k/extension-sdk'
155
+
156
+ import manifest from '../manifest.json'
157
+
158
+ const HelloPanel: PanelHook = {
159
+ /// What the operator sees when choosing a panel to place.
160
+ 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' }]
162
+ },
163
+
164
+ /// What the pane shows. A panel returns CONTENT, never widgets — which is
165
+ /// what keeps extensions out of the app's UI toolkit.
166
+ ///
167
+ /// The method is \`render\`, not \`renderPanel\`: the host routes to the hook by
168
+ /// its registered key and then calls this by name, so a near-miss is a panel
169
+ /// that installs, activates, and then renders nothing.
170
+ async render(_args: PanelRenderArgs, _ctx: HookContext): Promise<PanelContent> {
171
+ return { kind: 'markdown', content: '# Hello, World!' }
172
+ },
173
+ }
174
+
175
+ defineExtension({
176
+ ...manifest,
177
+ onActivation({ registerHook }) {
178
+ registerHook('panel', { key: '${key}', hook: HelloPanel })
179
+ },
180
+ })
181
+ `,
182
+
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`,
196
+
197
+ // The whole point of the file. Claude Code and its siblings read a project's
198
+ // AGENTS.md before they touch anything, so this is what carries the hook
199
+ // reference, the sandbox's limits and the manifest's shape into the session
200
+ // — none of which anyone would think to go looking for in node_modules.
201
+ 'AGENTS.md': `# ${name}
202
+
203
+ An extension for the Ham2K Logger. How to write one, what the hooks are, and
204
+ what the sandbox will and will not let you do:
205
+
206
+ @node_modules/@ham2k/extension-sdk/AGENTS.md
207
+ `,
208
+
209
+ '.gitignore': 'node_modules/\nbuild/\n*.h2kext\n',
210
+ }
211
+
212
+ // Reported together rather than one per run: a scaffold that stops at the
213
+ // first collision has already written half a project, and the second attempt
214
+ // then collides with its own output.
215
+ const packagePath = join(dir, 'package.json')
216
+ const currentPackage = await readFile(packagePath, 'utf8').then(
217
+ (text) => {
218
+ try {
219
+ return JSON.parse(text)
220
+ } catch (e) {
221
+ // Read BEFORE anything is written: parsed after the write loop, this
222
+ // threw a raw SyntaxError over six files already on disk, and the retry
223
+ // then collided with them.
224
+ console.error(`h2kext-init: ${packagePath} is not readable JSON (${e.message}) — nothing written`)
225
+ process.exit(1)
226
+ }
227
+ },
228
+ () => ({}))
229
+
230
+ // `null`, `[]` and `"text"` all parse, and none of them is something to merge
231
+ // into — reached at the end, each is a raw TypeError over six written files.
232
+ if (currentPackage === null || typeof currentPackage !== 'object' || Array.isArray(currentPackage)) {
233
+ console.error(`h2kext-init: ${packagePath} is not a package description — nothing written`)
234
+ process.exit(1)
235
+ }
236
+
237
+ // `access`, not a read: a directory where a file should go, or one this
238
+ // process cannot read, is something in the way — treated as absent, it becomes
239
+ // a write loop that fails half-way through.
240
+ const existing = []
241
+ for (const path of Object.keys(files)) {
242
+ if (await access(join(dir, path)).then(() => true, () => false)) existing.push(path)
243
+ }
244
+ if (existing.length) {
245
+ const where = relative(process.cwd(), dir)
246
+ console.error(
247
+ `h2kext-init: ${where.startsWith('..') ? dir : where || '.'} already holds ${existing.join(', ')} — ` +
248
+ 'nothing written. Scaffold into an empty directory with --dir.')
249
+ process.exit(1)
250
+ }
251
+
252
+ for (const [path, content] of Object.entries(files)) {
253
+ await mkdir(dirname(join(dir, path)), { recursive: true })
254
+ await writeFile(join(dir, path), content)
255
+ }
256
+
257
+ // `package.json` is the one file that gets merged rather than refused, because
258
+ // the obvious way to reach this command already made one:
259
+ // `npm i -D @ham2k/extension-tools && npx h2kext-init …`. Refusing there would
260
+ // turn the documented path into the broken one.
261
+ //
262
+ // Filled in key by key, never overwritten: what the author already declared —
263
+ // their own name for the project, a version of esbuild they chose — is theirs.
264
+ for (const [field, value] of Object.entries(projectPackage)) {
265
+ if (typeof value !== 'object') currentPackage[field] ??= value
266
+ else for (const [name, entry] of Object.entries(value)) (currentPackage[field] ??= {})[name] ??= entry
267
+ }
268
+ await writeFile(packagePath, `${JSON.stringify(currentPackage, null, 2)}\n`)
269
+
270
+ console.log(`${key} — ${name}
271
+
272
+ ${[...Object.keys(files), 'package.json'].join('\n ')}
273
+
274
+ Next:
275
+ npm install
276
+ npm run build && npm run pack`)
package/h2kext.mjs CHANGED
@@ -118,6 +118,34 @@ export const SHARED_MODULES_BY_API = {
118
118
  ],
119
119
  }
120
120
 
121
+ /// What a scaffolded extension declares on day one: the eight modules the SDK
122
+ /// barrel itself reaches, so a project that has written no code yet still
123
+ /// builds. Importing anything from `@ham2k/extension-sdk` pulls them in —
124
+ /// `dxcc`, `i18n` and `templates` are on the barrel — which makes declaring
125
+ /// them a starting condition rather than a choice the author makes later.
126
+ ///
127
+ /// Ranges at the MAJOR, which is the grain the check is really about and the
128
+ /// one the docs model. They are a starting point the author owns from the
129
+ /// moment it is written: narrowing one to the version actually tested against
130
+ /// is a normal thing to do before publishing.
131
+ ///
132
+ /// Versions here and nowhere else in this file. `SHARED_MODULES_BY_API` is the
133
+ /// host's OFFER, which must stay version-free; this is one author's opening
134
+ /// DECLARATION, which the format requires to carry ranges. A test in the repo
135
+ /// asserts every range here is satisfied by the versions this build's host
136
+ /// actually holds, so a major bump there fails a build rather than shipping a
137
+ /// scaffold that cannot install.
138
+ export const SCAFFOLD_SHARED_DEPENDENCIES = {
139
+ '@ham2k/lib-callsigns': '^1.0.0',
140
+ '@ham2k/lib-country-files': '^1.0.0',
141
+ '@ham2k/lib-dxcc-data': '^1.0.0',
142
+ '@ham2k/lib-format-tools': '^1.0.0',
143
+ '@ham2k/lib-geo-tools': '^1.0.0',
144
+ '@ham2k/lib-operation-data': '^1.0.0',
145
+ 'i18next': '^23.0.0',
146
+ 'liquidjs': '^10.0.0',
147
+ }
148
+
121
149
  /// What the host offers a bundle declaring [api].
122
150
  export function sharedModulesFor(api) {
123
151
  return SHARED_MODULES_BY_API[api] ?? SHARED_MODULES_BY_API[H2KEXT_API_VERSION]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ham2k/extension-tools",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Build and package extensions for the Ham2K Logger",
5
5
  "keywords": ["ham2k", "ham radio", "amateur radio", "extension", "esbuild", "h2kext"],
6
6
  "license": "MPL-2.0",
@@ -17,11 +17,13 @@
17
17
  "./format": "./h2kext.mjs"
18
18
  },
19
19
  "bin": {
20
+ "h2kext-init": "./h2kext-init.mjs",
20
21
  "h2kext-pack": "./h2kext-pack.mjs"
21
22
  },
22
23
  "files": [
23
24
  "h2kext.mjs",
24
25
  "h2kext-build.mjs",
26
+ "h2kext-init.mjs",
25
27
  "h2kext-pack.mjs",
26
28
  "README.md"
27
29
  ],