@ham2k/extension-tools 0.1.0 → 0.3.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/LICENSE +21 -0
- package/README.md +56 -2
- package/h2kext-build.mjs +61 -2
- package/h2kext-init.mjs +276 -0
- package/h2kext-pack.mjs +1 -1
- package/h2kext-publish.mjs +339 -0
- package/h2kext.mjs +152 -1
- package/package.json +7 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sebastian Delmont <sd@ham2k.com>
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
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
|
|
@@ -44,6 +65,39 @@ then misbehave where you cannot see it. Extension keys are `<callsign>-<name>`
|
|
|
44
65
|
— the namespace every author of this software already holds, so two people who
|
|
45
66
|
never met cannot ship the same key.
|
|
46
67
|
|
|
47
|
-
##
|
|
68
|
+
## Publishing to the catalog
|
|
69
|
+
|
|
70
|
+
```sh
|
|
71
|
+
export H2K_CATALOG_TOKEN=h2kc_…
|
|
72
|
+
npx h2kext-publish build --notes "What changed"
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Uploads a bundle to the [Ham2K extension catalog](https://catalog.ham2k.net),
|
|
76
|
+
where the app finds it. Sign in there and make a token under
|
|
77
|
+
[/publish](https://catalog.ham2k.net/publish); the command reads it from
|
|
78
|
+
`H2K_CATALOG_TOKEN` and from nowhere else — there is no flag for it, because a
|
|
79
|
+
flag lands in your shell history — and stores it nowhere. Publishing needs a
|
|
80
|
+
grant for your callsign prefix, which the same page is where you ask for.
|
|
81
|
+
|
|
82
|
+
A directory is packaged first, exactly as `h2kext-pack` would; a `.h2kext`
|
|
83
|
+
you already made is sent as it is. A bundle must be 16 MiB or smaller: the
|
|
84
|
+
app never downloads more than that, the catalog refuses more than that, and
|
|
85
|
+
the command refuses it before sending a byte. A version's bytes are frozen
|
|
86
|
+
the first time a reviewer approves them: from then on the same version with
|
|
87
|
+
different bytes is refused, so a fix is a new version.
|
|
88
|
+
|
|
89
|
+
Release notes go up in a second request, once the upload is accepted. If
|
|
90
|
+
that request is refused, the version is still published — the command says
|
|
91
|
+
so and exits 1, and you can write the notes on the site, where they can be
|
|
92
|
+
edited at any time.
|
|
93
|
+
|
|
94
|
+
The result line reads `published w7abc-notes 1.2.3 → prod (pending review)`.
|
|
95
|
+
"Pending review" means a Ham2K reviewer has not looked yet — the version is
|
|
96
|
+
stored, but nobody can install it until they approve it, and your dashboard on
|
|
97
|
+
the site shows the outcome and any note. Every refusal prints the catalog's
|
|
98
|
+
reason and exits 1; nothing is retried.
|
|
48
99
|
|
|
49
|
-
|
|
100
|
+
A channel says how finished a release is — `stable`, `unstable`, `bleeding` —
|
|
101
|
+
not which app edition is asking. They cascade upward, so `stable` reaches
|
|
102
|
+
everyone and `bleeding` reaches only somebody who asked for it. `--channel` is
|
|
103
|
+
optional and defaults to `stable`.
|
package/h2kext-build.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Copyright ©️ 2026 Sebastian Delmont <sd@ham2k.com>
|
|
2
|
-
// SPDX-License-Identifier:
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
3
|
//
|
|
4
4
|
// Building an extension: the esbuild settings a Ham2K bundle needs, and the
|
|
5
5
|
// plugin that takes the host up on the libraries it carries (HALO-442,
|
|
@@ -19,12 +19,29 @@
|
|
|
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'
|
|
22
23
|
import { copyFile, mkdir, readFile } from 'node:fs/promises'
|
|
23
24
|
import { createRequire } from 'node:module'
|
|
24
25
|
import { dirname, join, resolve } from 'node:path'
|
|
25
26
|
|
|
26
27
|
import { H2KEXT_API_VERSION, MANIFEST_ENTRY, sharedModulesFor } from './h2kext.mjs'
|
|
27
28
|
|
|
29
|
+
/// What an extension's own `build.mjs` says, for everything that has to write
|
|
30
|
+
/// one: `h2kext-init`'s scaffold, and the samples the SDK ships.
|
|
31
|
+
///
|
|
32
|
+
/// Here rather than in either of them because two copies of a file whose whole
|
|
33
|
+
/// job is to be correct on someone else's machine would drift, and neither
|
|
34
|
+
/// copy is exercised by this repo's own build — the extensions here are built
|
|
35
|
+
/// through `build-config.mjs`, which takes a different path into the same
|
|
36
|
+
/// preset.
|
|
37
|
+
export const EXAMPLE_BUILD_SCRIPT = `import { build } from 'esbuild'
|
|
38
|
+
import { buildExtension } from '@ham2k/extension-tools'
|
|
39
|
+
|
|
40
|
+
await buildExtension(build, { dir: import.meta.dirname })
|
|
41
|
+
|
|
42
|
+
console.log('built build/index.js')
|
|
43
|
+
`
|
|
44
|
+
|
|
28
45
|
export class BuildError extends Error {}
|
|
29
46
|
|
|
30
47
|
/// Rewrites imports of the libraries the host carries into lookups on the
|
|
@@ -41,6 +58,47 @@ export class BuildError extends Error {}
|
|
|
41
58
|
/// is how you use a version the host does not carry — and [inline] is how to
|
|
42
59
|
/// make it, but it is a choice worth stating, because the silent version costs
|
|
43
60
|
/// roughly thirty times the bundle size and nothing anywhere says so.
|
|
61
|
+
/// The version of a shared package as installed beside the extension being
|
|
62
|
+
/// built, for the range the "you must declare this" error suggests.
|
|
63
|
+
///
|
|
64
|
+
/// Suggesting a floor is not cosmetic. The host admits a bundle by comparing
|
|
65
|
+
/// its declared range against the package version the host holds — it cannot
|
|
66
|
+
/// see which SYMBOLS that version has. A package that grew an export (the
|
|
67
|
+
/// Cabrillo writer arrived in `@ham2k/lib-qson-cabrillo` 1.2.0) therefore
|
|
68
|
+
/// satisfies a `^1.0.0` declaration on a host too old to have the function,
|
|
69
|
+
/// and the extension installs, loads, and throws the first time an operator
|
|
70
|
+
/// asks for that feature. Suggesting the version the author is actually
|
|
71
|
+
/// building against makes the honest declaration the easy one.
|
|
72
|
+
///
|
|
73
|
+
/// Null when the package is not resolvable from there, which leaves the
|
|
74
|
+
/// caller its own fallback — a missing suggestion is worth far less than a
|
|
75
|
+
/// failed build.
|
|
76
|
+
function installedVersionOf(pkg, fromDir) {
|
|
77
|
+
try {
|
|
78
|
+
const require = createRequire(resolve(fromDir, 'noop.js'))
|
|
79
|
+
// Walked up from the entry point rather than resolved directly: a package
|
|
80
|
+
// with an `exports` map that does not list `./package.json` — which is
|
|
81
|
+
// most of them, `@ham2k/lib-qson-cabrillo` included — refuses that path
|
|
82
|
+
// outright, and asking for it is how this silently answers 1.0.0 for
|
|
83
|
+
// every package.
|
|
84
|
+
let dir = dirname(require.resolve(pkg))
|
|
85
|
+
for (let up = 0; up < 6; up += 1) {
|
|
86
|
+
try {
|
|
87
|
+
const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'))
|
|
88
|
+
if (manifest.name === pkg) return manifest.version ?? null
|
|
89
|
+
} catch {
|
|
90
|
+
// Not this directory's package.json, or not readable — keep climbing.
|
|
91
|
+
}
|
|
92
|
+
const parent = dirname(dir)
|
|
93
|
+
if (parent === dir) break
|
|
94
|
+
dir = parent
|
|
95
|
+
}
|
|
96
|
+
return null
|
|
97
|
+
} catch {
|
|
98
|
+
return null
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
44
102
|
export function sharedModulesPlugin({ sharedDependencies = {}, api = H2KEXT_API_VERSION, inline = [] } = {}) {
|
|
45
103
|
const offered = sharedModulesFor(api)
|
|
46
104
|
const declared = Object.keys(sharedDependencies)
|
|
@@ -89,7 +147,8 @@ export function sharedModulesPlugin({ sharedDependencies = {}, api = H2KEXT_API_
|
|
|
89
147
|
errors: [{
|
|
90
148
|
text: `'${args.path}' is carried by the Ham2K extension host, but this bundle does not declare it. ` +
|
|
91
149
|
`Add it to "sharedDependencies" in ${MANIFEST_ENTRY} with the version range you work with ` +
|
|
92
|
-
`(e.g. {${JSON.stringify(args.path)}: "
|
|
150
|
+
`(e.g. {${JSON.stringify(args.path)}: "^${installedVersionOf(args.path, args.resolveDir) ?? '1.0.0'}"}), ` +
|
|
151
|
+
`or pass inline: [${JSON.stringify(args.path)}] ` +
|
|
93
152
|
'to ship your own copy — which is how you use a version the host does not carry.',
|
|
94
153
|
}],
|
|
95
154
|
}))
|
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: MIT
|
|
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-pack.mjs
CHANGED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Copyright ©️ 2026 Sebastian Delmont <sd@ham2k.com>
|
|
3
|
+
// SPDX-License-Identifier: MIT
|
|
4
|
+
//
|
|
5
|
+
// Publishes a `.h2kext` to the extension catalog — catalog.ham2k.net, whose
|
|
6
|
+
// SPEC.md lives in the ham2k/catalog.ham2k.net repo; the § references below
|
|
7
|
+
// are to it (§5.1 is the publish request, §15 this command).
|
|
8
|
+
//
|
|
9
|
+
// H2K_CATALOG_TOKEN=h2kc_… h2kext-publish <bundle.h2kext | build dir>
|
|
10
|
+
// [--channel <stable|unstable|bleeding>] [--notes "…"] [--catalog <url>]
|
|
11
|
+
// [--force-name]
|
|
12
|
+
//
|
|
13
|
+
// One POST of the raw bytes, then one PUT for the notes; no retries: a refusal
|
|
14
|
+
// is the catalog's verdict on these bytes, and sending them again can only
|
|
15
|
+
// repeat it. The token is read from the environment for those requests and
|
|
16
|
+
// never written anywhere — not to a config file, not to the terminal, not into
|
|
17
|
+
// an error message.
|
|
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'
|
|
25
|
+
|
|
26
|
+
import { MANIFEST_ENTRY, pack, PackError } from './h2kext.mjs'
|
|
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'
|
|
32
|
+
/// How finished a release is, steadiest first — NOT an app edition. The
|
|
33
|
+
/// catalog cascades, so a release on `stable` is visible to everyone and one
|
|
34
|
+
/// on `bleeding` only to somebody who asked for it.
|
|
35
|
+
export const CHANNELS = ['stable', 'unstable', 'bleeding']
|
|
36
|
+
|
|
37
|
+
/// What `--channel` means when it is not given. Publishing without saying is
|
|
38
|
+
/// publishing something finished; anything rougher is a choice worth typing.
|
|
39
|
+
export const DEFAULT_CHANNEL = 'stable'
|
|
40
|
+
|
|
41
|
+
/// The catalog refuses a larger upload before reading it (§5.1 step 2),
|
|
42
|
+
/// because the app never downloads more than this (`kMaxDownloadBytes`).
|
|
43
|
+
/// Checked here so the refusal costs nothing instead of a wasted upload.
|
|
44
|
+
export const MAX_BUNDLE_BYTES = 16 * 1024 * 1024
|
|
45
|
+
|
|
46
|
+
/// Exit codes. Usage problems — including a missing token — are 2 so a script
|
|
47
|
+
/// can tell "you called this wrong" from "the catalog said no" (1).
|
|
48
|
+
export const EXIT_REFUSED = 1
|
|
49
|
+
export const EXIT_USAGE = 2
|
|
50
|
+
|
|
51
|
+
/// How a submission status reads on the result line. `pending` means a
|
|
52
|
+
/// reviewer has not looked yet; saying "review" is what stops an author
|
|
53
|
+
/// waiting for the extension to appear.
|
|
54
|
+
const STATUS_WORDS = { pending: 'pending review' }
|
|
55
|
+
|
|
56
|
+
export class PublishError extends Error {
|
|
57
|
+
constructor(message, exitCode = EXIT_REFUSED) {
|
|
58
|
+
super(message)
|
|
59
|
+
this.exitCode = exitCode
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const USAGE = `Publish an extension bundle to the Ham2K extension catalog.
|
|
64
|
+
|
|
65
|
+
${TOKEN_ENV}=h2kc_… h2kext-publish <bundle.h2kext | build dir> [--channel <stable|unstable|bleeding>]
|
|
66
|
+
[--notes "<release notes>"] [--catalog <url>] [--force-name]
|
|
67
|
+
|
|
68
|
+
A directory is packaged first, exactly as h2kext-pack would. A bundle over
|
|
69
|
+
16 MiB is refused here, as the catalog would refuse it.
|
|
70
|
+
|
|
71
|
+
The token comes from the ${TOKEN_ENV} environment variable and only from
|
|
72
|
+
there — a flag would land in shell history and \`ps\`. Get one at
|
|
73
|
+
${DEFAULT_CATALOG}/publish. Nothing here stores it.
|
|
74
|
+
|
|
75
|
+
--channel how finished this release is (default ${DEFAULT_CHANNEL});
|
|
76
|
+
stable is visible to everyone, bleeding only to somebody
|
|
77
|
+
who asked for it
|
|
78
|
+
--notes release notes for this version, markdown; sent in a second
|
|
79
|
+
request once the upload is accepted
|
|
80
|
+
--catalog defaults to ${DEFAULT_CATALOG}
|
|
81
|
+
--force-name waive the key naming convention when packaging a directory
|
|
82
|
+
(Ham2K's own builds; the catalog checks it again regardless)
|
|
83
|
+
|
|
84
|
+
Exits 0 once the catalog holds the submission and its notes, 1 when it refuses
|
|
85
|
+
or cannot be reached, 2 for a usage problem.`
|
|
86
|
+
|
|
87
|
+
/// The manifest inside a bundle's bytes.
|
|
88
|
+
///
|
|
89
|
+
/// A reader for one entry of a zip whose writer is ours — h2kext.mjs via yazl:
|
|
90
|
+
/// stored or deflated, no zip64, no encryption — which is why this is forty
|
|
91
|
+
/// lines and not a dependency. Sizes come from the central directory, never
|
|
92
|
+
/// the local header: yazl streams, so the local header's sizes are zero and
|
|
93
|
+
/// the truth sits in the data descriptor after the entry.
|
|
94
|
+
export function readManifestFromBundle(bytes) {
|
|
95
|
+
const buf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes)
|
|
96
|
+
if (buf.length < 22 || buf.readUInt32LE(0) !== 0x04034b50) {
|
|
97
|
+
throw new PublishError('not a .h2kext: not a zip')
|
|
98
|
+
}
|
|
99
|
+
// The end-of-central-directory record is the last 22 bytes plus a comment of
|
|
100
|
+
// up to 64 KiB, so the search walks backwards over that much and no more.
|
|
101
|
+
let eocd = -1
|
|
102
|
+
for (let i = buf.length - 22; i >= Math.max(0, buf.length - 22 - 0xffff); i--) {
|
|
103
|
+
if (buf.readUInt32LE(i) === 0x06054b50) {
|
|
104
|
+
eocd = i
|
|
105
|
+
break
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (eocd < 0) throw new PublishError('not a .h2kext: no zip central directory')
|
|
109
|
+
|
|
110
|
+
const count = buf.readUInt16LE(eocd + 10)
|
|
111
|
+
let offset = buf.readUInt32LE(eocd + 16)
|
|
112
|
+
for (let n = 0; n < count; n++) {
|
|
113
|
+
if (offset + 46 > buf.length || buf.readUInt32LE(offset) !== 0x02014b50) {
|
|
114
|
+
throw new PublishError('not a .h2kext: damaged zip central directory')
|
|
115
|
+
}
|
|
116
|
+
const method = buf.readUInt16LE(offset + 10)
|
|
117
|
+
const compressedSize = buf.readUInt32LE(offset + 20)
|
|
118
|
+
const nameLength = buf.readUInt16LE(offset + 28)
|
|
119
|
+
const extraLength = buf.readUInt16LE(offset + 30)
|
|
120
|
+
const commentLength = buf.readUInt16LE(offset + 32)
|
|
121
|
+
const localOffset = buf.readUInt32LE(offset + 42)
|
|
122
|
+
const name = buf.toString('utf8', offset + 46, offset + 46 + nameLength)
|
|
123
|
+
if (name === MANIFEST_ENTRY) {
|
|
124
|
+
// Every offset here comes from the file itself, so a truncated or
|
|
125
|
+
// doctored bundle can point past its own end: refused as a bundle,
|
|
126
|
+
// not as a RangeError out of Buffer.
|
|
127
|
+
const damaged = `not a .h2kext: ${MANIFEST_ENTRY} entry is truncated or damaged`
|
|
128
|
+
if (localOffset + 30 > buf.length || buf.readUInt32LE(localOffset) !== 0x04034b50) throw new PublishError(damaged)
|
|
129
|
+
const start = localOffset + 30 + buf.readUInt16LE(localOffset + 26) + buf.readUInt16LE(localOffset + 28)
|
|
130
|
+
if (start + compressedSize > buf.length) throw new PublishError(damaged)
|
|
131
|
+
const data = buf.subarray(start, start + compressedSize)
|
|
132
|
+
let raw
|
|
133
|
+
if (method === 0) raw = data
|
|
134
|
+
else if (method === 8) {
|
|
135
|
+
try {
|
|
136
|
+
raw = inflateRawSync(data)
|
|
137
|
+
} catch (e) {
|
|
138
|
+
throw new PublishError(`${damaged}: ${e.message}`)
|
|
139
|
+
}
|
|
140
|
+
} else throw new PublishError(`not a .h2kext: ${MANIFEST_ENTRY} uses zip compression method ${method}`)
|
|
141
|
+
try {
|
|
142
|
+
return JSON.parse(raw.toString('utf8'))
|
|
143
|
+
} catch (e) {
|
|
144
|
+
throw new PublishError(`${MANIFEST_ENTRY} inside the bundle is not valid JSON: ${e.message}`)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
offset += 46 + nameLength + extraLength + commentLength
|
|
148
|
+
}
|
|
149
|
+
throw new PublishError(`not a .h2kext: no ${MANIFEST_ENTRY} inside`)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/// The bundle's bytes: the file as given, or a directory packaged the way
|
|
153
|
+
/// h2kext-pack packages it, into a temporary file that is gone before this
|
|
154
|
+
/// returns.
|
|
155
|
+
async function loadBundle(source, { forceName }) {
|
|
156
|
+
let info
|
|
157
|
+
try {
|
|
158
|
+
info = await stat(source)
|
|
159
|
+
} catch (e) {
|
|
160
|
+
throw new PublishError(`${source}: ${e.code === 'ENOENT' ? 'no such file or directory' : e.message}`)
|
|
161
|
+
}
|
|
162
|
+
if (!info.isDirectory()) return await readFile(source)
|
|
163
|
+
|
|
164
|
+
const tmp = await mkdtemp(join(tmpdir(), 'h2kext-publish-'))
|
|
165
|
+
try {
|
|
166
|
+
const { outPath } = await pack(source, { outPath: join(tmp, 'bundle.h2kext'), forceName })
|
|
167
|
+
return await readFile(outPath)
|
|
168
|
+
} catch (e) {
|
|
169
|
+
if (e instanceof PackError) throw new PublishError(`${source} is not ready to publish:\n${e.message}`)
|
|
170
|
+
throw e
|
|
171
|
+
} finally {
|
|
172
|
+
await rm(tmp, { recursive: true, force: true })
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/// One request to the catalog, its answer parsed as JSON when it is JSON. A
|
|
177
|
+
/// network failure is a [PublishError] naming the catalog, not a stack trace.
|
|
178
|
+
async function call(fetch, catalog, path, init) {
|
|
179
|
+
let response
|
|
180
|
+
try {
|
|
181
|
+
response = await fetch(`${catalog}${path}`, init)
|
|
182
|
+
} catch (e) {
|
|
183
|
+
throw new PublishError(`could not reach ${catalog}: ${e.cause?.message ?? e.message}`)
|
|
184
|
+
}
|
|
185
|
+
const text = await response.text()
|
|
186
|
+
let body
|
|
187
|
+
try {
|
|
188
|
+
body = JSON.parse(text)
|
|
189
|
+
} catch {
|
|
190
|
+
body = undefined
|
|
191
|
+
}
|
|
192
|
+
return { response, text, body }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/// What a refusal says: the server's `error` and `code`, or its raw body when
|
|
196
|
+
/// it sent no JSON — a proxy's HTML is still worth seeing.
|
|
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'
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/// Uploads [bytes] as the raw request body (§5.1: no multipart) and
|
|
204
|
+
/// returns the catalog's 201 body. Any other answer is a [PublishError].
|
|
205
|
+
///
|
|
206
|
+
/// `Content-Length` is not set here: fetch derives it from a Buffer body, and a
|
|
207
|
+
/// header set by hand that disagreed with the body would be the request's
|
|
208
|
+
/// failure, not the catalog's.
|
|
209
|
+
export async function publish({ bytes, manifest, channel, catalog, token }, { fetch = globalThis.fetch } = {}) {
|
|
210
|
+
const answer = await call(fetch, catalog, `${PUBLISH_PATH}?channel=${channel}`, {
|
|
211
|
+
method: 'POST',
|
|
212
|
+
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/zip' },
|
|
213
|
+
body: bytes,
|
|
214
|
+
})
|
|
215
|
+
if (answer.response.status === 201 && answer.body) return answer.body
|
|
216
|
+
throw new PublishError(`${catalog} refused ${manifest.key} ${manifest.version} with HTTP ${answer.response.status}: ${refusal(answer)}`)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/// Sets a submission's release notes (§5.1, the second call). Any answer
|
|
220
|
+
/// outside 2xx is a [PublishError]; the submission it was for stands.
|
|
221
|
+
export async function setReleaseNotes({ submissionId, notes, catalog, token }, { fetch = globalThis.fetch } = {}) {
|
|
222
|
+
const answer = await call(fetch, catalog, `${SUBMISSIONS_PATH}/${encodeURIComponent(submissionId)}/notes`, {
|
|
223
|
+
method: 'PUT',
|
|
224
|
+
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
|
|
225
|
+
body: JSON.stringify({ releaseNotes: notes }),
|
|
226
|
+
})
|
|
227
|
+
if (answer.response.ok) return
|
|
228
|
+
throw new PublishError(`${catalog} refused the release notes for submission ${submissionId} with HTTP ${answer.response.status}: ${refusal(answer)}`)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/// The command. Returns the exit code rather than exiting, so the same code
|
|
232
|
+
/// runs under a test with a fake `fetch` and captured output.
|
|
233
|
+
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')) {
|
|
235
|
+
stdout.write(`${USAGE}\n`)
|
|
236
|
+
return argv.length === 0 ? EXIT_USAGE : 0
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const token = env[TOKEN_ENV]
|
|
240
|
+
|
|
241
|
+
// Every line out goes through this, so the token cannot reach a terminal or
|
|
242
|
+
// 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))
|
|
244
|
+
const say = (s) => stdout.write(`${redact(s)}\n`)
|
|
245
|
+
const complain = (s) => stderr.write(`h2kext-publish: ${redact(s)}\n`)
|
|
246
|
+
|
|
247
|
+
try {
|
|
248
|
+
// Walked by index, like h2kext-pack: `indexOf` finds the FIRST occurrence,
|
|
249
|
+
// and a value can repeat an option's name. Unlike h2kext-pack, an option
|
|
250
|
+
// this does not know is a refusal: `--token h2kc_…` from an older script
|
|
251
|
+
// would otherwise be ignored and the token sent nowhere, after sitting in
|
|
252
|
+
// the shell history — the reason the flag does not exist.
|
|
253
|
+
let source
|
|
254
|
+
let channel
|
|
255
|
+
let notes
|
|
256
|
+
let catalog = DEFAULT_CATALOG
|
|
257
|
+
let forceName = false
|
|
258
|
+
// An option at the end of the line has no value, and `--notes` with none
|
|
259
|
+
// would silently publish without notes — the one option whose absence
|
|
260
|
+
// looks like success.
|
|
261
|
+
const valueOf = (i) => {
|
|
262
|
+
if (i + 1 >= argv.length) throw new PublishError(`${argv[i]} needs a value`, EXIT_USAGE)
|
|
263
|
+
return argv[i + 1]
|
|
264
|
+
}
|
|
265
|
+
for (let i = 0; i < argv.length; i++) {
|
|
266
|
+
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)
|
|
273
|
+
else if (source === undefined) source = arg
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (!source) throw new PublishError('name the bundle or directory to publish', EXIT_USAGE)
|
|
277
|
+
channel ??= DEFAULT_CHANNEL
|
|
278
|
+
if (!CHANNELS.includes(channel)) {
|
|
279
|
+
throw new PublishError(`--channel must be one of ${CHANNELS.join(', ')}${channel ? `, not '${channel}'` : ''}`, EXIT_USAGE)
|
|
280
|
+
}
|
|
281
|
+
if (!token) {
|
|
282
|
+
throw new PublishError(`no token — set ${TOKEN_ENV} (get one at ${catalog}/publish)`, EXIT_USAGE)
|
|
283
|
+
}
|
|
284
|
+
try {
|
|
285
|
+
catalog = new URL(catalog).href.replace(/\/$/, '')
|
|
286
|
+
} catch {
|
|
287
|
+
throw new PublishError(`--catalog must be a URL, not '${catalog}'`, EXIT_USAGE)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const bytes = await loadBundle(source, { forceName })
|
|
291
|
+
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`)
|
|
293
|
+
}
|
|
294
|
+
const manifest = readManifestFromBundle(bytes)
|
|
295
|
+
for (const field of ['key', 'version']) {
|
|
296
|
+
if (typeof manifest[field] !== 'string' || manifest[field].trim() === '') {
|
|
297
|
+
throw new PublishError(`${MANIFEST_ENTRY} inside the bundle has no '${field}'`)
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
say(`publishing ${manifest.key} ${manifest.version}${manifest.name ? ` — ${manifest.name}` : ''} → ${channel} at ${catalog}`)
|
|
301
|
+
|
|
302
|
+
const result = await publish({ bytes, manifest, channel, catalog, token }, { fetch })
|
|
303
|
+
say(`published ${result.key} ${result.version} → ${result.channel} (${STATUS_WORDS[result.status] ?? result.status})`)
|
|
304
|
+
if (result.url) say(result.url)
|
|
305
|
+
|
|
306
|
+
if (notes !== undefined) {
|
|
307
|
+
try {
|
|
308
|
+
if (result.submissionId === undefined || result.submissionId === null) {
|
|
309
|
+
throw new PublishError(`the catalog's answer named no submissionId to attach them to`)
|
|
310
|
+
}
|
|
311
|
+
await setReleaseNotes({ submissionId: result.submissionId, notes, catalog, token }, { fetch })
|
|
312
|
+
} catch (e) {
|
|
313
|
+
if (!(e instanceof PublishError)) throw e
|
|
314
|
+
// The version is published and stays published: the notes are the
|
|
315
|
+
// site's to edit at any time, so nothing is rolled back — but the
|
|
316
|
+
// exit code says the command did not do all it was asked.
|
|
317
|
+
complain(`warning: ${result.key} ${result.version} is published, but its release notes were not set — ${e.message}. Edit them on the site instead.`)
|
|
318
|
+
return EXIT_REFUSED
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return 0
|
|
322
|
+
} catch (e) {
|
|
323
|
+
if (e instanceof PublishError) {
|
|
324
|
+
complain(e.message)
|
|
325
|
+
return e.exitCode
|
|
326
|
+
}
|
|
327
|
+
// Anything else is a bug here, not a refusal — but it still goes through
|
|
328
|
+
// the redactor rather than to an uncaught-exception stack trace.
|
|
329
|
+
complain(e.stack ?? e)
|
|
330
|
+
return EXIT_REFUSED
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Run only as the command. `realpathSync` because npm installs a bin as a
|
|
335
|
+
// symlink while `import.meta.url` resolves to the real file — compared raw,
|
|
336
|
+
// `npx h2kext-publish` would import this module and then do nothing.
|
|
337
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
|
|
338
|
+
process.exit(await main(process.argv.slice(2)))
|
|
339
|
+
}
|
package/h2kext.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Copyright ©️ 2026 Sebastian Delmont <sd@ham2k.com>
|
|
2
|
-
// SPDX-License-Identifier:
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
3
|
//
|
|
4
4
|
// The `.h2kext` bundle format: what one contains, what makes one valid, and how
|
|
5
5
|
// to write it (HALO-422, docs/extensions/distribution.md).
|
|
@@ -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]
|
|
@@ -125,6 +153,107 @@ export function sharedModulesFor(api) {
|
|
|
125
153
|
|
|
126
154
|
export class PackError extends Error {}
|
|
127
155
|
|
|
156
|
+
/// The vocabulary of the manifest's `relevance` — what the matcher compares
|
|
157
|
+
/// the operator's own values against, so a value outside it can never match
|
|
158
|
+
/// anyone. Continents are cty.dat's column-4 codes; the regions are the three
|
|
159
|
+
/// the ITU Radio Regulations define.
|
|
160
|
+
export const GEO_CONTINENTS = ['AF', 'AS', 'EU', 'NA', 'OC', 'SA', 'AN']
|
|
161
|
+
export const GEO_ITU_REGIONS = [1, 2, 3]
|
|
162
|
+
|
|
163
|
+
/// What `relevance.interests` may name: a mode, a band group, or a style of
|
|
164
|
+
/// operating. Closed, and checked, because an interest nothing else spells
|
|
165
|
+
/// the same way matches nobody while looking like it works — `CW`, `cw` and
|
|
166
|
+
/// `morse` would be three interests, and only one of them the one the app
|
|
167
|
+
/// asks for.
|
|
168
|
+
///
|
|
169
|
+
/// 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
|
+
]
|
|
175
|
+
|
|
176
|
+
const RELEVANCE_KEYS = ['entities', 'countries', 'continents', 'ituRegions', 'dates', 'interests']
|
|
177
|
+
|
|
178
|
+
/// Everything wrong with a manifest's `relevance`, as a list — empty for a
|
|
179
|
+
/// valid one, and for an absent one, which means worldwide and undated.
|
|
180
|
+
///
|
|
181
|
+
/// The shape rules are the ones that matter: the matcher reads a key it
|
|
182
|
+
/// cannot parse as "does not gate", so a misspelt key or a string where a
|
|
183
|
+
/// list should be would quietly make a Chilean extension relevant to the
|
|
184
|
+
/// whole world. The vocabulary rules catch the opposite mistake — a value that
|
|
185
|
+
/// can never match anyone, which is a bundle nobody is shown.
|
|
186
|
+
///
|
|
187
|
+
/// Shared with build-config.mjs's manifest checks, which import it: the
|
|
188
|
+
/// rules are the format's, not the repo's, and this module is the format.
|
|
189
|
+
export function validateRelevance(relevance) {
|
|
190
|
+
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(' / ')}`]
|
|
193
|
+
}
|
|
194
|
+
const problems = []
|
|
195
|
+
for (const key of Object.keys(relevance)) {
|
|
196
|
+
if (!RELEVANCE_KEYS.includes(key)) {
|
|
197
|
+
problems.push(`'relevance' has an unknown key '${key}' — only ${RELEVANCE_KEYS.join(', ')} are read`)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
for (const key of RELEVANCE_KEYS) {
|
|
201
|
+
const values = relevance[key]
|
|
202
|
+
if (values === undefined) continue
|
|
203
|
+
if (!Array.isArray(values)) {
|
|
204
|
+
problems.push(`'relevance.${key}' must be a list`)
|
|
205
|
+
continue
|
|
206
|
+
}
|
|
207
|
+
for (const value of values) {
|
|
208
|
+
const problem = relevanceValueProblem(key, value)
|
|
209
|
+
if (problem) problems.push(`'relevance.${key}' has ${JSON.stringify(value)}, which ${problem}`)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return problems
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
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(', ')})`
|
|
218
|
+
}
|
|
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 '*')"
|
|
224
|
+
}
|
|
225
|
+
if (key === 'countries') {
|
|
226
|
+
return /^[a-z]{2}$/.test(value.toLowerCase()) ? null : 'is not a two-letter ISO country code'
|
|
227
|
+
}
|
|
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(' ')})`
|
|
233
|
+
}
|
|
234
|
+
return GEO_CONTINENTS.includes(value.toUpperCase()) ? null : `is not a continent (${GEO_CONTINENTS.join(' ')})`
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/// Why [value] is not a UTC calendar day, or null.
|
|
238
|
+
///
|
|
239
|
+
/// The round trip is the whole check: `Date.UTC` rolls 31 February over into
|
|
240
|
+
/// 3 March rather than refusing it, so a pattern match alone accepts a day
|
|
241
|
+
/// that does not exist and then silently means a different one.
|
|
242
|
+
///
|
|
243
|
+
/// A year under 1000 is refused before that round trip, and the Dart twin
|
|
244
|
+
/// refuses it too. `Date.UTC` reads a year under 100 as 19xx, so `0050-01-01`
|
|
245
|
+
/// would fail the round trip here and pass in Dart — a disagreement between
|
|
246
|
+
/// the two on an input neither should be accepting anyway.
|
|
247
|
+
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'
|
|
251
|
+
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'
|
|
255
|
+
}
|
|
256
|
+
|
|
128
257
|
/// Everything wrong with a candidate bundle, as a list — an author fixing one
|
|
129
258
|
/// problem per run is an author running this five times.
|
|
130
259
|
export function validateManifest(manifest, { source = MANIFEST_ENTRY, forceName = false } = {}) {
|
|
@@ -185,6 +314,28 @@ export function validateManifest(manifest, { source = MANIFEST_ENTRY, forceName
|
|
|
185
314
|
say("'experiments' gates on the app's own experiment catalog and would hide this extension permanently — remove it")
|
|
186
315
|
}
|
|
187
316
|
|
|
317
|
+
// The name `relevance` replaced. Nothing reads `geo`, and nothing here
|
|
318
|
+
// rejects unknown top-level keys, so a manifest still carrying it would
|
|
319
|
+
// pack, publish and install as WORLDWIDE — the author's audience silently
|
|
320
|
+
// discarded rather than refused. It is the one stale key certain to
|
|
321
|
+
// arrive, because it is what every older manifest says.
|
|
322
|
+
if (manifest.geo !== undefined) {
|
|
323
|
+
say("'geo' is now 'relevance' — rename it (docs/extensions/distribution.md)")
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// `enabledByDefault` is the shipping app's answer for its OWN extensions,
|
|
327
|
+
// which arrive whether the operator wanted them or not. An installed
|
|
328
|
+
// bundle is one the operator went and got, so the answer is always yes and
|
|
329
|
+
// a manifest that says otherwise is describing a state it cannot reach.
|
|
330
|
+
if (manifest.enabledByDefault !== undefined) {
|
|
331
|
+
say("'enabledByDefault' applies to an app's built-in extensions — an installed one is on because the operator installed it")
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// `relevance` ranks the bundle, in the catalog and the Extensions panel,
|
|
335
|
+
// for the operators it was written for. The matcher reads a key it cannot
|
|
336
|
+
// parse as "does not gate", so a malformed one would ship as worldwide.
|
|
337
|
+
for (const problem of validateRelevance(manifest.relevance)) say(problem)
|
|
338
|
+
|
|
188
339
|
return problems
|
|
189
340
|
}
|
|
190
341
|
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ham2k/extension-tools",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Build and package extensions for the Ham2K Logger",
|
|
5
5
|
"keywords": ["ham2k", "ham radio", "amateur radio", "extension", "esbuild", "h2kext"],
|
|
6
|
-
"license": "
|
|
6
|
+
"license": "MIT",
|
|
7
7
|
"author": "Sebastian Delmont <sd@ham2k.com>",
|
|
8
8
|
"homepage": "https://ham2k.com",
|
|
9
9
|
"repository": {
|
|
@@ -17,12 +17,16 @@
|
|
|
17
17
|
"./format": "./h2kext.mjs"
|
|
18
18
|
},
|
|
19
19
|
"bin": {
|
|
20
|
-
"h2kext-
|
|
20
|
+
"h2kext-init": "./h2kext-init.mjs",
|
|
21
|
+
"h2kext-pack": "./h2kext-pack.mjs",
|
|
22
|
+
"h2kext-publish": "./h2kext-publish.mjs"
|
|
21
23
|
},
|
|
22
24
|
"files": [
|
|
23
25
|
"h2kext.mjs",
|
|
24
26
|
"h2kext-build.mjs",
|
|
27
|
+
"h2kext-init.mjs",
|
|
25
28
|
"h2kext-pack.mjs",
|
|
29
|
+
"h2kext-publish.mjs",
|
|
26
30
|
"README.md"
|
|
27
31
|
],
|
|
28
32
|
"engines": {
|