@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/LICENSE +21 -0
- package/README.md +41 -2
- package/h2kext-build.mjs +91 -45
- package/h2kext-init.mjs +179 -81
- package/h2kext-pack.mjs +9 -9
- package/h2kext-publish.mjs +339 -0
- package/h2kext.mjs +164 -48
- package/package.json +13 -4
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
|
@@ -17,6 +17,12 @@ script, a tsconfig — into the working directory, and refuses rather than
|
|
|
17
17
|
overwrite anything already there. An existing `package.json` is added to, never
|
|
18
18
|
replaced.
|
|
19
19
|
|
|
20
|
+
It also writes a `.prettierrc.json` and the `format` / `format:check` scripts,
|
|
21
|
+
and what it scaffolds is already in that style — so `npm run format:check`
|
|
22
|
+
passes on an untouched project, and the code an agent adds to it lands
|
|
23
|
+
formatted the same way. Configure Prettier already, anywhere it looks, and
|
|
24
|
+
yours is left in charge: no config is written, and the command says so.
|
|
25
|
+
|
|
20
26
|
It takes the key rather than defaulting one: a placeholder like
|
|
21
27
|
`n0call-my-extension` validates, so it would ship, which is the one outcome the
|
|
22
28
|
naming convention exists to stop.
|
|
@@ -65,6 +71,39 @@ then misbehave where you cannot see it. Extension keys are `<callsign>-<name>`
|
|
|
65
71
|
— the namespace every author of this software already holds, so two people who
|
|
66
72
|
never met cannot ship the same key.
|
|
67
73
|
|
|
68
|
-
##
|
|
74
|
+
## Publishing to the catalog
|
|
75
|
+
|
|
76
|
+
```sh
|
|
77
|
+
export H2K_CATALOG_TOKEN=h2kc_…
|
|
78
|
+
npx h2kext-publish build --notes "What changed"
|
|
79
|
+
```
|
|
69
80
|
|
|
70
|
-
|
|
81
|
+
Uploads a bundle to the [Ham2K extension catalog](https://catalog.ham2k.net),
|
|
82
|
+
where the app finds it. Sign in there and make a token under
|
|
83
|
+
[/publish](https://catalog.ham2k.net/publish); the command reads it from
|
|
84
|
+
`H2K_CATALOG_TOKEN` and from nowhere else — there is no flag for it, because a
|
|
85
|
+
flag lands in your shell history — and stores it nowhere. Publishing needs a
|
|
86
|
+
grant for your callsign prefix, which the same page is where you ask for.
|
|
87
|
+
|
|
88
|
+
A directory is packaged first, exactly as `h2kext-pack` would; a `.h2kext`
|
|
89
|
+
you already made is sent as it is. A bundle must be 16 MiB or smaller: the
|
|
90
|
+
app never downloads more than that, the catalog refuses more than that, and
|
|
91
|
+
the command refuses it before sending a byte. A version's bytes are frozen
|
|
92
|
+
the first time a reviewer approves them: from then on the same version with
|
|
93
|
+
different bytes is refused, so a fix is a new version.
|
|
94
|
+
|
|
95
|
+
Release notes go up in a second request, once the upload is accepted. If
|
|
96
|
+
that request is refused, the version is still published — the command says
|
|
97
|
+
so and exits 1, and you can write the notes on the site, where they can be
|
|
98
|
+
edited at any time.
|
|
99
|
+
|
|
100
|
+
The result line reads `published w7abc-notes 1.2.3 → prod (pending review)`.
|
|
101
|
+
"Pending review" means a Ham2K reviewer has not looked yet — the version is
|
|
102
|
+
stored, but nobody can install it until they approve it, and your dashboard on
|
|
103
|
+
the site shows the outcome and any note. Every refusal prints the catalog's
|
|
104
|
+
reason and exits 1; nothing is retried.
|
|
105
|
+
|
|
106
|
+
A channel says how finished a release is — `stable`, `unstable`, `bleeding` —
|
|
107
|
+
not which app edition is asking. They cascade upward, so `stable` reaches
|
|
108
|
+
everyone and `bleeding` reaches only somebody who asked for it. `--channel` is
|
|
109
|
+
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,11 +19,12 @@
|
|
|
19
19
|
// The repo's own build uses it too, on the same terms, which is what keeps the
|
|
20
20
|
// path a third party takes from being the untested one.
|
|
21
21
|
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
22
|
+
import { readFileSync } from "node:fs"
|
|
23
|
+
import { copyFile, mkdir, readFile } from "node:fs/promises"
|
|
24
|
+
import { createRequire } from "node:module"
|
|
25
|
+
import { dirname, join, resolve } from "node:path"
|
|
25
26
|
|
|
26
|
-
import { H2KEXT_API_VERSION, MANIFEST_ENTRY, sharedModulesFor } from
|
|
27
|
+
import { H2KEXT_API_VERSION, MANIFEST_ENTRY, sharedModulesFor } from "./h2kext.mjs"
|
|
27
28
|
|
|
28
29
|
/// What an extension's own `build.mjs` says, for everything that has to write
|
|
29
30
|
/// one: `h2kext-init`'s scaffold, and the samples the SDK ships.
|
|
@@ -33,12 +34,12 @@ import { H2KEXT_API_VERSION, MANIFEST_ENTRY, sharedModulesFor } from './h2kext.m
|
|
|
33
34
|
/// copy is exercised by this repo's own build — the extensions here are built
|
|
34
35
|
/// through `build-config.mjs`, which takes a different path into the same
|
|
35
36
|
/// preset.
|
|
36
|
-
export const EXAMPLE_BUILD_SCRIPT = `import { build } from
|
|
37
|
-
import { buildExtension } from
|
|
37
|
+
export const EXAMPLE_BUILD_SCRIPT = `import { build } from "esbuild"
|
|
38
|
+
import { buildExtension } from "@ham2k/extension-tools"
|
|
38
39
|
|
|
39
40
|
await buildExtension(build, { dir: import.meta.dirname })
|
|
40
41
|
|
|
41
|
-
console.log(
|
|
42
|
+
console.log("built build/index.js")
|
|
42
43
|
`
|
|
43
44
|
|
|
44
45
|
export class BuildError extends Error {}
|
|
@@ -57,6 +58,47 @@ export class BuildError extends Error {}
|
|
|
57
58
|
/// is how you use a version the host does not carry — and [inline] is how to
|
|
58
59
|
/// make it, but it is a choice worth stating, because the silent version costs
|
|
59
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
|
+
|
|
60
102
|
export function sharedModulesPlugin({ sharedDependencies = {}, api = H2KEXT_API_VERSION, inline = [] } = {}) {
|
|
61
103
|
const offered = sharedModulesFor(api)
|
|
62
104
|
const declared = Object.keys(sharedDependencies)
|
|
@@ -67,8 +109,9 @@ export function sharedModulesPlugin({ sharedDependencies = {}, api = H2KEXT_API_
|
|
|
67
109
|
const notOffered = declared.filter((pkg) => !offered.includes(pkg))
|
|
68
110
|
if (notOffered.length) {
|
|
69
111
|
throw new BuildError(
|
|
70
|
-
`${MANIFEST_ENTRY} declares sharedDependencies the host does not carry (${notOffered.join(
|
|
71
|
-
|
|
112
|
+
`${MANIFEST_ENTRY} declares sharedDependencies the host does not carry (${notOffered.join(", ")}). ` +
|
|
113
|
+
"Bundle those into your build instead — only what the host offers can be shared.",
|
|
114
|
+
)
|
|
72
115
|
}
|
|
73
116
|
|
|
74
117
|
// The two say opposite things about the same package, so neither can be
|
|
@@ -76,19 +119,19 @@ export function sharedModulesPlugin({ sharedDependencies = {}, api = H2KEXT_API_
|
|
|
76
119
|
const both = declared.filter((pkg) => inline.includes(pkg))
|
|
77
120
|
if (both.length) {
|
|
78
121
|
throw new BuildError(
|
|
79
|
-
`${both.join(
|
|
80
|
-
|
|
122
|
+
`${both.join(", ")} appears in both sharedDependencies and inline. ` + "Declare it to use the host's copy, or inline it to ship your own — not both.",
|
|
123
|
+
)
|
|
81
124
|
}
|
|
82
125
|
|
|
83
126
|
const undeclared = offered.filter((pkg) => !declared.includes(pkg) && !inline.includes(pkg))
|
|
84
127
|
|
|
85
128
|
return {
|
|
86
|
-
name:
|
|
129
|
+
name: "ham2k-shared-modules",
|
|
87
130
|
setup(build) {
|
|
88
131
|
if (declared.length) {
|
|
89
132
|
build.onResolve({ filter: matcherFor(declared) }, (args) => ({
|
|
90
133
|
path: args.path,
|
|
91
|
-
namespace:
|
|
134
|
+
namespace: "ham2k-shared-module",
|
|
92
135
|
}))
|
|
93
136
|
}
|
|
94
137
|
|
|
@@ -102,18 +145,22 @@ export function sharedModulesPlugin({ sharedDependencies = {}, api = H2KEXT_API_
|
|
|
102
145
|
// esbuild's pretty printer and nothing else, so a build script that
|
|
103
146
|
// catches the error — or runs at logLevel 'silent' — shows the
|
|
104
147
|
// author the complaint with the answer stripped off.
|
|
105
|
-
errors: [
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
148
|
+
errors: [
|
|
149
|
+
{
|
|
150
|
+
text:
|
|
151
|
+
`'${args.path}' is carried by the Ham2K extension host, but this bundle does not declare it. ` +
|
|
152
|
+
`Add it to "sharedDependencies" in ${MANIFEST_ENTRY} with the version range you work with ` +
|
|
153
|
+
`(e.g. {${JSON.stringify(args.path)}: "^${installedVersionOf(args.path, args.resolveDir) ?? "1.0.0"}"}), ` +
|
|
154
|
+
`or pass inline: [${JSON.stringify(args.path)}] ` +
|
|
155
|
+
"to ship your own copy — which is how you use a version the host does not carry.",
|
|
156
|
+
},
|
|
157
|
+
],
|
|
111
158
|
}))
|
|
112
159
|
}
|
|
113
160
|
|
|
114
|
-
build.onLoad({ filter: /.*/, namespace:
|
|
161
|
+
build.onLoad({ filter: /.*/, namespace: "ham2k-shared-module" }, (args) => ({
|
|
115
162
|
contents: `module.exports = globalThis.__polo.sharedModules[${JSON.stringify(args.path)}];`,
|
|
116
|
-
loader:
|
|
163
|
+
loader: "js",
|
|
117
164
|
}))
|
|
118
165
|
},
|
|
119
166
|
}
|
|
@@ -123,7 +170,7 @@ export function sharedModulesPlugin({ sharedDependencies = {}, api = H2KEXT_API_
|
|
|
123
170
|
/// names carry regex metacharacters (`@ham2k/lib-qson-adif` has a slash, and a
|
|
124
171
|
/// dot would be a wildcard), so every one is escaped.
|
|
125
172
|
function matcherFor(packages) {
|
|
126
|
-
const alternatives = packages.map((pkg) => pkg.replace(/[.*+?^${}()|[\]\\/]/g,
|
|
173
|
+
const alternatives = packages.map((pkg) => pkg.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&")).join("|")
|
|
127
174
|
return new RegExp(`^(${alternatives})$`)
|
|
128
175
|
}
|
|
129
176
|
|
|
@@ -143,14 +190,14 @@ function liquidBrowserBuild(fromDir) {
|
|
|
143
190
|
// `dir` is the caller's to pass — a relative one lands in the catch below
|
|
144
191
|
// and loses the alias, which is precisely the failure this function exists
|
|
145
192
|
// to prevent, arriving with nothing to point at the cause.
|
|
146
|
-
const require = createRequire(resolve(fromDir,
|
|
147
|
-
return join(dirname(require.resolve(
|
|
193
|
+
const require = createRequire(resolve(fromDir, "noop.js"))
|
|
194
|
+
return join(dirname(require.resolve("liquidjs/package.json")), "dist/liquid.browser.mjs")
|
|
148
195
|
} catch (e) {
|
|
149
196
|
// Not installed is the normal case for an extension that neither inlines
|
|
150
197
|
// liquidjs nor uses it, and there is nothing to alias. Anything else is a
|
|
151
198
|
// real fault, and swallowing it would hand the author five unresolvable
|
|
152
199
|
// node built-ins instead.
|
|
153
|
-
if (e.code ===
|
|
200
|
+
if (e.code === "MODULE_NOT_FOUND") return null
|
|
154
201
|
throw e
|
|
155
202
|
}
|
|
156
203
|
}
|
|
@@ -167,20 +214,17 @@ export function buildOptionsFor({ entry, outfile, manifest = {}, dir = process.c
|
|
|
167
214
|
entryPoints: [entry],
|
|
168
215
|
...(outfile ? { outfile } : { write: false }),
|
|
169
216
|
bundle: true,
|
|
170
|
-
format:
|
|
171
|
-
platform:
|
|
172
|
-
mainFields: [
|
|
173
|
-
target:
|
|
174
|
-
logLevel:
|
|
217
|
+
format: "iife",
|
|
218
|
+
platform: "neutral",
|
|
219
|
+
mainFields: ["module", "main"],
|
|
220
|
+
target: "es2020",
|
|
221
|
+
logLevel: "warning",
|
|
175
222
|
...rest,
|
|
176
223
|
alias: {
|
|
177
224
|
...(liquid ? { liquidjs: liquid } : {}),
|
|
178
225
|
...alias,
|
|
179
226
|
},
|
|
180
|
-
plugins: [
|
|
181
|
-
sharedModulesPlugin({ sharedDependencies: manifest.sharedDependencies, api: manifest.api, inline }),
|
|
182
|
-
...(rest.plugins ?? []),
|
|
183
|
-
],
|
|
227
|
+
plugins: [sharedModulesPlugin({ sharedDependencies: manifest.sharedDependencies, api: manifest.api, inline }), ...(rest.plugins ?? [])],
|
|
184
228
|
}
|
|
185
229
|
}
|
|
186
230
|
|
|
@@ -193,12 +237,12 @@ export function buildOptionsFor({ entry, outfile, manifest = {}, dir = process.c
|
|
|
193
237
|
///
|
|
194
238
|
/// [esbuild] is the caller's `build` function, passed in rather than imported
|
|
195
239
|
/// so this module needs no copy of its own.
|
|
196
|
-
export async function buildExtension(esbuild, { dir, entry =
|
|
197
|
-
if (!dir) throw new BuildError(
|
|
240
|
+
export async function buildExtension(esbuild, { dir, entry = "src/index.ts", outDir = "build", ...rest } = {}) {
|
|
241
|
+
if (!dir) throw new BuildError("buildExtension needs `dir`: the extension directory holding manifest.json")
|
|
198
242
|
|
|
199
243
|
let manifest
|
|
200
244
|
try {
|
|
201
|
-
manifest = JSON.parse(await readFile(join(dir, MANIFEST_ENTRY),
|
|
245
|
+
manifest = JSON.parse(await readFile(join(dir, MANIFEST_ENTRY), "utf8"))
|
|
202
246
|
} catch (e) {
|
|
203
247
|
throw new BuildError(`could not read ${join(dir, MANIFEST_ENTRY)}: ${e.message}`)
|
|
204
248
|
}
|
|
@@ -206,13 +250,15 @@ export async function buildExtension(esbuild, { dir, entry = 'src/index.ts', out
|
|
|
206
250
|
const out = join(dir, outDir)
|
|
207
251
|
await mkdir(out, { recursive: true })
|
|
208
252
|
|
|
209
|
-
await esbuild(
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
253
|
+
await esbuild(
|
|
254
|
+
buildOptionsFor({
|
|
255
|
+
entry: join(dir, entry),
|
|
256
|
+
outfile: join(out, "index.js"),
|
|
257
|
+
manifest,
|
|
258
|
+
dir,
|
|
259
|
+
...rest,
|
|
260
|
+
}),
|
|
261
|
+
)
|
|
216
262
|
|
|
217
263
|
// The packer reads both from one directory, so the manifest the bundle was
|
|
218
264
|
// built against is the manifest that ships with it. Copied rather than left
|