@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/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).
|
|
@@ -16,11 +16,11 @@
|
|
|
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
|
|
@@ -36,7 +36,7 @@ export const KEY_PATTERN = /^[a-z0-9]+-[a-z0-9-]*[a-z0-9]$/
|
|
|
36
36
|
/// bundle unless it is one of ours: there is no signing yet, so anyone could
|
|
37
37
|
/// claim it, and a key is what the app shows when it says where a hook came
|
|
38
38
|
/// from.
|
|
39
|
-
export const RESERVED_PREFIX =
|
|
39
|
+
export const RESERVED_PREFIX = "ham2k"
|
|
40
40
|
|
|
41
41
|
/// Whether [prefix] is a real amateur callsign — the namespace every author of
|
|
42
42
|
/// this software already has, globally unique, and free.
|
|
@@ -66,14 +66,14 @@ export const SAFE_KEY_PATTERN = /^[a-z0-9][a-z0-9-]*$/
|
|
|
66
66
|
/// app is where the rule bites, refusing a reserved or colliding key at
|
|
67
67
|
/// install.
|
|
68
68
|
export function keyProblem(key, { forceName = false } = {}) {
|
|
69
|
-
if (typeof key !==
|
|
69
|
+
if (typeof key !== "string" || !SAFE_KEY_PATTERN.test(key)) {
|
|
70
70
|
return `key '${key}' must be lowercase, and contain only letters, digits and hyphens`
|
|
71
71
|
}
|
|
72
72
|
if (forceName) return null
|
|
73
73
|
if (!KEY_PATTERN.test(key)) {
|
|
74
74
|
return `key '${key}' must be '<callsign>-<name>' (e.g. ki2d-my-clock)`
|
|
75
75
|
}
|
|
76
|
-
const prefix = key.slice(0, key.indexOf(
|
|
76
|
+
const prefix = key.slice(0, key.indexOf("-"))
|
|
77
77
|
if (prefix === RESERVED_PREFIX) {
|
|
78
78
|
return `key '${key}' uses the '${RESERVED_PREFIX}-' prefix, which is reserved for Ham2K's own extensions`
|
|
79
79
|
}
|
|
@@ -84,9 +84,9 @@ export function keyProblem(key, { forceName = false } = {}) {
|
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
/// The only entries a bundle may contain, beyond `assets/`.
|
|
87
|
-
export const MANIFEST_ENTRY =
|
|
88
|
-
export const BUNDLE_ENTRY =
|
|
89
|
-
export const ASSETS_PREFIX =
|
|
87
|
+
export const MANIFEST_ENTRY = "manifest.json"
|
|
88
|
+
export const BUNDLE_ENTRY = "index.js"
|
|
89
|
+
export const ASSETS_PREFIX = "assets/"
|
|
90
90
|
|
|
91
91
|
/// The libraries the extension host carries, per API version, so a bundle does
|
|
92
92
|
/// not have to ship its own copy of each. The kernel holds one instance and
|
|
@@ -103,18 +103,18 @@ export const ASSETS_PREFIX = 'assets/'
|
|
|
103
103
|
/// live once this file is published on its own.
|
|
104
104
|
export const SHARED_MODULES_BY_API = {
|
|
105
105
|
1: [
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
106
|
+
"@ham2k/lib-callsigns",
|
|
107
|
+
"@ham2k/lib-country-files",
|
|
108
|
+
"@ham2k/lib-cqmag-data",
|
|
109
|
+
"@ham2k/lib-dxcc-data",
|
|
110
|
+
"@ham2k/lib-format-tools",
|
|
111
|
+
"@ham2k/lib-geo-tools",
|
|
112
|
+
"@ham2k/lib-operation-data",
|
|
113
|
+
"@ham2k/lib-qson-adif",
|
|
114
|
+
"@ham2k/lib-qson-cabrillo",
|
|
115
|
+
"@ham2k/lib-qson-tools",
|
|
116
|
+
"i18next",
|
|
117
|
+
"liquidjs",
|
|
118
118
|
],
|
|
119
119
|
}
|
|
120
120
|
|
|
@@ -136,14 +136,14 @@ export const SHARED_MODULES_BY_API = {
|
|
|
136
136
|
/// actually holds, so a major bump there fails a build rather than shipping a
|
|
137
137
|
/// scaffold that cannot install.
|
|
138
138
|
export const SCAFFOLD_SHARED_DEPENDENCIES = {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
147
|
}
|
|
148
148
|
|
|
149
149
|
/// What the host offers a bundle declaring [api].
|
|
@@ -153,18 +153,109 @@ export function sharedModulesFor(api) {
|
|
|
153
153
|
|
|
154
154
|
export class PackError extends Error {}
|
|
155
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 = ["cw", "phone", "digital", "hf", "vhf", "uhf", "microwave", "satellite", "portable", "qrp"]
|
|
171
|
+
|
|
172
|
+
const RELEVANCE_KEYS = ["entities", "countries", "continents", "ituRegions", "dates", "interests"]
|
|
173
|
+
|
|
174
|
+
/// Everything wrong with a manifest's `relevance`, as a list — empty for a
|
|
175
|
+
/// valid one, and for an absent one, which means worldwide and undated.
|
|
176
|
+
///
|
|
177
|
+
/// The shape rules are the ones that matter: the matcher reads a key it
|
|
178
|
+
/// cannot parse as "does not gate", so a misspelt key or a string where a
|
|
179
|
+
/// list should be would quietly make a Chilean extension relevant to the
|
|
180
|
+
/// whole world. The vocabulary rules catch the opposite mistake — a value that
|
|
181
|
+
/// can never match anyone, which is a bundle nobody is shown.
|
|
182
|
+
///
|
|
183
|
+
/// Shared with build-config.mjs's manifest checks, which import it: the
|
|
184
|
+
/// rules are the format's, not the repo's, and this module is the format.
|
|
185
|
+
export function validateRelevance(relevance) {
|
|
186
|
+
if (relevance === undefined) return []
|
|
187
|
+
if (typeof relevance !== "object" || relevance === null || Array.isArray(relevance)) {
|
|
188
|
+
return [`'relevance' must be an object of ${RELEVANCE_KEYS.join(" / ")}`]
|
|
189
|
+
}
|
|
190
|
+
const problems = []
|
|
191
|
+
for (const key of Object.keys(relevance)) {
|
|
192
|
+
if (!RELEVANCE_KEYS.includes(key)) {
|
|
193
|
+
problems.push(`'relevance' has an unknown key '${key}' — only ${RELEVANCE_KEYS.join(", ")} are read`)
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
for (const key of RELEVANCE_KEYS) {
|
|
197
|
+
const values = relevance[key]
|
|
198
|
+
if (values === undefined) continue
|
|
199
|
+
if (!Array.isArray(values)) {
|
|
200
|
+
problems.push(`'relevance.${key}' must be a list`)
|
|
201
|
+
continue
|
|
202
|
+
}
|
|
203
|
+
for (const value of values) {
|
|
204
|
+
const problem = relevanceValueProblem(key, value)
|
|
205
|
+
if (problem) problems.push(`'relevance.${key}' has ${JSON.stringify(value)}, which ${problem}`)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return problems
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function relevanceValueProblem(key, value) {
|
|
212
|
+
if (key === "ituRegions") {
|
|
213
|
+
return GEO_ITU_REGIONS.includes(value) ? null : `is not an ITU region (${GEO_ITU_REGIONS.join(", ")})`
|
|
214
|
+
}
|
|
215
|
+
if (typeof value !== "string" || value.trim() === "") return "is not a non-empty string"
|
|
216
|
+
if (key === "entities") {
|
|
217
|
+
return /^\*?[A-Z0-9/]+$/.test(value.toUpperCase()) ? null : "is not a DXCC entity prefix (letters, digits and '/', with an optional leading '*')"
|
|
218
|
+
}
|
|
219
|
+
if (key === "countries") {
|
|
220
|
+
return /^[a-z]{2}$/.test(value.toLowerCase()) ? null : "is not a two-letter ISO country code"
|
|
221
|
+
}
|
|
222
|
+
if (key === "dates") return calendarDateProblem(value)
|
|
223
|
+
if (key === "interests") {
|
|
224
|
+
return RELEVANCE_INTERESTS.includes(value) ? null : `is not an interest (${RELEVANCE_INTERESTS.join(" ")})`
|
|
225
|
+
}
|
|
226
|
+
return GEO_CONTINENTS.includes(value.toUpperCase()) ? null : `is not a continent (${GEO_CONTINENTS.join(" ")})`
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/// Why [value] is not a UTC calendar day, or null.
|
|
230
|
+
///
|
|
231
|
+
/// The round trip is the whole check: `Date.UTC` rolls 31 February over into
|
|
232
|
+
/// 3 March rather than refusing it, so a pattern match alone accepts a day
|
|
233
|
+
/// that does not exist and then silently means a different one.
|
|
234
|
+
///
|
|
235
|
+
/// A year under 1000 is refused before that round trip, and the Dart twin
|
|
236
|
+
/// refuses it too. `Date.UTC` reads a year under 100 as 19xx, so `0050-01-01`
|
|
237
|
+
/// would fail the round trip here and pass in Dart — a disagreement between
|
|
238
|
+
/// the two on an input neither should be accepting anyway.
|
|
239
|
+
function calendarDateProblem(value) {
|
|
240
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return "is not a YYYY-MM-DD date"
|
|
241
|
+
const [year, month, day] = value.split("-").map(Number)
|
|
242
|
+
if (year < 1000) return "is not a year an event could fall in"
|
|
243
|
+
const at = new Date(Date.UTC(year, month - 1, day))
|
|
244
|
+
return at.getUTCFullYear() === year && at.getUTCMonth() === month - 1 && at.getUTCDate() === day ? null : "is not a day that exists"
|
|
245
|
+
}
|
|
246
|
+
|
|
156
247
|
/// Everything wrong with a candidate bundle, as a list — an author fixing one
|
|
157
248
|
/// problem per run is an author running this five times.
|
|
158
249
|
export function validateManifest(manifest, { source = MANIFEST_ENTRY, forceName = false } = {}) {
|
|
159
250
|
const problems = []
|
|
160
251
|
const say = (m) => problems.push(`${source}: ${m}`)
|
|
161
252
|
|
|
162
|
-
if (typeof manifest !==
|
|
253
|
+
if (typeof manifest !== "object" || manifest === null || Array.isArray(manifest)) {
|
|
163
254
|
return [`${source}: must be a JSON object`]
|
|
164
255
|
}
|
|
165
256
|
|
|
166
|
-
for (const field of [
|
|
167
|
-
if (typeof manifest[field] !==
|
|
257
|
+
for (const field of ["key", "name", "version", "description"]) {
|
|
258
|
+
if (typeof manifest[field] !== "string" || manifest[field].trim() === "") {
|
|
168
259
|
say(`missing required field '${field}'`)
|
|
169
260
|
}
|
|
170
261
|
}
|
|
@@ -186,20 +277,20 @@ export function validateManifest(manifest, { source = MANIFEST_ENTRY, forceName
|
|
|
186
277
|
// never listed in the Extensions panel. That is a status the app grants its
|
|
187
278
|
// own extensions, not one a distributed bundle may claim for itself — a user
|
|
188
279
|
// could neither see it nor turn it off.
|
|
189
|
-
if (typeof manifest.category !==
|
|
280
|
+
if (typeof manifest.category !== "string" || manifest.category.trim() === "") {
|
|
190
281
|
say("missing 'category' — a bundle with none is treated as core: always on, and invisible in the Extensions panel")
|
|
191
282
|
}
|
|
192
283
|
|
|
193
284
|
if (manifest.sharedDependencies !== undefined) {
|
|
194
285
|
const declared = manifest.sharedDependencies
|
|
195
|
-
if (typeof declared !==
|
|
286
|
+
if (typeof declared !== "object" || declared === null || Array.isArray(declared)) {
|
|
196
287
|
say("'sharedDependencies' must be an object of package name to version range")
|
|
197
288
|
} else {
|
|
198
289
|
const offered = sharedModulesFor(manifest.api)
|
|
199
290
|
for (const [pkg, range] of Object.entries(declared)) {
|
|
200
291
|
if (!offered.includes(pkg)) {
|
|
201
292
|
say(`'sharedDependencies' names '${pkg}', which the host does not carry — bundle it into your build instead`)
|
|
202
|
-
} else if (typeof range !==
|
|
293
|
+
} else if (typeof range !== "string" || range.trim() === "") {
|
|
203
294
|
say(`'sharedDependencies' entry '${pkg}' needs a version range, e.g. "^1.0.0"`)
|
|
204
295
|
}
|
|
205
296
|
}
|
|
@@ -213,6 +304,28 @@ export function validateManifest(manifest, { source = MANIFEST_ENTRY, forceName
|
|
|
213
304
|
say("'experiments' gates on the app's own experiment catalog and would hide this extension permanently — remove it")
|
|
214
305
|
}
|
|
215
306
|
|
|
307
|
+
// The name `relevance` replaced. Nothing reads `geo`, and nothing here
|
|
308
|
+
// rejects unknown top-level keys, so a manifest still carrying it would
|
|
309
|
+
// pack, publish and install as WORLDWIDE — the author's audience silently
|
|
310
|
+
// discarded rather than refused. It is the one stale key certain to
|
|
311
|
+
// arrive, because it is what every older manifest says.
|
|
312
|
+
if (manifest.geo !== undefined) {
|
|
313
|
+
say("'geo' is now 'relevance' — rename it (docs/extensions/distribution.md)")
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// `enabledByDefault` is the shipping app's answer for its OWN extensions,
|
|
317
|
+
// which arrive whether the operator wanted them or not. An installed
|
|
318
|
+
// bundle is one the operator went and got, so the answer is always yes and
|
|
319
|
+
// a manifest that says otherwise is describing a state it cannot reach.
|
|
320
|
+
if (manifest.enabledByDefault !== undefined) {
|
|
321
|
+
say("'enabledByDefault' applies to an app's built-in extensions — an installed one is on because the operator installed it")
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// `relevance` ranks the bundle, in the catalog and the Extensions panel,
|
|
325
|
+
// for the operators it was written for. The matcher reads a key it cannot
|
|
326
|
+
// parse as "does not gate", so a malformed one would ship as worldwide.
|
|
327
|
+
for (const problem of validateRelevance(manifest.relevance)) say(problem)
|
|
328
|
+
|
|
216
329
|
return problems
|
|
217
330
|
}
|
|
218
331
|
|
|
@@ -238,7 +351,7 @@ export function findSharedModuleReferences(source) {
|
|
|
238
351
|
export async function readBundleDirectory(dir, { forceName = false } = {}) {
|
|
239
352
|
let manifestRaw
|
|
240
353
|
try {
|
|
241
|
-
manifestRaw = await readFile(join(dir, MANIFEST_ENTRY),
|
|
354
|
+
manifestRaw = await readFile(join(dir, MANIFEST_ENTRY), "utf8")
|
|
242
355
|
} catch {
|
|
243
356
|
throw new PackError(`no ${MANIFEST_ENTRY} in ${dir}`)
|
|
244
357
|
}
|
|
@@ -254,11 +367,11 @@ export async function readBundleDirectory(dir, { forceName = false } = {}) {
|
|
|
254
367
|
|
|
255
368
|
let bundleSource
|
|
256
369
|
try {
|
|
257
|
-
bundleSource = await readFile(join(dir, BUNDLE_ENTRY),
|
|
370
|
+
bundleSource = await readFile(join(dir, BUNDLE_ENTRY), "utf8")
|
|
258
371
|
} catch {
|
|
259
372
|
throw new PackError(`no ${BUNDLE_ENTRY} in ${dir} — build your extension first; this packages, it does not compile`)
|
|
260
373
|
}
|
|
261
|
-
if (bundleSource.trim() ===
|
|
374
|
+
if (bundleSource.trim() === "") problems.push(`${BUNDLE_ENTRY}: is empty`)
|
|
262
375
|
|
|
263
376
|
// Using the host's shared libraries is allowed, and is much the better
|
|
264
377
|
// option — inlining them costs about ten times the bundle size. What is NOT
|
|
@@ -270,12 +383,15 @@ export async function readBundleDirectory(dir, { forceName = false } = {}) {
|
|
|
270
383
|
const undeclared = findSharedModuleReferences(bundleSource).filter((pkg) => !(pkg in declaredShared))
|
|
271
384
|
if (undeclared.length) {
|
|
272
385
|
problems.push(
|
|
273
|
-
`${BUNDLE_ENTRY}: uses the host's shared modules without declaring them (${undeclared.join(
|
|
386
|
+
`${BUNDLE_ENTRY}: uses the host's shared modules without declaring them (${undeclared.join(", ")}). ` +
|
|
274
387
|
`Add them to 'sharedDependencies' in ${MANIFEST_ENTRY} with the version range you work with, ` +
|
|
275
|
-
'e.g. {"' +
|
|
388
|
+
'e.g. {"' +
|
|
389
|
+
undeclared[0] +
|
|
390
|
+
'": "^1.0.0"}, so the host can refuse to load this against a version you never tested.',
|
|
391
|
+
)
|
|
276
392
|
}
|
|
277
393
|
|
|
278
|
-
if (problems.length) throw new PackError(problems.join(
|
|
394
|
+
if (problems.length) throw new PackError(problems.join("\n"))
|
|
279
395
|
|
|
280
396
|
const files = [
|
|
281
397
|
{ entry: MANIFEST_ENTRY, path: join(dir, MANIFEST_ENTRY) },
|
|
@@ -296,7 +412,7 @@ async function collectAssets(dir) {
|
|
|
296
412
|
// No assets/ is ordinary. Anything else — unreadable, an I/O error, a
|
|
297
413
|
// broken symlink — is not, and swallowing it ships a bundle silently
|
|
298
414
|
// missing files the author believes are in it.
|
|
299
|
-
if (e.code ===
|
|
415
|
+
if (e.code === "ENOENT") return []
|
|
300
416
|
throw new PackError(`could not read ${root}: ${e.message}`)
|
|
301
417
|
}
|
|
302
418
|
|
|
@@ -323,9 +439,9 @@ export function writeBundle(files, outPath) {
|
|
|
323
439
|
for (const file of files) zip.addFile(file.path, file.entry)
|
|
324
440
|
const out = createWriteStream(outPath)
|
|
325
441
|
zip.outputStream.pipe(out)
|
|
326
|
-
out.on(
|
|
327
|
-
out.on(
|
|
328
|
-
zip.outputStream.on(
|
|
442
|
+
out.on("close", () => resolve(outPath))
|
|
443
|
+
out.on("error", reject)
|
|
444
|
+
zip.outputStream.on("error", reject)
|
|
329
445
|
zip.end()
|
|
330
446
|
})
|
|
331
447
|
}
|
package/package.json
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ham2k/extension-tools",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Build and package extensions for the Ham2K Logger",
|
|
5
|
-
"keywords": [
|
|
6
|
-
|
|
5
|
+
"keywords": [
|
|
6
|
+
"ham2k",
|
|
7
|
+
"ham radio",
|
|
8
|
+
"amateur radio",
|
|
9
|
+
"extension",
|
|
10
|
+
"esbuild",
|
|
11
|
+
"h2kext"
|
|
12
|
+
],
|
|
13
|
+
"license": "MIT",
|
|
7
14
|
"author": "Sebastian Delmont <sd@ham2k.com>",
|
|
8
15
|
"homepage": "https://ham2k.com",
|
|
9
16
|
"repository": {
|
|
@@ -18,13 +25,15 @@
|
|
|
18
25
|
},
|
|
19
26
|
"bin": {
|
|
20
27
|
"h2kext-init": "./h2kext-init.mjs",
|
|
21
|
-
"h2kext-pack": "./h2kext-pack.mjs"
|
|
28
|
+
"h2kext-pack": "./h2kext-pack.mjs",
|
|
29
|
+
"h2kext-publish": "./h2kext-publish.mjs"
|
|
22
30
|
},
|
|
23
31
|
"files": [
|
|
24
32
|
"h2kext.mjs",
|
|
25
33
|
"h2kext-build.mjs",
|
|
26
34
|
"h2kext-init.mjs",
|
|
27
35
|
"h2kext-pack.mjs",
|
|
36
|
+
"h2kext-publish.mjs",
|
|
28
37
|
"README.md"
|
|
29
38
|
],
|
|
30
39
|
"engines": {
|