@motrix/plugin-cli 2.0.0 → 2.1.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 CHANGED
@@ -35,7 +35,16 @@ motrix-plugin validate
35
35
  ```
36
36
  Validate `motrix-plugin.json` against
37
37
  [`@motrix/plugin-manifest-schema`](https://www.npmjs.com/package/@motrix/plugin-manifest-schema) —
38
- the same schema the Motrix host uses when installing a plugin.
38
+ the same schema the Motrix host uses when installing a plugin. Beyond the
39
+ schema this also applies the rules a schema cannot express:
40
+
41
+ - **hooks ⇒ hostPermissions** — contributing any hook requires at least one
42
+ `hostPermissions` entry, or the host refuses to install the plugin
43
+ - **locale coverage** — every `%key%` the manifest interpolates must exist in
44
+ `en-US`, including the ones under `contributes.configuration`
45
+
46
+ `pack` runs this identical gate, so the two commands can never disagree about
47
+ whether a plugin is shippable.
39
48
 
40
49
  ### Schema versioning
41
50
 
@@ -49,22 +58,39 @@ staying current on the CLI keeps you current on the schema too.
49
58
  ```bash
50
59
  motrix-plugin lint
51
60
  ```
52
- Static checks beyond schema validation: locale coverage
53
- (`contributes`/UI strings present in every declared locale) and
54
- top-level side-effect detection (guest code must not run effectful code at
55
- module scope).
61
+ Static checks on the **packed bundle**, so run `pack` first: top-level
62
+ side-effect detection (guest code must not run effectful code at module
63
+ scope), a size warning as the bundle approaches the 1 MiB cap, and advisories
64
+ for `invokesCommands` entries whose callee may not be installed.
56
65
 
57
66
  ```bash
58
67
  motrix-plugin validate-host-permissions
59
68
  ```
60
- Sanity-check `hostPermissions` match patterns and warn on overly broad grants
61
- (e.g. `<all_urls>`).
69
+ Reviews `hostPermissions` in more depth than `validate` does: each pattern is
70
+ parsed with the host's own match-pattern grammar, and broad grants
71
+ (`<all_urls>`, `https://*/*`) are reported as warnings so you can narrow them.
62
72
 
63
73
  ```bash
64
74
  motrix-plugin pack
65
75
  ```
66
- Bundle the plugin with esbuild and zip it into a `.moext` distributable,
67
- enforcing the per-bundle and total package size limits.
76
+ Produce the `.moext` distributable at `dist/<id>-<version>.moext`.
77
+
78
+ Every gate — schema, hooks ⇒ hostPermissions, locale coverage — runs *before*
79
+ esbuild writes anything, so a rejected plugin leaves no half-built bundle
80
+ behind and a `.moext` the host would refuse to install cannot be created in the
81
+ first place. The bundle is then checked against the 1 MiB cap and the archive
82
+ against 5 MiB.
83
+
84
+ The archive is **reproducible**: entries are written in sorted order with
85
+ pinned timestamps and modes, so the same source tree always yields the same
86
+ bytes, on any machine and in any timezone. `pack` prints the resulting sha256 —
87
+ that is the value a registry entry's `package.sha256` needs, and re-packing an
88
+ unchanged tree must reprint it.
89
+
90
+ ```
91
+ Packed → …/dist/acme.demo-1.0.0.moext (4540 bytes)
92
+ sha256 5d69eaf3bb44992952beafc67d818ff376b8478de4b32b936ce5e603b963605d
93
+ ```
68
94
 
69
95
  ```bash
70
96
  pnpm create motrix-plugin my-plugin [template]
@@ -14954,6 +14954,73 @@ var ManifestSchema = external_exports.object({
14954
14954
  contributes: ContributesSchema,
14955
14955
  l10n: external_exports.string().max(256).optional()
14956
14956
  }).strict();
14957
+ var HOST_PERMISSIONS_REQUIRED_FOR_HOOKS = "plugin.manifest.host_permissions_required_for_hooks";
14958
+ var LOCALE_MISSING_EN_US = "plugin.manifest.locale.missing_en_us";
14959
+ var LOCALE_MISSING_KEYS = "plugin.manifest.locale.missing_keys";
14960
+ function requireHostPermissionsForHooks(manifest, id = "<unknown>") {
14961
+ const hooks = manifest.contributes?.hooks;
14962
+ const hasHooks = hooks != null && Object.keys(hooks).length > 0;
14963
+ if (!hasHooks) return;
14964
+ const hostPermissions = Array.isArray(manifest.hostPermissions) ? manifest.hostPermissions : [];
14965
+ if (hostPermissions.length === 0) {
14966
+ throw new Error(
14967
+ `${id}: ${HOST_PERMISSIONS_REQUIRED_FOR_HOOKS}: declared hooks require at least one hostPermissions entry`
14968
+ );
14969
+ }
14970
+ }
14971
+ function validateManifest(manifest, id) {
14972
+ const label = id ?? (typeof manifest?.id === "string" ? manifest.id : "<unknown>");
14973
+ let parsed;
14974
+ try {
14975
+ parsed = ManifestSchema.parse(manifest);
14976
+ } catch (err) {
14977
+ const issues = err.issues;
14978
+ const detail = Array.isArray(issues) ? issues.map((i) => `${i.path.join(".") || "<root>"}: ${i.message}`).join("; ") : err?.message ?? String(err);
14979
+ throw new Error(`${label}: invalid motrix-plugin.json \u2014 ${detail}`);
14980
+ }
14981
+ requireHostPermissionsForHooks(parsed, label);
14982
+ return parsed;
14983
+ }
14984
+ function flattenLocaleKeys(source, prefix = "", out = /* @__PURE__ */ new Set()) {
14985
+ for (const [key, value] of Object.entries(source)) {
14986
+ const path9 = prefix ? `${prefix}.${key}` : key;
14987
+ if (typeof value === "string") out.add(path9);
14988
+ else if (value && typeof value === "object") {
14989
+ flattenLocaleKeys(value, path9, out);
14990
+ }
14991
+ }
14992
+ return out;
14993
+ }
14994
+ function collectLocaleRefs(manifest) {
14995
+ const found = /* @__PURE__ */ new Set();
14996
+ const walk = (node) => {
14997
+ if (typeof node === "string") {
14998
+ const m = node.match(/^%([^%]+)%$/);
14999
+ if (m) found.add(m[1]);
15000
+ return;
15001
+ }
15002
+ if (Array.isArray(node)) {
15003
+ for (const item of node) walk(item);
15004
+ return;
15005
+ }
15006
+ if (node && typeof node === "object") {
15007
+ for (const value of Object.values(node)) walk(value);
15008
+ }
15009
+ };
15010
+ walk(manifest);
15011
+ return [...found];
15012
+ }
15013
+ function checkLocaleCoverage(manifest, baseLocale) {
15014
+ if (!manifest.l10n) return;
15015
+ if (baseLocale == null) throw new Error(LOCALE_MISSING_EN_US);
15016
+ const refs = collectLocaleRefs(manifest);
15017
+ if (refs.length === 0) return;
15018
+ const available = flattenLocaleKeys(baseLocale);
15019
+ const missing = refs.filter((key) => !available.has(key));
15020
+ if (missing.length > 0) {
15021
+ throw new Error(`${LOCALE_MISSING_KEYS}: ${missing.sort().join(", ")}`);
15022
+ }
15023
+ }
14957
15024
 
14958
15025
  // src/commands/lint.ts
14959
15026
  async function lint(projectDir) {
@@ -14992,6 +15059,7 @@ async function lint(projectDir) {
14992
15059
  }
14993
15060
 
14994
15061
  // src/commands/pack.ts
15062
+ import { createHash } from "crypto";
14995
15063
  import { createWriteStream } from "fs";
14996
15064
  import { mkdir as mkdir2, readdir as readdir2, readFile as readFile4, stat as stat3, unlink } from "fs/promises";
14997
15065
  import path6 from "path";
@@ -15003,52 +15071,32 @@ import { readFile as readFile3 } from "fs/promises";
15003
15071
  import path5 from "path";
15004
15072
  async function validateLocaleCoverage(projectDir, manifest) {
15005
15073
  if (!manifest.l10n) return;
15006
- const dir = path5.join(projectDir, manifest.l10n);
15007
- let enUS;
15074
+ let baseLocale = null;
15008
15075
  try {
15009
- enUS = JSON.parse(await readFile3(path5.join(dir, "en-US.json"), "utf8"));
15010
- } catch {
15011
- throw new Error("plugin.manifest.locale.missing_en_us");
15012
- }
15013
- const keys = /* @__PURE__ */ new Set();
15014
- function walk(o, prefix = "") {
15015
- for (const [k, v] of Object.entries(o)) {
15016
- const p = prefix ? `${prefix}.${k}` : k;
15017
- if (typeof v === "string") keys.add(p);
15018
- else if (v && typeof v === "object") walk(v, p);
15019
- }
15020
- }
15021
- walk(enUS);
15022
- const collected = [];
15023
- const visit = (s) => {
15024
- if (typeof s === "string") {
15025
- const m = s.match(/^%([^%]+)%$/);
15026
- if (m) collected.push(m[1]);
15027
- }
15028
- };
15029
- visit(manifest.name);
15030
- visit(manifest.description);
15031
- for (const c of manifest.contributes?.commands ?? []) visit(c.title);
15032
- const missing = collected.filter((k) => !keys.has(k));
15033
- if (missing.length > 0) {
15034
- throw new Error(
15035
- `plugin.manifest.locale.missing_keys: ${missing.join(", ")}`
15076
+ baseLocale = JSON.parse(
15077
+ await readFile3(path5.join(projectDir, manifest.l10n, "en-US.json"), "utf8")
15036
15078
  );
15079
+ } catch {
15080
+ baseLocale = null;
15037
15081
  }
15082
+ checkLocaleCoverage(manifest, baseLocale);
15038
15083
  }
15039
15084
 
15040
15085
  // src/commands/pack.ts
15041
15086
  var BUNDLE_MAX = 1024 * 1024;
15042
15087
  var TOTAL_MAX = 5 * 1024 * 1024;
15088
+ var DOS_EPOCH = new Date(1980, 0, 1, 0, 0, 0);
15089
+ var ENTRY_MODE = 33188;
15043
15090
  async function pack(opts) {
15044
15091
  const projectDir = path6.resolve(opts.projectDir);
15045
15092
  const manifestPath = path6.join(
15046
15093
  projectDir,
15047
15094
  opts.manifest ?? "motrix-plugin.json"
15048
15095
  );
15049
- const manifest = ManifestSchema.parse(
15096
+ const manifest = validateManifest(
15050
15097
  JSON.parse(await readFile4(manifestPath, "utf8"))
15051
15098
  );
15099
+ await validateLocaleCoverage(projectDir, manifest);
15052
15100
  const entry = opts.entry ?? "src/index.ts";
15053
15101
  const outDir = path6.join(projectDir, opts.outDir ?? "dist");
15054
15102
  await mkdir2(outDir, { recursive: true });
@@ -15072,41 +15120,54 @@ async function pack(opts) {
15072
15120
  `bundle ${bundle.byteLength} bytes > ${BUNDLE_MAX} byte cap`
15073
15121
  );
15074
15122
  }
15075
- await validateLocaleCoverage(projectDir, manifest);
15076
15123
  const moextOut = path6.join(outDir, `${manifest.id}-${manifest.version}.moext`);
15077
- const z2 = new yazl.ZipFile();
15078
- z2.addFile(manifestPath, "motrix-plugin.json");
15079
- z2.addFile(bundlePath, "dist/plugin.js");
15124
+ const entries = [
15125
+ { abs: manifestPath, rel: "motrix-plugin.json" },
15126
+ { abs: bundlePath, rel: "dist/plugin.js" }
15127
+ ];
15080
15128
  for (const f of ["icon.png", "LICENSE", "CHANGELOG.md"]) {
15081
15129
  try {
15082
15130
  await stat3(path6.join(projectDir, f));
15083
- z2.addFile(path6.join(projectDir, f), f);
15131
+ entries.push({ abs: path6.join(projectDir, f), rel: f });
15084
15132
  } catch {
15085
15133
  }
15086
15134
  }
15087
15135
  if (manifest.l10n) {
15136
+ const localeDir = path6.join(projectDir, manifest.l10n);
15137
+ let localeEntries = [];
15088
15138
  try {
15089
- const localeDir = path6.join(projectDir, manifest.l10n);
15090
- const entries = await readdir2(localeDir, { withFileTypes: true });
15091
- for (const e of entries) {
15092
- if (!e.isFile()) continue;
15093
- z2.addFile(path6.join(localeDir, e.name), `${manifest.l10n}/${e.name}`);
15094
- }
15095
- } catch {
15139
+ localeEntries = await readdir2(localeDir);
15140
+ } catch (err) {
15141
+ if (err?.code !== "ENOENT") throw err;
15142
+ }
15143
+ for (const name of localeEntries) {
15144
+ const abs = path6.join(localeDir, name);
15145
+ if (!(await stat3(abs)).isFile()) continue;
15146
+ entries.push({ abs, rel: `${manifest.l10n}/${name}` });
15096
15147
  }
15097
15148
  }
15149
+ entries.sort((a, b) => a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0);
15150
+ const z2 = new yazl.ZipFile();
15151
+ for (const { abs, rel } of entries) {
15152
+ z2.addFile(abs, rel, { mtime: DOS_EPOCH, mode: ENTRY_MODE });
15153
+ }
15098
15154
  z2.end();
15099
15155
  await new Promise((res, rej) => {
15100
15156
  z2.on("error", rej);
15101
15157
  const ws = createWriteStream(moextOut);
15102
15158
  z2.outputStream.pipe(ws).on("close", () => res()).on("error", rej);
15103
15159
  });
15104
- const total = (await stat3(moextOut)).size;
15105
- if (total > TOTAL_MAX) {
15160
+ const archive = await readFile4(moextOut);
15161
+ if (archive.byteLength > TOTAL_MAX) {
15106
15162
  await unlink(moextOut);
15107
- throw new Error(`moext ${total} bytes > ${TOTAL_MAX} byte cap`);
15163
+ throw new Error(`moext ${archive.byteLength} bytes > ${TOTAL_MAX} byte cap`);
15108
15164
  }
15109
- return { outFile: moextOut, bundleSize: bundle.byteLength, totalSize: total };
15165
+ return {
15166
+ outFile: moextOut,
15167
+ bundleSize: bundle.byteLength,
15168
+ totalSize: archive.byteLength,
15169
+ sha256: createHash("sha256").update(archive).digest("hex")
15170
+ };
15110
15171
  }
15111
15172
 
15112
15173
  // src/commands/validate.ts
@@ -15119,7 +15180,7 @@ async function validate(projectDir) {
15119
15180
  path7.join(projectDir, "motrix-plugin.json"),
15120
15181
  "utf8"
15121
15182
  );
15122
- const m = ManifestSchema.parse(JSON.parse(raw));
15183
+ const m = validateManifest(JSON.parse(raw));
15123
15184
  if (isReservedPublisher(m.id)) {
15124
15185
  throw new Error(`publisher name is reserved: "${m.id}"`);
15125
15186
  }
@@ -15192,7 +15253,7 @@ async function validateHostPermissions(projectDir) {
15192
15253
  }
15193
15254
 
15194
15255
  // src/bin/motrix-plugin.ts
15195
- var program = new Command().name("motrix-plugin").description("Motrix plugin developer tools").version("2.0.0");
15256
+ var program = new Command().name("motrix-plugin").description("Motrix plugin developer tools").version("2.1.0");
15196
15257
  program.command("init <name>").addOption(
15197
15258
  new Option("-t, --template <id>", "template").choices(["basic-resolver", "post-action"]).default("basic-resolver")
15198
15259
  ).option("-p, --publisher <pub>", "publisher name", "me").action(
@@ -15207,8 +15268,14 @@ program.command("init <name>").addOption(
15207
15268
  }
15208
15269
  );
15209
15270
  program.command("pack").action(async () => {
15210
- const r = await pack({ projectDir: process.cwd() });
15211
- console.log(`Packed \u2192 ${r.outFile} (${r.totalSize} bytes)`);
15271
+ try {
15272
+ const r = await pack({ projectDir: process.cwd() });
15273
+ console.log(`Packed \u2192 ${r.outFile} (${r.totalSize} bytes)`);
15274
+ console.log(`sha256 ${r.sha256}`);
15275
+ } catch (e) {
15276
+ console.error(e.message);
15277
+ process.exit(1);
15278
+ }
15212
15279
  });
15213
15280
  program.command("validate").action(async () => {
15214
15281
  const r = await validate(process.cwd());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@motrix/plugin-cli",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "motrix-plugin": "dist/bin/motrix-plugin.js",
@@ -18,6 +18,11 @@
18
18
  "directory": "packages/plugin-cli"
19
19
  },
20
20
  "license": "MIT",
21
+ "scripts": {
22
+ "build": "tsup",
23
+ "typecheck": "tsc --noEmit",
24
+ "test": "vitest run"
25
+ },
21
26
  "dependencies": {
22
27
  "chokidar": "^4",
23
28
  "commander": "^12",
@@ -25,16 +30,11 @@
25
30
  "yazl": "^2"
26
31
  },
27
32
  "devDependencies": {
33
+ "@motrix/plugin-manifest-schema": "workspace:*",
28
34
  "@types/node": "^24",
29
35
  "tsup": "^8",
30
36
  "typescript": "^5.6",
31
37
  "vitest": "^3",
32
- "zod": "^4",
33
- "@motrix/plugin-manifest-schema": "2.0.0"
34
- },
35
- "scripts": {
36
- "build": "tsup",
37
- "typecheck": "tsc --noEmit",
38
- "test": "vitest run"
38
+ "zod": "^4"
39
39
  }
40
- }
40
+ }