@ham2k/extension-tools 0.3.0 → 0.5.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 +6 -0
- package/h2kext-build.mjs +51 -48
- package/h2kext-init.mjs +178 -80
- package/h2kext-pack.mjs +11 -8
- package/h2kext-publish.mjs +44 -44
- package/h2kext.mjs +90 -79
- package/package.json +9 -2
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.
|
package/h2kext-build.mjs
CHANGED
|
@@ -19,12 +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 { readFileSync } from
|
|
23
|
-
import { copyFile, mkdir, readFile } from
|
|
24
|
-
import { createRequire } from
|
|
25
|
-
import { dirname, join, resolve } from
|
|
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"
|
|
26
26
|
|
|
27
|
-
import { H2KEXT_API_VERSION, MANIFEST_ENTRY, sharedModulesFor } from
|
|
27
|
+
import { H2KEXT_API_VERSION, MANIFEST_ENTRY, sharedModulesFor } from "./h2kext.mjs"
|
|
28
28
|
|
|
29
29
|
/// What an extension's own `build.mjs` says, for everything that has to write
|
|
30
30
|
/// one: `h2kext-init`'s scaffold, and the samples the SDK ships.
|
|
@@ -34,12 +34,12 @@ import { H2KEXT_API_VERSION, MANIFEST_ENTRY, sharedModulesFor } from './h2kext.m
|
|
|
34
34
|
/// copy is exercised by this repo's own build — the extensions here are built
|
|
35
35
|
/// through `build-config.mjs`, which takes a different path into the same
|
|
36
36
|
/// preset.
|
|
37
|
-
export const EXAMPLE_BUILD_SCRIPT = `import { build } from
|
|
38
|
-
import { buildExtension } from
|
|
37
|
+
export const EXAMPLE_BUILD_SCRIPT = `import { build } from "esbuild"
|
|
38
|
+
import { buildExtension } from "@ham2k/extension-tools"
|
|
39
39
|
|
|
40
40
|
await buildExtension(build, { dir: import.meta.dirname })
|
|
41
41
|
|
|
42
|
-
console.log(
|
|
42
|
+
console.log("built build/index.js")
|
|
43
43
|
`
|
|
44
44
|
|
|
45
45
|
export class BuildError extends Error {}
|
|
@@ -75,7 +75,7 @@ export class BuildError extends Error {}
|
|
|
75
75
|
/// failed build.
|
|
76
76
|
function installedVersionOf(pkg, fromDir) {
|
|
77
77
|
try {
|
|
78
|
-
const require = createRequire(resolve(fromDir,
|
|
78
|
+
const require = createRequire(resolve(fromDir, "noop.js"))
|
|
79
79
|
// Walked up from the entry point rather than resolved directly: a package
|
|
80
80
|
// with an `exports` map that does not list `./package.json` — which is
|
|
81
81
|
// most of them, `@ham2k/lib-qson-cabrillo` included — refuses that path
|
|
@@ -84,7 +84,7 @@ function installedVersionOf(pkg, fromDir) {
|
|
|
84
84
|
let dir = dirname(require.resolve(pkg))
|
|
85
85
|
for (let up = 0; up < 6; up += 1) {
|
|
86
86
|
try {
|
|
87
|
-
const manifest = JSON.parse(readFileSync(join(dir,
|
|
87
|
+
const manifest = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"))
|
|
88
88
|
if (manifest.name === pkg) return manifest.version ?? null
|
|
89
89
|
} catch {
|
|
90
90
|
// Not this directory's package.json, or not readable — keep climbing.
|
|
@@ -109,8 +109,9 @@ export function sharedModulesPlugin({ sharedDependencies = {}, api = H2KEXT_API_
|
|
|
109
109
|
const notOffered = declared.filter((pkg) => !offered.includes(pkg))
|
|
110
110
|
if (notOffered.length) {
|
|
111
111
|
throw new BuildError(
|
|
112
|
-
`${MANIFEST_ENTRY} declares sharedDependencies the host does not carry (${notOffered.join(
|
|
113
|
-
|
|
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
|
+
)
|
|
114
115
|
}
|
|
115
116
|
|
|
116
117
|
// The two say opposite things about the same package, so neither can be
|
|
@@ -118,19 +119,19 @@ export function sharedModulesPlugin({ sharedDependencies = {}, api = H2KEXT_API_
|
|
|
118
119
|
const both = declared.filter((pkg) => inline.includes(pkg))
|
|
119
120
|
if (both.length) {
|
|
120
121
|
throw new BuildError(
|
|
121
|
-
`${both.join(
|
|
122
|
-
|
|
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
|
+
)
|
|
123
124
|
}
|
|
124
125
|
|
|
125
126
|
const undeclared = offered.filter((pkg) => !declared.includes(pkg) && !inline.includes(pkg))
|
|
126
127
|
|
|
127
128
|
return {
|
|
128
|
-
name:
|
|
129
|
+
name: "ham2k-shared-modules",
|
|
129
130
|
setup(build) {
|
|
130
131
|
if (declared.length) {
|
|
131
132
|
build.onResolve({ filter: matcherFor(declared) }, (args) => ({
|
|
132
133
|
path: args.path,
|
|
133
|
-
namespace:
|
|
134
|
+
namespace: "ham2k-shared-module",
|
|
134
135
|
}))
|
|
135
136
|
}
|
|
136
137
|
|
|
@@ -144,19 +145,22 @@ export function sharedModulesPlugin({ sharedDependencies = {}, api = H2KEXT_API_
|
|
|
144
145
|
// esbuild's pretty printer and nothing else, so a build script that
|
|
145
146
|
// catches the error — or runs at logLevel 'silent' — shows the
|
|
146
147
|
// author the complaint with the answer stripped off.
|
|
147
|
-
errors: [
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
+
],
|
|
154
158
|
}))
|
|
155
159
|
}
|
|
156
160
|
|
|
157
|
-
build.onLoad({ filter: /.*/, namespace:
|
|
161
|
+
build.onLoad({ filter: /.*/, namespace: "ham2k-shared-module" }, (args) => ({
|
|
158
162
|
contents: `module.exports = globalThis.__polo.sharedModules[${JSON.stringify(args.path)}];`,
|
|
159
|
-
loader:
|
|
163
|
+
loader: "js",
|
|
160
164
|
}))
|
|
161
165
|
},
|
|
162
166
|
}
|
|
@@ -166,7 +170,7 @@ export function sharedModulesPlugin({ sharedDependencies = {}, api = H2KEXT_API_
|
|
|
166
170
|
/// names carry regex metacharacters (`@ham2k/lib-qson-adif` has a slash, and a
|
|
167
171
|
/// dot would be a wildcard), so every one is escaped.
|
|
168
172
|
function matcherFor(packages) {
|
|
169
|
-
const alternatives = packages.map((pkg) => pkg.replace(/[.*+?^${}()|[\]\\/]/g,
|
|
173
|
+
const alternatives = packages.map((pkg) => pkg.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&")).join("|")
|
|
170
174
|
return new RegExp(`^(${alternatives})$`)
|
|
171
175
|
}
|
|
172
176
|
|
|
@@ -186,14 +190,14 @@ function liquidBrowserBuild(fromDir) {
|
|
|
186
190
|
// `dir` is the caller's to pass — a relative one lands in the catch below
|
|
187
191
|
// and loses the alias, which is precisely the failure this function exists
|
|
188
192
|
// to prevent, arriving with nothing to point at the cause.
|
|
189
|
-
const require = createRequire(resolve(fromDir,
|
|
190
|
-
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")
|
|
191
195
|
} catch (e) {
|
|
192
196
|
// Not installed is the normal case for an extension that neither inlines
|
|
193
197
|
// liquidjs nor uses it, and there is nothing to alias. Anything else is a
|
|
194
198
|
// real fault, and swallowing it would hand the author five unresolvable
|
|
195
199
|
// node built-ins instead.
|
|
196
|
-
if (e.code ===
|
|
200
|
+
if (e.code === "MODULE_NOT_FOUND") return null
|
|
197
201
|
throw e
|
|
198
202
|
}
|
|
199
203
|
}
|
|
@@ -210,20 +214,17 @@ export function buildOptionsFor({ entry, outfile, manifest = {}, dir = process.c
|
|
|
210
214
|
entryPoints: [entry],
|
|
211
215
|
...(outfile ? { outfile } : { write: false }),
|
|
212
216
|
bundle: true,
|
|
213
|
-
format:
|
|
214
|
-
platform:
|
|
215
|
-
mainFields: [
|
|
216
|
-
target:
|
|
217
|
-
logLevel:
|
|
217
|
+
format: "iife",
|
|
218
|
+
platform: "neutral",
|
|
219
|
+
mainFields: ["module", "main"],
|
|
220
|
+
target: "es2020",
|
|
221
|
+
logLevel: "warning",
|
|
218
222
|
...rest,
|
|
219
223
|
alias: {
|
|
220
224
|
...(liquid ? { liquidjs: liquid } : {}),
|
|
221
225
|
...alias,
|
|
222
226
|
},
|
|
223
|
-
plugins: [
|
|
224
|
-
sharedModulesPlugin({ sharedDependencies: manifest.sharedDependencies, api: manifest.api, inline }),
|
|
225
|
-
...(rest.plugins ?? []),
|
|
226
|
-
],
|
|
227
|
+
plugins: [sharedModulesPlugin({ sharedDependencies: manifest.sharedDependencies, api: manifest.api, inline }), ...(rest.plugins ?? [])],
|
|
227
228
|
}
|
|
228
229
|
}
|
|
229
230
|
|
|
@@ -236,12 +237,12 @@ export function buildOptionsFor({ entry, outfile, manifest = {}, dir = process.c
|
|
|
236
237
|
///
|
|
237
238
|
/// [esbuild] is the caller's `build` function, passed in rather than imported
|
|
238
239
|
/// so this module needs no copy of its own.
|
|
239
|
-
export async function buildExtension(esbuild, { dir, entry =
|
|
240
|
-
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")
|
|
241
242
|
|
|
242
243
|
let manifest
|
|
243
244
|
try {
|
|
244
|
-
manifest = JSON.parse(await readFile(join(dir, MANIFEST_ENTRY),
|
|
245
|
+
manifest = JSON.parse(await readFile(join(dir, MANIFEST_ENTRY), "utf8"))
|
|
245
246
|
} catch (e) {
|
|
246
247
|
throw new BuildError(`could not read ${join(dir, MANIFEST_ENTRY)}: ${e.message}`)
|
|
247
248
|
}
|
|
@@ -249,13 +250,15 @@ export async function buildExtension(esbuild, { dir, entry = 'src/index.ts', out
|
|
|
249
250
|
const out = join(dir, outDir)
|
|
250
251
|
await mkdir(out, { recursive: true })
|
|
251
252
|
|
|
252
|
-
await esbuild(
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
253
|
+
await esbuild(
|
|
254
|
+
buildOptionsFor({
|
|
255
|
+
entry: join(dir, entry),
|
|
256
|
+
outfile: join(out, "index.js"),
|
|
257
|
+
manifest,
|
|
258
|
+
dir,
|
|
259
|
+
...rest,
|
|
260
|
+
}),
|
|
261
|
+
)
|
|
259
262
|
|
|
260
263
|
// The packer reads both from one directory, so the manifest the bundle was
|
|
261
264
|
// built against is the manifest that ships with it. Copied rather than left
|
package/h2kext-init.mjs
CHANGED
|
@@ -16,17 +16,17 @@
|
|
|
16
16
|
// `n0call-my-extension` VALIDATES — N0CALL is a well-formed callsign — so it
|
|
17
17
|
// would ship, which is the one outcome the naming convention exists to stop.
|
|
18
18
|
|
|
19
|
-
import { readFileSync } from
|
|
20
|
-
import { access, mkdir, readFile, writeFile } from
|
|
21
|
-
import { dirname, join, relative, resolve } from
|
|
22
|
-
import { fileURLToPath } from
|
|
19
|
+
import { readFileSync } from "node:fs"
|
|
20
|
+
import { access, mkdir, readFile, writeFile } from "node:fs/promises"
|
|
21
|
+
import { dirname, join, relative, resolve } from "node:path"
|
|
22
|
+
import { fileURLToPath } from "node:url"
|
|
23
23
|
|
|
24
|
-
import { EXAMPLE_BUILD_SCRIPT } from
|
|
25
|
-
import { H2KEXT_API_VERSION, SCAFFOLD_SHARED_DEPENDENCIES, keyProblem } from
|
|
24
|
+
import { EXAMPLE_BUILD_SCRIPT } from "./h2kext-build.mjs"
|
|
25
|
+
import { H2KEXT_API_VERSION, SCAFFOLD_SHARED_DEPENDENCIES, keyProblem } from "./h2kext.mjs"
|
|
26
26
|
|
|
27
27
|
const args = process.argv.slice(2)
|
|
28
28
|
|
|
29
|
-
if (args.length === 0 || args.includes(
|
|
29
|
+
if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
|
|
30
30
|
console.log(`Scaffold a Ham2K Logger extension.
|
|
31
31
|
|
|
32
32
|
h2kext-init <key> [--dir <path>] [--name "<display name>"]
|
|
@@ -35,10 +35,11 @@ The key is '<yourcallsign>-<name>' — e.g. ki2d-my-clock. The callsign is a
|
|
|
35
35
|
namespace you already have, globally unique, so two authors who never met
|
|
36
36
|
cannot collide. It also namespaces your extension's storage.
|
|
37
37
|
|
|
38
|
-
Writes manifest.json, src/index.ts, build.mjs, tsconfig.json, AGENTS.md
|
|
39
|
-
.gitignore into <path> (default: the working directory),
|
|
40
|
-
overwrite anything already there. An existing
|
|
41
|
-
scripts and dependencies this needs, nothing of
|
|
38
|
+
Writes manifest.json, src/index.ts, build.mjs, tsconfig.json, AGENTS.md,
|
|
39
|
+
.prettierrc.json and .gitignore into <path> (default: the working directory),
|
|
40
|
+
refusing rather than overwrite anything already there. An existing
|
|
41
|
+
package.json is added to — the scripts and dependencies this needs, nothing of
|
|
42
|
+
yours replaced.`)
|
|
42
43
|
process.exit(args.length === 0 ? 1 : 0)
|
|
43
44
|
}
|
|
44
45
|
|
|
@@ -48,9 +49,9 @@ let displayName
|
|
|
48
49
|
for (let i = 0; i < args.length; i++) {
|
|
49
50
|
// `resolve('')` is the working directory, so a bare `--dir` would scaffold
|
|
50
51
|
// wherever the author happened to be standing.
|
|
51
|
-
if (args[i] ===
|
|
52
|
-
else if (args[i] ===
|
|
53
|
-
else if (!args[i].startsWith(
|
|
52
|
+
if (args[i] === "--dir") dir = args[++i] ? resolve(args[i]) : undefined
|
|
53
|
+
else if (args[i] === "--name") displayName = args[++i]
|
|
54
|
+
else if (!args[i].startsWith("-") && key === undefined) key = args[i]
|
|
54
55
|
}
|
|
55
56
|
|
|
56
57
|
const problem = keyProblem(key)
|
|
@@ -60,16 +61,21 @@ if (problem) {
|
|
|
60
61
|
}
|
|
61
62
|
|
|
62
63
|
if (dir === undefined) {
|
|
63
|
-
console.error(
|
|
64
|
+
console.error("h2kext-init: --dir needs the directory to scaffold into")
|
|
64
65
|
process.exit(1)
|
|
65
66
|
}
|
|
66
67
|
|
|
67
|
-
const shortKey = key.slice(key.indexOf(
|
|
68
|
-
const name =
|
|
69
|
-
|
|
68
|
+
const shortKey = key.slice(key.indexOf("-") + 1)
|
|
69
|
+
const name =
|
|
70
|
+
displayName ??
|
|
71
|
+
shortKey
|
|
72
|
+
.split("-")
|
|
73
|
+
.filter(Boolean)
|
|
74
|
+
.map((w) => w[0].toUpperCase() + w.slice(1))
|
|
75
|
+
.join(" ")
|
|
70
76
|
|
|
71
77
|
const here = dirname(fileURLToPath(import.meta.url))
|
|
72
|
-
const { version: toolsVersion } = JSON.parse(await readFile(join(here,
|
|
78
|
+
const { version: toolsVersion } = JSON.parse(await readFile(join(here, "package.json"), "utf8"))
|
|
73
79
|
|
|
74
80
|
/// The SDK is a SEPARATE package on its own version line, so this cannot be
|
|
75
81
|
/// answered with the version of the tools that happen to be running: a
|
|
@@ -93,8 +99,8 @@ function sdkRange(projectDir) {
|
|
|
93
99
|
for (const start of [projectDir, here]) {
|
|
94
100
|
for (let dir = start; ; dir = dirname(dir)) {
|
|
95
101
|
try {
|
|
96
|
-
const path = join(dir,
|
|
97
|
-
const { version } = JSON.parse(readFileSync(path,
|
|
102
|
+
const path = join(dir, "node_modules/@ham2k/extension-sdk/package.json")
|
|
103
|
+
const { version } = JSON.parse(readFileSync(path, "utf8"))
|
|
98
104
|
if (version) return `^${version}`
|
|
99
105
|
} catch {
|
|
100
106
|
// Not here; walk up to the next node_modules.
|
|
@@ -102,63 +108,91 @@ function sdkRange(projectDir) {
|
|
|
102
108
|
if (dirname(dir) === dir) break
|
|
103
109
|
}
|
|
104
110
|
}
|
|
105
|
-
return
|
|
111
|
+
return "latest"
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/// One object of a JSON file, indented as Prettier indents it. Prettier keeps
|
|
115
|
+
/// an object expanded when it was written expanded, so nesting one of these
|
|
116
|
+
/// inside a template is stable at any width; an ARRAY it re-decides by width,
|
|
117
|
+
/// which is why every array below is written inline by hand rather than
|
|
118
|
+
/// stringified. Held by a test that formats everything the scaffold writes
|
|
119
|
+
/// and compares it with what Prettier would have produced.
|
|
120
|
+
function nestedJson(value, indent) {
|
|
121
|
+
return JSON.stringify(value, null, 2).replaceAll("\n", `\n${indent}`)
|
|
106
122
|
}
|
|
107
123
|
|
|
108
124
|
/// What the project's package.json has to say for the build, the packer and
|
|
109
125
|
/// the typecheck to run.
|
|
110
126
|
const projectPackage = {
|
|
111
127
|
name: key,
|
|
112
|
-
version:
|
|
128
|
+
version: "0.1.0",
|
|
113
129
|
private: true,
|
|
114
|
-
type:
|
|
130
|
+
type: "module",
|
|
115
131
|
scripts: {
|
|
116
|
-
build:
|
|
117
|
-
pack:
|
|
118
|
-
typecheck:
|
|
132
|
+
build: "node build.mjs",
|
|
133
|
+
pack: "h2kext-pack build",
|
|
134
|
+
typecheck: "tsc --noEmit",
|
|
135
|
+
format: "prettier --write .",
|
|
136
|
+
"format:check": "prettier --check .",
|
|
119
137
|
},
|
|
120
138
|
dependencies: {
|
|
121
|
-
|
|
139
|
+
"@ham2k/extension-sdk": sdkRange(dir),
|
|
122
140
|
},
|
|
123
141
|
devDependencies: {
|
|
124
|
-
|
|
125
|
-
esbuild:
|
|
126
|
-
|
|
142
|
+
"@ham2k/extension-tools": `^${toolsVersion}`,
|
|
143
|
+
esbuild: "^0.25.0",
|
|
144
|
+
// A patch range, not a caret: Prettier changes its output in a MINOR, and
|
|
145
|
+
// the templates below are checked against one version here. A caret would
|
|
146
|
+
// let an author install a 3.x that formats differently and fail the first
|
|
147
|
+
// `format:check` on files they never touched.
|
|
148
|
+
prettier: "~3.9.0",
|
|
149
|
+
typescript: "^5.6.0",
|
|
127
150
|
},
|
|
128
151
|
}
|
|
129
152
|
|
|
130
153
|
const files = {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
154
|
+
// `hooks` is what the Extensions list reads without evaluating any bundle,
|
|
155
|
+
// and the Add-Panel route shows only what declares `panel`. Undeclared, a
|
|
156
|
+
// panel extension installs and then cannot be found or switched on.
|
|
157
|
+
"manifest.json": `{
|
|
158
|
+
"key": ${JSON.stringify(key)},
|
|
159
|
+
"name": ${JSON.stringify(name)},
|
|
160
|
+
"version": "0.1.0",
|
|
161
|
+
"description": ${JSON.stringify(`${name}, an extension for the Ham2K Logger`)},
|
|
162
|
+
"category": "dashboard",
|
|
163
|
+
"api": ${JSON.stringify(H2KEXT_API_VERSION)},
|
|
164
|
+
"icon": "hand-wave-outline",
|
|
165
|
+
"hooks": ["panel"],
|
|
166
|
+
"sharedDependencies": ${nestedJson(SCAFFOLD_SHARED_DEPENDENCIES, " ")}
|
|
167
|
+
}
|
|
168
|
+
`,
|
|
169
|
+
|
|
170
|
+
"build.mjs": EXAMPLE_BUILD_SCRIPT,
|
|
171
|
+
|
|
172
|
+
"src/index.ts": `// ${name} — a panel that says hello.
|
|
149
173
|
//
|
|
150
174
|
// Everything here is the minimum: one hook, one descriptor, one render. See
|
|
151
175
|
// node_modules/@ham2k/extension-sdk/AGENTS.md for what to read next.
|
|
152
176
|
|
|
153
|
-
import { defineExtension } from
|
|
154
|
-
import type { HookContext, PanelContent, PanelDescriptor, PanelHook, PanelRenderArgs } from
|
|
177
|
+
import { defineExtension } from "@ham2k/extension-sdk"
|
|
178
|
+
import type { HookContext, PanelContent, PanelDescriptor, PanelHook, PanelRenderArgs } from "@ham2k/extension-sdk"
|
|
155
179
|
|
|
156
|
-
import manifest from
|
|
180
|
+
import manifest from "../manifest.json"
|
|
157
181
|
|
|
158
182
|
const HelloPanel: PanelHook = {
|
|
159
183
|
/// What the operator sees when choosing a panel to place.
|
|
160
184
|
async getPanels(_args: Record<string, never>, _ctx: HookContext): Promise<PanelDescriptor[]> {
|
|
161
|
-
|
|
185
|
+
// Written a field per line, and left that way by the formatter: on one
|
|
186
|
+
// line a long enough --name would push it past the width and the
|
|
187
|
+
// scaffold's own \`format:check\` would fail on untouched code.
|
|
188
|
+
return [
|
|
189
|
+
{
|
|
190
|
+
key: "hello",
|
|
191
|
+
title: ${JSON.stringify(name)},
|
|
192
|
+
description: "A panel that says hello",
|
|
193
|
+
icon: "hand-wave-outline",
|
|
194
|
+
},
|
|
195
|
+
]
|
|
162
196
|
},
|
|
163
197
|
|
|
164
198
|
/// What the pane shows. A panel returns CONTENT, never widgets — which is
|
|
@@ -168,37 +202,38 @@ const HelloPanel: PanelHook = {
|
|
|
168
202
|
/// its registered key and then calls this by name, so a near-miss is a panel
|
|
169
203
|
/// that installs, activates, and then renders nothing.
|
|
170
204
|
async render(_args: PanelRenderArgs, _ctx: HookContext): Promise<PanelContent> {
|
|
171
|
-
return { kind:
|
|
205
|
+
return { kind: "markdown", content: "# Hello, World!" }
|
|
172
206
|
},
|
|
173
207
|
}
|
|
174
208
|
|
|
175
209
|
defineExtension({
|
|
176
210
|
...manifest,
|
|
177
211
|
onActivation({ registerHook }) {
|
|
178
|
-
registerHook(
|
|
212
|
+
registerHook("panel", { key: "${key}", hook: HelloPanel })
|
|
179
213
|
},
|
|
180
214
|
})
|
|
181
215
|
`,
|
|
182
216
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
217
|
+
"tsconfig.json": `{
|
|
218
|
+
"compilerOptions": {
|
|
219
|
+
"target": "ES2020",
|
|
220
|
+
"module": "ESNext",
|
|
221
|
+
"moduleResolution": "bundler",
|
|
222
|
+
"lib": ["ES2020"],
|
|
223
|
+
"strict": true,
|
|
224
|
+
"noEmit": true,
|
|
225
|
+
"skipLibCheck": true,
|
|
226
|
+
"resolveJsonModule": true
|
|
227
|
+
},
|
|
228
|
+
"include": ["src/**/*.ts"]
|
|
229
|
+
}
|
|
230
|
+
`,
|
|
196
231
|
|
|
197
232
|
// The whole point of the file. Claude Code and its siblings read a project's
|
|
198
233
|
// AGENTS.md before they touch anything, so this is what carries the hook
|
|
199
234
|
// reference, the sandbox's limits and the manifest's shape into the session
|
|
200
235
|
// — none of which anyone would think to go looking for in node_modules.
|
|
201
|
-
|
|
236
|
+
"AGENTS.md": `# ${name}
|
|
202
237
|
|
|
203
238
|
An extension for the Ham2K Logger. How to write one, what the hooks are, and
|
|
204
239
|
what the sandbox will and will not let you do:
|
|
@@ -206,14 +241,24 @@ what the sandbox will and will not let you do:
|
|
|
206
241
|
@node_modules/@ham2k/extension-sdk/AGENTS.md
|
|
207
242
|
`,
|
|
208
243
|
|
|
209
|
-
|
|
244
|
+
// The style the scaffolded files are already in, so `npm run format:check`
|
|
245
|
+
// passes on an untouched project. Prettier reads the project's .gitignore
|
|
246
|
+
// from its working directory, so build/ and the packed .h2kext are already
|
|
247
|
+
// out of its way and no .prettierignore is needed.
|
|
248
|
+
".prettierrc.json": `{
|
|
249
|
+
"printWidth": 160,
|
|
250
|
+
"semi": false
|
|
251
|
+
}
|
|
252
|
+
`,
|
|
253
|
+
|
|
254
|
+
".gitignore": "node_modules/\nbuild/\n*.h2kext\n",
|
|
210
255
|
}
|
|
211
256
|
|
|
212
257
|
// Reported together rather than one per run: a scaffold that stops at the
|
|
213
258
|
// first collision has already written half a project, and the second attempt
|
|
214
259
|
// then collides with its own output.
|
|
215
|
-
const packagePath = join(dir,
|
|
216
|
-
const currentPackage = await readFile(packagePath,
|
|
260
|
+
const packagePath = join(dir, "package.json")
|
|
261
|
+
const currentPackage = await readFile(packagePath, "utf8").then(
|
|
217
262
|
(text) => {
|
|
218
263
|
try {
|
|
219
264
|
return JSON.parse(text)
|
|
@@ -225,27 +270,71 @@ const currentPackage = await readFile(packagePath, 'utf8').then(
|
|
|
225
270
|
process.exit(1)
|
|
226
271
|
}
|
|
227
272
|
},
|
|
228
|
-
() => ({})
|
|
273
|
+
() => ({}),
|
|
274
|
+
)
|
|
229
275
|
|
|
230
276
|
// `null`, `[]` and `"text"` all parse, and none of them is something to merge
|
|
231
277
|
// into — reached at the end, each is a raw TypeError over six written files.
|
|
232
|
-
if (currentPackage === null || typeof currentPackage !==
|
|
278
|
+
if (currentPackage === null || typeof currentPackage !== "object" || Array.isArray(currentPackage)) {
|
|
233
279
|
console.error(`h2kext-init: ${packagePath} is not a package description — nothing written`)
|
|
234
280
|
process.exit(1)
|
|
235
281
|
}
|
|
236
282
|
|
|
283
|
+
// Prettier reads the FIRST config it finds, and a `prettier` key in the
|
|
284
|
+
// package.json already here outranks a `.prettierrc.json` sitting beside it —
|
|
285
|
+
// so writing ours would leave a file that is never read, under templates
|
|
286
|
+
// formatted to a style the author's own config contradicts. Their config
|
|
287
|
+
// wins; this one is dropped, and said out loud, because the alternative is a
|
|
288
|
+
// project whose first `npm run format` rewrites every scaffolded file.
|
|
289
|
+
const PRETTIER_CONFIG_FILES = [
|
|
290
|
+
".prettierrc",
|
|
291
|
+
".prettierrc.json",
|
|
292
|
+
".prettierrc.json5",
|
|
293
|
+
".prettierrc.yaml",
|
|
294
|
+
".prettierrc.yml",
|
|
295
|
+
".prettierrc.js",
|
|
296
|
+
".prettierrc.mjs",
|
|
297
|
+
".prettierrc.cjs",
|
|
298
|
+
".prettierrc.toml",
|
|
299
|
+
"prettier.config.js",
|
|
300
|
+
"prettier.config.mjs",
|
|
301
|
+
"prettier.config.cjs",
|
|
302
|
+
]
|
|
303
|
+
const foundAt = async (path) =>
|
|
304
|
+
await access(join(dir, path)).then(
|
|
305
|
+
() => true,
|
|
306
|
+
() => false,
|
|
307
|
+
)
|
|
308
|
+
let foreignPrettierConfig
|
|
309
|
+
if (currentPackage.prettier !== undefined) foreignPrettierConfig = "the `prettier` key in package.json"
|
|
310
|
+
else
|
|
311
|
+
for (const path of PRETTIER_CONFIG_FILES) {
|
|
312
|
+
if (path !== ".prettierrc.json" && (await foundAt(path))) {
|
|
313
|
+
foreignPrettierConfig = path
|
|
314
|
+
break
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
if (foreignPrettierConfig) delete files[".prettierrc.json"]
|
|
318
|
+
|
|
237
319
|
// `access`, not a read: a directory where a file should go, or one this
|
|
238
320
|
// process cannot read, is something in the way — treated as absent, it becomes
|
|
239
321
|
// a write loop that fails half-way through.
|
|
240
322
|
const existing = []
|
|
241
323
|
for (const path of Object.keys(files)) {
|
|
242
|
-
if (
|
|
324
|
+
if (
|
|
325
|
+
await access(join(dir, path)).then(
|
|
326
|
+
() => true,
|
|
327
|
+
() => false,
|
|
328
|
+
)
|
|
329
|
+
)
|
|
330
|
+
existing.push(path)
|
|
243
331
|
}
|
|
244
332
|
if (existing.length) {
|
|
245
333
|
const where = relative(process.cwd(), dir)
|
|
246
334
|
console.error(
|
|
247
|
-
`h2kext-init: ${where.startsWith(
|
|
248
|
-
|
|
335
|
+
`h2kext-init: ${where.startsWith("..") ? dir : where || "."} already holds ${existing.join(", ")} — ` +
|
|
336
|
+
"nothing written. Scaffold into an empty directory with --dir.",
|
|
337
|
+
)
|
|
249
338
|
process.exit(1)
|
|
250
339
|
}
|
|
251
340
|
|
|
@@ -262,15 +351,24 @@ for (const [path, content] of Object.entries(files)) {
|
|
|
262
351
|
// Filled in key by key, never overwritten: what the author already declared —
|
|
263
352
|
// their own name for the project, a version of esbuild they chose — is theirs.
|
|
264
353
|
for (const [field, value] of Object.entries(projectPackage)) {
|
|
265
|
-
if (typeof value !==
|
|
354
|
+
if (typeof value !== "object") currentPackage[field] ??= value
|
|
266
355
|
else for (const [name, entry] of Object.entries(value)) (currentPackage[field] ??= {})[name] ??= entry
|
|
267
356
|
}
|
|
268
357
|
await writeFile(packagePath, `${JSON.stringify(currentPackage, null, 2)}\n`)
|
|
269
358
|
|
|
270
359
|
console.log(`${key} — ${name}
|
|
271
360
|
|
|
272
|
-
${[...Object.keys(files),
|
|
273
|
-
|
|
361
|
+
${[...Object.keys(files), "package.json"].join("\n ")}
|
|
362
|
+
${
|
|
363
|
+
foreignPrettierConfig
|
|
364
|
+
? `
|
|
365
|
+
No .prettierrc.json written — ${foreignPrettierConfig} already configures
|
|
366
|
+
Prettier here, and it is the one Prettier reads. The scaffolded files are in
|
|
367
|
+
the style this command's own config describes (print width 160, no
|
|
368
|
+
semicolons); \`npm run format\` will restyle them to yours.
|
|
369
|
+
`
|
|
370
|
+
: ""
|
|
371
|
+
}
|
|
274
372
|
Next:
|
|
275
373
|
npm install
|
|
276
374
|
npm run build && npm run pack`)
|
package/h2kext-pack.mjs
CHANGED
|
@@ -10,11 +10,11 @@
|
|
|
10
10
|
// The directory holds a built `index.js`, a `manifest.json`, and optionally an
|
|
11
11
|
// `assets/` folder. Nothing here compiles anything — see h2kext.mjs.
|
|
12
12
|
|
|
13
|
-
import { pack, PackError, H2KEXT_API_VERSION, RESERVED_PREFIX } from
|
|
13
|
+
import { pack, PackError, H2KEXT_API_VERSION, RESERVED_PREFIX } from "./h2kext.mjs"
|
|
14
14
|
|
|
15
15
|
const args = process.argv.slice(2)
|
|
16
16
|
|
|
17
|
-
if (args.length === 0 || args.includes(
|
|
17
|
+
if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
|
|
18
18
|
console.log(`Package an extension directory into a .h2kext bundle.
|
|
19
19
|
|
|
20
20
|
h2kext-pack <directory> [-o <out.h2kext>] [--force-name]
|
|
@@ -37,19 +37,19 @@ Defaults to <key>-<version>.h2kext in the working directory.`)
|
|
|
37
37
|
|
|
38
38
|
// Walked by index rather than matched by value: `args.indexOf(a)` finds the
|
|
39
39
|
// FIRST occurrence, so `-o foo foo` mistook the output path for the directory.
|
|
40
|
-
const forceName = args.includes(
|
|
40
|
+
const forceName = args.includes("--force-name")
|
|
41
41
|
let dir
|
|
42
42
|
let outPath
|
|
43
43
|
for (let i = 0; i < args.length; i++) {
|
|
44
|
-
if (args[i] ===
|
|
44
|
+
if (args[i] === "-o") {
|
|
45
45
|
outPath = args[++i]
|
|
46
|
-
} else if (!args[i].startsWith(
|
|
46
|
+
} else if (!args[i].startsWith("-") && dir === undefined) {
|
|
47
47
|
dir = args[i]
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
if (!dir) {
|
|
52
|
-
console.error(
|
|
52
|
+
console.error("h2kext-pack: name the directory to package")
|
|
53
53
|
process.exit(1)
|
|
54
54
|
}
|
|
55
55
|
|
|
@@ -58,11 +58,14 @@ try {
|
|
|
58
58
|
const assets = result.files.length - 2
|
|
59
59
|
console.log(`${result.outPath}`)
|
|
60
60
|
console.log(` ${result.manifest.key} ${result.manifest.version} — ${result.manifest.name}`)
|
|
61
|
-
console.log(` ${assets} asset${assets === 1 ?
|
|
61
|
+
console.log(` ${assets} asset${assets === 1 ? "" : "s"}`)
|
|
62
62
|
if (result.manifest.domains?.length) {
|
|
63
63
|
// Named on the way out, because this is the one part of the manifest the
|
|
64
64
|
// person installing it will be asked to approve.
|
|
65
|
-
console.log(` may reach: ${result.manifest.domains.join(
|
|
65
|
+
console.log(` may reach: ${result.manifest.domains.join(", ")}`)
|
|
66
|
+
}
|
|
67
|
+
if (result.manifest.webSockets?.length) {
|
|
68
|
+
console.log(` may hold connections to: ${result.manifest.webSockets.join(", ")}`)
|
|
66
69
|
}
|
|
67
70
|
} catch (e) {
|
|
68
71
|
if (e instanceof PackError) {
|
package/h2kext-publish.mjs
CHANGED
|
@@ -16,27 +16,27 @@
|
|
|
16
16
|
// never written anywhere — not to a config file, not to the terminal, not into
|
|
17
17
|
// an error message.
|
|
18
18
|
|
|
19
|
-
import { realpathSync } from
|
|
20
|
-
import { mkdtemp, readFile, rm, stat } from
|
|
21
|
-
import { tmpdir } from
|
|
22
|
-
import { join } from
|
|
23
|
-
import { pathToFileURL } from
|
|
24
|
-
import { inflateRawSync } from
|
|
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
25
|
|
|
26
|
-
import { MANIFEST_ENTRY, pack, PackError } from
|
|
26
|
+
import { MANIFEST_ENTRY, pack, PackError } from "./h2kext.mjs"
|
|
27
27
|
|
|
28
|
-
export const DEFAULT_CATALOG =
|
|
29
|
-
export const PUBLISH_PATH =
|
|
30
|
-
export const SUBMISSIONS_PATH =
|
|
31
|
-
export const TOKEN_ENV =
|
|
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
32
|
/// How finished a release is, steadiest first — NOT an app edition. The
|
|
33
33
|
/// catalog cascades, so a release on `stable` is visible to everyone and one
|
|
34
34
|
/// on `bleeding` only to somebody who asked for it.
|
|
35
|
-
export const CHANNELS = [
|
|
35
|
+
export const CHANNELS = ["stable", "unstable", "bleeding"]
|
|
36
36
|
|
|
37
37
|
/// What `--channel` means when it is not given. Publishing without saying is
|
|
38
38
|
/// publishing something finished; anything rougher is a choice worth typing.
|
|
39
|
-
export const DEFAULT_CHANNEL =
|
|
39
|
+
export const DEFAULT_CHANNEL = "stable"
|
|
40
40
|
|
|
41
41
|
/// The catalog refuses a larger upload before reading it (§5.1 step 2),
|
|
42
42
|
/// because the app never downloads more than this (`kMaxDownloadBytes`).
|
|
@@ -51,7 +51,7 @@ export const EXIT_USAGE = 2
|
|
|
51
51
|
/// How a submission status reads on the result line. `pending` means a
|
|
52
52
|
/// reviewer has not looked yet; saying "review" is what stops an author
|
|
53
53
|
/// waiting for the extension to appear.
|
|
54
|
-
const STATUS_WORDS = { pending:
|
|
54
|
+
const STATUS_WORDS = { pending: "pending review" }
|
|
55
55
|
|
|
56
56
|
export class PublishError extends Error {
|
|
57
57
|
constructor(message, exitCode = EXIT_REFUSED) {
|
|
@@ -94,7 +94,7 @@ or cannot be reached, 2 for a usage problem.`
|
|
|
94
94
|
export function readManifestFromBundle(bytes) {
|
|
95
95
|
const buf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes)
|
|
96
96
|
if (buf.length < 22 || buf.readUInt32LE(0) !== 0x04034b50) {
|
|
97
|
-
throw new PublishError(
|
|
97
|
+
throw new PublishError("not a .h2kext: not a zip")
|
|
98
98
|
}
|
|
99
99
|
// The end-of-central-directory record is the last 22 bytes plus a comment of
|
|
100
100
|
// up to 64 KiB, so the search walks backwards over that much and no more.
|
|
@@ -105,13 +105,13 @@ export function readManifestFromBundle(bytes) {
|
|
|
105
105
|
break
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
|
-
if (eocd < 0) throw new PublishError(
|
|
108
|
+
if (eocd < 0) throw new PublishError("not a .h2kext: no zip central directory")
|
|
109
109
|
|
|
110
110
|
const count = buf.readUInt16LE(eocd + 10)
|
|
111
111
|
let offset = buf.readUInt32LE(eocd + 16)
|
|
112
112
|
for (let n = 0; n < count; n++) {
|
|
113
113
|
if (offset + 46 > buf.length || buf.readUInt32LE(offset) !== 0x02014b50) {
|
|
114
|
-
throw new PublishError(
|
|
114
|
+
throw new PublishError("not a .h2kext: damaged zip central directory")
|
|
115
115
|
}
|
|
116
116
|
const method = buf.readUInt16LE(offset + 10)
|
|
117
117
|
const compressedSize = buf.readUInt32LE(offset + 20)
|
|
@@ -119,7 +119,7 @@ export function readManifestFromBundle(bytes) {
|
|
|
119
119
|
const extraLength = buf.readUInt16LE(offset + 30)
|
|
120
120
|
const commentLength = buf.readUInt16LE(offset + 32)
|
|
121
121
|
const localOffset = buf.readUInt32LE(offset + 42)
|
|
122
|
-
const name = buf.toString(
|
|
122
|
+
const name = buf.toString("utf8", offset + 46, offset + 46 + nameLength)
|
|
123
123
|
if (name === MANIFEST_ENTRY) {
|
|
124
124
|
// Every offset here comes from the file itself, so a truncated or
|
|
125
125
|
// doctored bundle can point past its own end: refused as a bundle,
|
|
@@ -139,7 +139,7 @@ export function readManifestFromBundle(bytes) {
|
|
|
139
139
|
}
|
|
140
140
|
} else throw new PublishError(`not a .h2kext: ${MANIFEST_ENTRY} uses zip compression method ${method}`)
|
|
141
141
|
try {
|
|
142
|
-
return JSON.parse(raw.toString(
|
|
142
|
+
return JSON.parse(raw.toString("utf8"))
|
|
143
143
|
} catch (e) {
|
|
144
144
|
throw new PublishError(`${MANIFEST_ENTRY} inside the bundle is not valid JSON: ${e.message}`)
|
|
145
145
|
}
|
|
@@ -157,13 +157,13 @@ async function loadBundle(source, { forceName }) {
|
|
|
157
157
|
try {
|
|
158
158
|
info = await stat(source)
|
|
159
159
|
} catch (e) {
|
|
160
|
-
throw new PublishError(`${source}: ${e.code ===
|
|
160
|
+
throw new PublishError(`${source}: ${e.code === "ENOENT" ? "no such file or directory" : e.message}`)
|
|
161
161
|
}
|
|
162
162
|
if (!info.isDirectory()) return await readFile(source)
|
|
163
163
|
|
|
164
|
-
const tmp = await mkdtemp(join(tmpdir(),
|
|
164
|
+
const tmp = await mkdtemp(join(tmpdir(), "h2kext-publish-"))
|
|
165
165
|
try {
|
|
166
|
-
const { outPath } = await pack(source, { outPath: join(tmp,
|
|
166
|
+
const { outPath } = await pack(source, { outPath: join(tmp, "bundle.h2kext"), forceName })
|
|
167
167
|
return await readFile(outPath)
|
|
168
168
|
} catch (e) {
|
|
169
169
|
if (e instanceof PackError) throw new PublishError(`${source} is not ready to publish:\n${e.message}`)
|
|
@@ -195,9 +195,7 @@ async function call(fetch, catalog, path, init) {
|
|
|
195
195
|
/// What a refusal says: the server's `error` and `code`, or its raw body when
|
|
196
196
|
/// it sent no JSON — a proxy's HTML is still worth seeing.
|
|
197
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'
|
|
198
|
+
return body?.error ? `${body.error}${body.code ? ` (${body.code})` : ""}` : text.trim() || response.statusText || "no response body"
|
|
201
199
|
}
|
|
202
200
|
|
|
203
201
|
/// Uploads [bytes] as the raw request body (§5.1: no multipart) and
|
|
@@ -208,8 +206,8 @@ function refusal({ response, text, body }) {
|
|
|
208
206
|
/// failure, not the catalog's.
|
|
209
207
|
export async function publish({ bytes, manifest, channel, catalog, token }, { fetch = globalThis.fetch } = {}) {
|
|
210
208
|
const answer = await call(fetch, catalog, `${PUBLISH_PATH}?channel=${channel}`, {
|
|
211
|
-
method:
|
|
212
|
-
headers: { authorization: `Bearer ${token}`,
|
|
209
|
+
method: "POST",
|
|
210
|
+
headers: { authorization: `Bearer ${token}`, "content-type": "application/zip" },
|
|
213
211
|
body: bytes,
|
|
214
212
|
})
|
|
215
213
|
if (answer.response.status === 201 && answer.body) return answer.body
|
|
@@ -220,8 +218,8 @@ export async function publish({ bytes, manifest, channel, catalog, token }, { fe
|
|
|
220
218
|
/// outside 2xx is a [PublishError]; the submission it was for stands.
|
|
221
219
|
export async function setReleaseNotes({ submissionId, notes, catalog, token }, { fetch = globalThis.fetch } = {}) {
|
|
222
220
|
const answer = await call(fetch, catalog, `${SUBMISSIONS_PATH}/${encodeURIComponent(submissionId)}/notes`, {
|
|
223
|
-
method:
|
|
224
|
-
headers: { authorization: `Bearer ${token}`,
|
|
221
|
+
method: "PUT",
|
|
222
|
+
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
|
|
225
223
|
body: JSON.stringify({ releaseNotes: notes }),
|
|
226
224
|
})
|
|
227
225
|
if (answer.response.ok) return
|
|
@@ -231,7 +229,7 @@ export async function setReleaseNotes({ submissionId, notes, catalog, token }, {
|
|
|
231
229
|
/// The command. Returns the exit code rather than exiting, so the same code
|
|
232
230
|
/// runs under a test with a fake `fetch` and captured output.
|
|
233
231
|
export async function main(argv, { env = process.env, fetch = globalThis.fetch, stdout = process.stdout, stderr = process.stderr } = {}) {
|
|
234
|
-
if (argv.length === 0 || argv.includes(
|
|
232
|
+
if (argv.length === 0 || argv.includes("--help") || argv.includes("-h")) {
|
|
235
233
|
stdout.write(`${USAGE}\n`)
|
|
236
234
|
return argv.length === 0 ? EXIT_USAGE : 0
|
|
237
235
|
}
|
|
@@ -240,7 +238,7 @@ export async function main(argv, { env = process.env, fetch = globalThis.fetch,
|
|
|
240
238
|
|
|
241
239
|
// Every line out goes through this, so the token cannot reach a terminal or
|
|
242
240
|
// 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(
|
|
241
|
+
const redact = (s) => (token ? String(s).split(token).join("<token>") : String(s))
|
|
244
242
|
const say = (s) => stdout.write(`${redact(s)}\n`)
|
|
245
243
|
const complain = (s) => stderr.write(`h2kext-publish: ${redact(s)}\n`)
|
|
246
244
|
|
|
@@ -264,40 +262,42 @@ export async function main(argv, { env = process.env, fetch = globalThis.fetch,
|
|
|
264
262
|
}
|
|
265
263
|
for (let i = 0; i < argv.length; i++) {
|
|
266
264
|
const arg = argv[i]
|
|
267
|
-
if (arg ===
|
|
268
|
-
else if (arg ===
|
|
269
|
-
else if (arg ===
|
|
270
|
-
else if (arg ===
|
|
271
|
-
else if (arg ===
|
|
272
|
-
else if (arg.startsWith(
|
|
265
|
+
if (arg === "--channel") channel = valueOf(i++)
|
|
266
|
+
else if (arg === "--notes") notes = valueOf(i++)
|
|
267
|
+
else if (arg === "--catalog") catalog = valueOf(i++)
|
|
268
|
+
else if (arg === "--force-name") forceName = true
|
|
269
|
+
else if (arg === "--token") throw new PublishError(`there is no --token option; set ${TOKEN_ENV} instead`, EXIT_USAGE)
|
|
270
|
+
else if (arg.startsWith("-")) throw new PublishError(`unknown option '${arg}'`, EXIT_USAGE)
|
|
273
271
|
else if (source === undefined) source = arg
|
|
274
272
|
}
|
|
275
273
|
|
|
276
|
-
if (!source) throw new PublishError(
|
|
274
|
+
if (!source) throw new PublishError("name the bundle or directory to publish", EXIT_USAGE)
|
|
277
275
|
channel ??= DEFAULT_CHANNEL
|
|
278
276
|
if (!CHANNELS.includes(channel)) {
|
|
279
|
-
throw new PublishError(`--channel must be one of ${CHANNELS.join(
|
|
277
|
+
throw new PublishError(`--channel must be one of ${CHANNELS.join(", ")}${channel ? `, not '${channel}'` : ""}`, EXIT_USAGE)
|
|
280
278
|
}
|
|
281
279
|
if (!token) {
|
|
282
280
|
throw new PublishError(`no token — set ${TOKEN_ENV} (get one at ${catalog}/publish)`, EXIT_USAGE)
|
|
283
281
|
}
|
|
284
282
|
try {
|
|
285
|
-
catalog = new URL(catalog).href.replace(/\/$/,
|
|
283
|
+
catalog = new URL(catalog).href.replace(/\/$/, "")
|
|
286
284
|
} catch {
|
|
287
285
|
throw new PublishError(`--catalog must be a URL, not '${catalog}'`, EXIT_USAGE)
|
|
288
286
|
}
|
|
289
287
|
|
|
290
288
|
const bytes = await loadBundle(source, { forceName })
|
|
291
289
|
if (bytes.length > MAX_BUNDLE_BYTES) {
|
|
292
|
-
throw new PublishError(
|
|
290
|
+
throw new PublishError(
|
|
291
|
+
`${source} is ${bytes.length} bytes; the catalog takes at most 16 MiB (${MAX_BUNDLE_BYTES} bytes), because the app downloads no more than that`,
|
|
292
|
+
)
|
|
293
293
|
}
|
|
294
294
|
const manifest = readManifestFromBundle(bytes)
|
|
295
|
-
for (const field of [
|
|
296
|
-
if (typeof manifest[field] !==
|
|
295
|
+
for (const field of ["key", "version"]) {
|
|
296
|
+
if (typeof manifest[field] !== "string" || manifest[field].trim() === "") {
|
|
297
297
|
throw new PublishError(`${MANIFEST_ENTRY} inside the bundle has no '${field}'`)
|
|
298
298
|
}
|
|
299
299
|
}
|
|
300
|
-
say(`publishing ${manifest.key} ${manifest.version}${manifest.name ? ` — ${manifest.name}` :
|
|
300
|
+
say(`publishing ${manifest.key} ${manifest.version}${manifest.name ? ` — ${manifest.name}` : ""} → ${channel} at ${catalog}`)
|
|
301
301
|
|
|
302
302
|
const result = await publish({ bytes, manifest, channel, catalog, token }, { fetch })
|
|
303
303
|
say(`published ${result.key} ${result.version} → ${result.channel} (${STATUS_WORDS[result.status] ?? result.status})`)
|
package/h2kext.mjs
CHANGED
|
@@ -16,16 +16,25 @@
|
|
|
16
16
|
// one way it can be built WRONG is invisible until the extension is running
|
|
17
17
|
// inside somebody's app.
|
|
18
18
|
|
|
19
|
-
import { createWriteStream } from
|
|
20
|
-
import { readFile, readdir, stat } from
|
|
21
|
-
import { join, posix, relative, sep } from
|
|
22
|
-
import { parseCallsign } from
|
|
23
|
-
import yazl from
|
|
19
|
+
import { createWriteStream } from "node:fs"
|
|
20
|
+
import { readFile, readdir, stat } from "node:fs/promises"
|
|
21
|
+
import { join, posix, relative, sep } from "node:path"
|
|
22
|
+
import { parseCallsign } from "@ham2k/lib-callsigns"
|
|
23
|
+
import yazl from "yazl"
|
|
24
24
|
|
|
25
25
|
/// The extension API this format describes. A bundle declares the version it
|
|
26
26
|
/// was built against and the host refuses one it does not speak, so an old app
|
|
27
27
|
/// meeting a newer bundle says so instead of failing at a call site.
|
|
28
|
-
|
|
28
|
+
///
|
|
29
|
+
/// Raised when a bundle using something new could not work at all on an app
|
|
30
|
+
/// without it — not for every addition. 2 adds `host.webSocket` and the
|
|
31
|
+
/// manifest's `webSockets` grant.
|
|
32
|
+
export const H2KEXT_API_VERSION = 2
|
|
33
|
+
|
|
34
|
+
/// The first API with `host.webSocket`. A manifest declaring `webSockets`
|
|
35
|
+
/// under an older one is refused: an older app would install it and fail only
|
|
36
|
+
/// when it tried to connect.
|
|
37
|
+
export const WEB_SOCKETS_API_VERSION = 2
|
|
29
38
|
|
|
30
39
|
/// `<prefix>-<name>`, all lowercase. The shape is what the kernel's
|
|
31
40
|
/// `bundleMayDefine` prefix rule can reason about; the PREFIX is what keeps two
|
|
@@ -36,7 +45,7 @@ export const KEY_PATTERN = /^[a-z0-9]+-[a-z0-9-]*[a-z0-9]$/
|
|
|
36
45
|
/// bundle unless it is one of ours: there is no signing yet, so anyone could
|
|
37
46
|
/// claim it, and a key is what the app shows when it says where a hook came
|
|
38
47
|
/// from.
|
|
39
|
-
export const RESERVED_PREFIX =
|
|
48
|
+
export const RESERVED_PREFIX = "ham2k"
|
|
40
49
|
|
|
41
50
|
/// Whether [prefix] is a real amateur callsign — the namespace every author of
|
|
42
51
|
/// this software already has, globally unique, and free.
|
|
@@ -66,14 +75,14 @@ export const SAFE_KEY_PATTERN = /^[a-z0-9][a-z0-9-]*$/
|
|
|
66
75
|
/// app is where the rule bites, refusing a reserved or colliding key at
|
|
67
76
|
/// install.
|
|
68
77
|
export function keyProblem(key, { forceName = false } = {}) {
|
|
69
|
-
if (typeof key !==
|
|
78
|
+
if (typeof key !== "string" || !SAFE_KEY_PATTERN.test(key)) {
|
|
70
79
|
return `key '${key}' must be lowercase, and contain only letters, digits and hyphens`
|
|
71
80
|
}
|
|
72
81
|
if (forceName) return null
|
|
73
82
|
if (!KEY_PATTERN.test(key)) {
|
|
74
83
|
return `key '${key}' must be '<callsign>-<name>' (e.g. ki2d-my-clock)`
|
|
75
84
|
}
|
|
76
|
-
const prefix = key.slice(0, key.indexOf(
|
|
85
|
+
const prefix = key.slice(0, key.indexOf("-"))
|
|
77
86
|
if (prefix === RESERVED_PREFIX) {
|
|
78
87
|
return `key '${key}' uses the '${RESERVED_PREFIX}-' prefix, which is reserved for Ham2K's own extensions`
|
|
79
88
|
}
|
|
@@ -84,9 +93,9 @@ export function keyProblem(key, { forceName = false } = {}) {
|
|
|
84
93
|
}
|
|
85
94
|
|
|
86
95
|
/// The only entries a bundle may contain, beyond `assets/`.
|
|
87
|
-
export const MANIFEST_ENTRY =
|
|
88
|
-
export const BUNDLE_ENTRY =
|
|
89
|
-
export const ASSETS_PREFIX =
|
|
96
|
+
export const MANIFEST_ENTRY = "manifest.json"
|
|
97
|
+
export const BUNDLE_ENTRY = "index.js"
|
|
98
|
+
export const ASSETS_PREFIX = "assets/"
|
|
90
99
|
|
|
91
100
|
/// The libraries the extension host carries, per API version, so a bundle does
|
|
92
101
|
/// not have to ship its own copy of each. The kernel holds one instance and
|
|
@@ -103,20 +112,21 @@ export const ASSETS_PREFIX = 'assets/'
|
|
|
103
112
|
/// live once this file is published on its own.
|
|
104
113
|
export const SHARED_MODULES_BY_API = {
|
|
105
114
|
1: [
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
115
|
+
"@ham2k/lib-callsigns",
|
|
116
|
+
"@ham2k/lib-country-files",
|
|
117
|
+
"@ham2k/lib-cqmag-data",
|
|
118
|
+
"@ham2k/lib-dxcc-data",
|
|
119
|
+
"@ham2k/lib-format-tools",
|
|
120
|
+
"@ham2k/lib-geo-tools",
|
|
121
|
+
"@ham2k/lib-operation-data",
|
|
122
|
+
"@ham2k/lib-qson-adif",
|
|
123
|
+
"@ham2k/lib-qson-cabrillo",
|
|
124
|
+
"@ham2k/lib-qson-tools",
|
|
125
|
+
"i18next",
|
|
126
|
+
"liquidjs",
|
|
118
127
|
],
|
|
119
128
|
}
|
|
129
|
+
SHARED_MODULES_BY_API[2] = SHARED_MODULES_BY_API[1]
|
|
120
130
|
|
|
121
131
|
/// What a scaffolded extension declares on day one: the eight modules the SDK
|
|
122
132
|
/// barrel itself reaches, so a project that has written no code yet still
|
|
@@ -136,14 +146,14 @@ export const SHARED_MODULES_BY_API = {
|
|
|
136
146
|
/// actually holds, so a major bump there fails a build rather than shipping a
|
|
137
147
|
/// scaffold that cannot install.
|
|
138
148
|
export const SCAFFOLD_SHARED_DEPENDENCIES = {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
149
|
+
"@ham2k/lib-callsigns": "^1.0.0",
|
|
150
|
+
"@ham2k/lib-country-files": "^1.0.0",
|
|
151
|
+
"@ham2k/lib-dxcc-data": "^1.0.0",
|
|
152
|
+
"@ham2k/lib-format-tools": "^1.0.0",
|
|
153
|
+
"@ham2k/lib-geo-tools": "^1.0.0",
|
|
154
|
+
"@ham2k/lib-operation-data": "^1.0.0",
|
|
155
|
+
i18next: "^23.0.0",
|
|
156
|
+
liquidjs: "^10.0.0",
|
|
147
157
|
}
|
|
148
158
|
|
|
149
159
|
/// What the host offers a bundle declaring [api].
|
|
@@ -157,7 +167,7 @@ export class PackError extends Error {}
|
|
|
157
167
|
/// the operator's own values against, so a value outside it can never match
|
|
158
168
|
/// anyone. Continents are cty.dat's column-4 codes; the regions are the three
|
|
159
169
|
/// the ITU Radio Regulations define.
|
|
160
|
-
export const GEO_CONTINENTS = [
|
|
170
|
+
export const GEO_CONTINENTS = ["AF", "AS", "EU", "NA", "OC", "SA", "AN"]
|
|
161
171
|
export const GEO_ITU_REGIONS = [1, 2, 3]
|
|
162
172
|
|
|
163
173
|
/// What `relevance.interests` may name: a mode, a band group, or a style of
|
|
@@ -167,13 +177,9 @@ export const GEO_ITU_REGIONS = [1, 2, 3]
|
|
|
167
177
|
/// asks for.
|
|
168
178
|
///
|
|
169
179
|
/// 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
|
-
]
|
|
180
|
+
export const RELEVANCE_INTERESTS = ["cw", "phone", "digital", "hf", "vhf", "uhf", "microwave", "satellite", "portable", "qrp"]
|
|
175
181
|
|
|
176
|
-
const RELEVANCE_KEYS = [
|
|
182
|
+
const RELEVANCE_KEYS = ["entities", "countries", "continents", "ituRegions", "dates", "interests"]
|
|
177
183
|
|
|
178
184
|
/// Everything wrong with a manifest's `relevance`, as a list — empty for a
|
|
179
185
|
/// valid one, and for an absent one, which means worldwide and undated.
|
|
@@ -188,13 +194,13 @@ const RELEVANCE_KEYS = ['entities', 'countries', 'continents', 'ituRegions', 'da
|
|
|
188
194
|
/// rules are the format's, not the repo's, and this module is the format.
|
|
189
195
|
export function validateRelevance(relevance) {
|
|
190
196
|
if (relevance === undefined) return []
|
|
191
|
-
if (typeof relevance !==
|
|
192
|
-
return [`'relevance' must be an object of ${RELEVANCE_KEYS.join(
|
|
197
|
+
if (typeof relevance !== "object" || relevance === null || Array.isArray(relevance)) {
|
|
198
|
+
return [`'relevance' must be an object of ${RELEVANCE_KEYS.join(" / ")}`]
|
|
193
199
|
}
|
|
194
200
|
const problems = []
|
|
195
201
|
for (const key of Object.keys(relevance)) {
|
|
196
202
|
if (!RELEVANCE_KEYS.includes(key)) {
|
|
197
|
-
problems.push(`'relevance' has an unknown key '${key}' — only ${RELEVANCE_KEYS.join(
|
|
203
|
+
problems.push(`'relevance' has an unknown key '${key}' — only ${RELEVANCE_KEYS.join(", ")} are read`)
|
|
198
204
|
}
|
|
199
205
|
}
|
|
200
206
|
for (const key of RELEVANCE_KEYS) {
|
|
@@ -213,25 +219,21 @@ export function validateRelevance(relevance) {
|
|
|
213
219
|
}
|
|
214
220
|
|
|
215
221
|
function relevanceValueProblem(key, value) {
|
|
216
|
-
if (key ===
|
|
217
|
-
return GEO_ITU_REGIONS.includes(value) ? null : `is not an ITU region (${GEO_ITU_REGIONS.join(
|
|
222
|
+
if (key === "ituRegions") {
|
|
223
|
+
return GEO_ITU_REGIONS.includes(value) ? null : `is not an ITU region (${GEO_ITU_REGIONS.join(", ")})`
|
|
218
224
|
}
|
|
219
|
-
if (typeof value !==
|
|
220
|
-
if (key ===
|
|
221
|
-
return /^\*?[A-Z0-9/]+$/.test(value.toUpperCase())
|
|
222
|
-
? null
|
|
223
|
-
: "is not a DXCC entity prefix (letters, digits and '/', with an optional leading '*')"
|
|
225
|
+
if (typeof value !== "string" || value.trim() === "") return "is not a non-empty string"
|
|
226
|
+
if (key === "entities") {
|
|
227
|
+
return /^\*?[A-Z0-9/]+$/.test(value.toUpperCase()) ? null : "is not a DXCC entity prefix (letters, digits and '/', with an optional leading '*')"
|
|
224
228
|
}
|
|
225
|
-
if (key ===
|
|
226
|
-
return /^[a-z]{2}$/.test(value.toLowerCase()) ? null :
|
|
229
|
+
if (key === "countries") {
|
|
230
|
+
return /^[a-z]{2}$/.test(value.toLowerCase()) ? null : "is not a two-letter ISO country code"
|
|
227
231
|
}
|
|
228
|
-
if (key ===
|
|
229
|
-
if (key ===
|
|
230
|
-
return RELEVANCE_INTERESTS.includes(value)
|
|
231
|
-
? null
|
|
232
|
-
: `is not an interest (${RELEVANCE_INTERESTS.join(' ')})`
|
|
232
|
+
if (key === "dates") return calendarDateProblem(value)
|
|
233
|
+
if (key === "interests") {
|
|
234
|
+
return RELEVANCE_INTERESTS.includes(value) ? null : `is not an interest (${RELEVANCE_INTERESTS.join(" ")})`
|
|
233
235
|
}
|
|
234
|
-
return GEO_CONTINENTS.includes(value.toUpperCase()) ? null : `is not a continent (${GEO_CONTINENTS.join(
|
|
236
|
+
return GEO_CONTINENTS.includes(value.toUpperCase()) ? null : `is not a continent (${GEO_CONTINENTS.join(" ")})`
|
|
235
237
|
}
|
|
236
238
|
|
|
237
239
|
/// Why [value] is not a UTC calendar day, or null.
|
|
@@ -245,13 +247,11 @@ function relevanceValueProblem(key, value) {
|
|
|
245
247
|
/// would fail the round trip here and pass in Dart — a disagreement between
|
|
246
248
|
/// the two on an input neither should be accepting anyway.
|
|
247
249
|
function calendarDateProblem(value) {
|
|
248
|
-
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return
|
|
249
|
-
const [year, month, day] = value.split(
|
|
250
|
-
if (year < 1000) return
|
|
250
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return "is not a YYYY-MM-DD date"
|
|
251
|
+
const [year, month, day] = value.split("-").map(Number)
|
|
252
|
+
if (year < 1000) return "is not a year an event could fall in"
|
|
251
253
|
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'
|
|
254
|
+
return at.getUTCFullYear() === year && at.getUTCMonth() === month - 1 && at.getUTCDate() === day ? null : "is not a day that exists"
|
|
255
255
|
}
|
|
256
256
|
|
|
257
257
|
/// Everything wrong with a candidate bundle, as a list — an author fixing one
|
|
@@ -260,12 +260,12 @@ export function validateManifest(manifest, { source = MANIFEST_ENTRY, forceName
|
|
|
260
260
|
const problems = []
|
|
261
261
|
const say = (m) => problems.push(`${source}: ${m}`)
|
|
262
262
|
|
|
263
|
-
if (typeof manifest !==
|
|
263
|
+
if (typeof manifest !== "object" || manifest === null || Array.isArray(manifest)) {
|
|
264
264
|
return [`${source}: must be a JSON object`]
|
|
265
265
|
}
|
|
266
266
|
|
|
267
|
-
for (const field of [
|
|
268
|
-
if (typeof manifest[field] !==
|
|
267
|
+
for (const field of ["key", "name", "version", "description"]) {
|
|
268
|
+
if (typeof manifest[field] !== "string" || manifest[field].trim() === "") {
|
|
269
269
|
say(`missing required field '${field}'`)
|
|
270
270
|
}
|
|
271
271
|
}
|
|
@@ -283,24 +283,32 @@ export function validateManifest(manifest, { source = MANIFEST_ENTRY, forceName
|
|
|
283
283
|
say(`'api' is ${manifest.api}, but this packer only knows ${H2KEXT_API_VERSION}`)
|
|
284
284
|
}
|
|
285
285
|
|
|
286
|
+
if (manifest.webSockets !== undefined) {
|
|
287
|
+
if (!Array.isArray(manifest.webSockets) || manifest.webSockets.some((host) => typeof host !== "string")) {
|
|
288
|
+
say("'webSockets' must be a list of hosts")
|
|
289
|
+
} else if (manifest.webSockets.length && Number.isInteger(manifest.api) && manifest.api < WEB_SOCKETS_API_VERSION) {
|
|
290
|
+
say(`'webSockets' needs 'api' ${WEB_SOCKETS_API_VERSION} or later — an app older than that would install this and fail only when it connects`)
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
286
294
|
// An extension with no category is CORE to the host: always enabled, and
|
|
287
295
|
// never listed in the Extensions panel. That is a status the app grants its
|
|
288
296
|
// own extensions, not one a distributed bundle may claim for itself — a user
|
|
289
297
|
// could neither see it nor turn it off.
|
|
290
|
-
if (typeof manifest.category !==
|
|
298
|
+
if (typeof manifest.category !== "string" || manifest.category.trim() === "") {
|
|
291
299
|
say("missing 'category' — a bundle with none is treated as core: always on, and invisible in the Extensions panel")
|
|
292
300
|
}
|
|
293
301
|
|
|
294
302
|
if (manifest.sharedDependencies !== undefined) {
|
|
295
303
|
const declared = manifest.sharedDependencies
|
|
296
|
-
if (typeof declared !==
|
|
304
|
+
if (typeof declared !== "object" || declared === null || Array.isArray(declared)) {
|
|
297
305
|
say("'sharedDependencies' must be an object of package name to version range")
|
|
298
306
|
} else {
|
|
299
307
|
const offered = sharedModulesFor(manifest.api)
|
|
300
308
|
for (const [pkg, range] of Object.entries(declared)) {
|
|
301
309
|
if (!offered.includes(pkg)) {
|
|
302
310
|
say(`'sharedDependencies' names '${pkg}', which the host does not carry — bundle it into your build instead`)
|
|
303
|
-
} else if (typeof range !==
|
|
311
|
+
} else if (typeof range !== "string" || range.trim() === "") {
|
|
304
312
|
say(`'sharedDependencies' entry '${pkg}' needs a version range, e.g. "^1.0.0"`)
|
|
305
313
|
}
|
|
306
314
|
}
|
|
@@ -361,7 +369,7 @@ export function findSharedModuleReferences(source) {
|
|
|
361
369
|
export async function readBundleDirectory(dir, { forceName = false } = {}) {
|
|
362
370
|
let manifestRaw
|
|
363
371
|
try {
|
|
364
|
-
manifestRaw = await readFile(join(dir, MANIFEST_ENTRY),
|
|
372
|
+
manifestRaw = await readFile(join(dir, MANIFEST_ENTRY), "utf8")
|
|
365
373
|
} catch {
|
|
366
374
|
throw new PackError(`no ${MANIFEST_ENTRY} in ${dir}`)
|
|
367
375
|
}
|
|
@@ -377,11 +385,11 @@ export async function readBundleDirectory(dir, { forceName = false } = {}) {
|
|
|
377
385
|
|
|
378
386
|
let bundleSource
|
|
379
387
|
try {
|
|
380
|
-
bundleSource = await readFile(join(dir, BUNDLE_ENTRY),
|
|
388
|
+
bundleSource = await readFile(join(dir, BUNDLE_ENTRY), "utf8")
|
|
381
389
|
} catch {
|
|
382
390
|
throw new PackError(`no ${BUNDLE_ENTRY} in ${dir} — build your extension first; this packages, it does not compile`)
|
|
383
391
|
}
|
|
384
|
-
if (bundleSource.trim() ===
|
|
392
|
+
if (bundleSource.trim() === "") problems.push(`${BUNDLE_ENTRY}: is empty`)
|
|
385
393
|
|
|
386
394
|
// Using the host's shared libraries is allowed, and is much the better
|
|
387
395
|
// option — inlining them costs about ten times the bundle size. What is NOT
|
|
@@ -393,12 +401,15 @@ export async function readBundleDirectory(dir, { forceName = false } = {}) {
|
|
|
393
401
|
const undeclared = findSharedModuleReferences(bundleSource).filter((pkg) => !(pkg in declaredShared))
|
|
394
402
|
if (undeclared.length) {
|
|
395
403
|
problems.push(
|
|
396
|
-
`${BUNDLE_ENTRY}: uses the host's shared modules without declaring them (${undeclared.join(
|
|
404
|
+
`${BUNDLE_ENTRY}: uses the host's shared modules without declaring them (${undeclared.join(", ")}). ` +
|
|
397
405
|
`Add them to 'sharedDependencies' in ${MANIFEST_ENTRY} with the version range you work with, ` +
|
|
398
|
-
'e.g. {"' +
|
|
406
|
+
'e.g. {"' +
|
|
407
|
+
undeclared[0] +
|
|
408
|
+
'": "^1.0.0"}, so the host can refuse to load this against a version you never tested.',
|
|
409
|
+
)
|
|
399
410
|
}
|
|
400
411
|
|
|
401
|
-
if (problems.length) throw new PackError(problems.join(
|
|
412
|
+
if (problems.length) throw new PackError(problems.join("\n"))
|
|
402
413
|
|
|
403
414
|
const files = [
|
|
404
415
|
{ entry: MANIFEST_ENTRY, path: join(dir, MANIFEST_ENTRY) },
|
|
@@ -419,7 +430,7 @@ async function collectAssets(dir) {
|
|
|
419
430
|
// No assets/ is ordinary. Anything else — unreadable, an I/O error, a
|
|
420
431
|
// broken symlink — is not, and swallowing it ships a bundle silently
|
|
421
432
|
// missing files the author believes are in it.
|
|
422
|
-
if (e.code ===
|
|
433
|
+
if (e.code === "ENOENT") return []
|
|
423
434
|
throw new PackError(`could not read ${root}: ${e.message}`)
|
|
424
435
|
}
|
|
425
436
|
|
|
@@ -446,9 +457,9 @@ export function writeBundle(files, outPath) {
|
|
|
446
457
|
for (const file of files) zip.addFile(file.path, file.entry)
|
|
447
458
|
const out = createWriteStream(outPath)
|
|
448
459
|
zip.outputStream.pipe(out)
|
|
449
|
-
out.on(
|
|
450
|
-
out.on(
|
|
451
|
-
zip.outputStream.on(
|
|
460
|
+
out.on("close", () => resolve(outPath))
|
|
461
|
+
out.on("error", reject)
|
|
462
|
+
zip.outputStream.on("error", reject)
|
|
452
463
|
zip.end()
|
|
453
464
|
})
|
|
454
465
|
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ham2k/extension-tools",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Build and package extensions for the Ham2K Logger",
|
|
5
|
-
"keywords": [
|
|
5
|
+
"keywords": [
|
|
6
|
+
"ham2k",
|
|
7
|
+
"ham radio",
|
|
8
|
+
"amateur radio",
|
|
9
|
+
"extension",
|
|
10
|
+
"esbuild",
|
|
11
|
+
"h2kext"
|
|
12
|
+
],
|
|
6
13
|
"license": "MIT",
|
|
7
14
|
"author": "Sebastian Delmont <sd@ham2k.com>",
|
|
8
15
|
"homepage": "https://ham2k.com",
|