@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 +21 -0
- package/h2kext-build.mjs +16 -0
- package/h2kext-init.mjs +276 -0
- package/h2kext.mjs +28 -0
- package/package.json +3 -1
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
|
package/h2kext-init.mjs
ADDED
|
@@ -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.
|
|
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
|
],
|