@capybara-hq/plugin-tools 0.1.0-rc.1 → 0.1.0-rc.10

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.
Files changed (51) hide show
  1. package/README.md +20 -19
  2. package/bin/capybara-plugin.mjs +1 -1
  3. package/dist/check/applied-migrations.d.ts +23 -0
  4. package/dist/check/applied-migrations.js +86 -0
  5. package/dist/check/cli.d.ts +2 -1
  6. package/dist/check/cli.js +7 -3
  7. package/dist/check/config-forms.d.ts +13 -0
  8. package/dist/check/config-forms.js +71 -0
  9. package/dist/check/deps.d.ts +4 -2
  10. package/dist/check/deps.js +14 -2
  11. package/dist/check/index.d.ts +4 -1
  12. package/dist/check/index.js +4 -1
  13. package/dist/check/migrations.d.ts +1 -1
  14. package/dist/check/migrations.js +30 -1
  15. package/dist/check/org-override.d.ts +10 -0
  16. package/dist/check/org-override.js +62 -0
  17. package/dist/check/plugin-pack.js +2 -0
  18. package/dist/check/plugin-permissions.d.ts +34 -1
  19. package/dist/check/plugin-permissions.js +66 -6
  20. package/dist/check/plugin-tool-keys.d.ts +13 -0
  21. package/dist/check/plugin-tool-keys.js +38 -0
  22. package/dist/check/release-metadata.d.ts +5 -0
  23. package/dist/check/release-metadata.js +47 -0
  24. package/dist/check/report.d.ts +4 -3
  25. package/dist/check/report.js +13 -3
  26. package/dist/check/run.d.ts +3 -1
  27. package/dist/check/run.js +12 -1
  28. package/dist/check/source.d.ts +7 -0
  29. package/dist/check/source.js +2 -0
  30. package/dist/check/themes.d.ts +19 -0
  31. package/dist/check/themes.js +112 -0
  32. package/dist/check/tool-design.d.ts +1 -1
  33. package/dist/check/tool-design.js +3 -3
  34. package/dist/check/ui-slots.d.ts +7 -0
  35. package/dist/check/ui-slots.js +30 -0
  36. package/dist/cli.js +0 -2
  37. package/dist/marketplace/cli.d.ts +10 -7
  38. package/dist/marketplace/cli.js +20 -10
  39. package/dist/marketplace/marketplace.d.ts +46 -24
  40. package/dist/marketplace/marketplace.js +232 -60
  41. package/dist/pack/cli.d.ts +3 -10
  42. package/dist/pack/cli.js +3 -40
  43. package/dist/pack/config-schemas.d.ts +34 -0
  44. package/dist/pack/config-schemas.js +151 -0
  45. package/dist/pack/index.d.ts +2 -2
  46. package/dist/pack/index.js +2 -2
  47. package/dist/pack/pack.d.ts +8 -2
  48. package/dist/pack/pack.js +154 -38
  49. package/package.json +4 -4
  50. package/dist/pack/bootstrap.d.ts +0 -25
  51. package/dist/pack/bootstrap.js +0 -70
@@ -1,20 +1,23 @@
1
1
  export declare const MARKETPLACE_USAGE: string;
2
2
  /**
3
3
  * `capybara-plugin marketplace`: writes (`--write`) or checks (`--check`) the repository's
4
- * marketplace manifest against reproducible packs of its public plugins ({@link syncMarketplace}).
4
+ * marketplace manifest, one entry per public plugin at its current version, against reproducible
5
+ * packs of its public plugins ({@link syncMarketplace}).
5
6
  *
6
7
  * - `--root <dir>`: the repository (default: the current directory);
7
8
  * - `--file <path>`: the manifest, relative to the root (default `.capybara-plugin/marketplace.json`);
8
9
  * - `--plugins <glob>`: the plugin directories, relative to the root (default `plugins/*`, or the
9
10
  * root itself when it is a plugin).
10
11
  *
11
- * - `--base <rev>` (with `--check`, on pull requests): the entries the file had at `<rev>` must be
12
- * unchanged and first, in order (`marketplace-append-only`), and the entries added since must
13
- * match the tree's packs (`marketplace-integrity`).
12
+ * - `--base <rev>` (with `--check`, on pull requests): an entry's version may only increase over the
13
+ * versions `<rev>` lists (`marketplace-version-decrease`), a version `<rev>` lists keeps its
14
+ * integrity, and the entries added since must match the tree's packs (`marketplace-integrity`).
14
15
  *
15
- * `--check` exits 1 on any finding (`marketplace-missing`, `marketplace-integrity`,
16
- * `marketplace-append-only`, `marketplace-invalid`); `--write` appends the missing entries and
17
- * exits 1 only on `marketplace-invalid`.
16
+ * `--check` exits 1 on any finding that is not a warning (`marketplace-missing`,
17
+ * `marketplace-display-stale`, `marketplace-integrity`, `marketplace-version-decrease`,
18
+ * `marketplace-invalid`); `--write` writes each plugin's entry (replacing its other versions,
19
+ * refreshing a listed entry's summary, displayName and icon) and exits 1 only on
20
+ * `marketplace-invalid`.
18
21
  * Relative paths resolve against `cwd`. Returns the exit code.
19
22
  */
20
23
  export declare function marketplaceCommand(args: string[], cwd: string): Promise<number>;
@@ -1,25 +1,29 @@
1
1
  import { resolve } from "node:path";
2
2
  import { parseArgs } from "node:util";
3
3
  import { formatFinding } from "../check/report.js";
4
+ import { errorsOf } from "../check/source.js";
4
5
  import { MARKETPLACE_FILE, syncMarketplace } from "./marketplace.js";
5
6
  export const MARKETPLACE_USAGE = "capybara-plugin marketplace --write|--check [--base <rev>] [--root <dir>] [--file <path>]\n" +
6
7
  " [--plugins <glob>]";
7
8
  /**
8
9
  * `capybara-plugin marketplace`: writes (`--write`) or checks (`--check`) the repository's
9
- * marketplace manifest against reproducible packs of its public plugins ({@link syncMarketplace}).
10
+ * marketplace manifest, one entry per public plugin at its current version, against reproducible
11
+ * packs of its public plugins ({@link syncMarketplace}).
10
12
  *
11
13
  * - `--root <dir>`: the repository (default: the current directory);
12
14
  * - `--file <path>`: the manifest, relative to the root (default `.capybara-plugin/marketplace.json`);
13
15
  * - `--plugins <glob>`: the plugin directories, relative to the root (default `plugins/*`, or the
14
16
  * root itself when it is a plugin).
15
17
  *
16
- * - `--base <rev>` (with `--check`, on pull requests): the entries the file had at `<rev>` must be
17
- * unchanged and first, in order (`marketplace-append-only`), and the entries added since must
18
- * match the tree's packs (`marketplace-integrity`).
18
+ * - `--base <rev>` (with `--check`, on pull requests): an entry's version may only increase over the
19
+ * versions `<rev>` lists (`marketplace-version-decrease`), a version `<rev>` lists keeps its
20
+ * integrity, and the entries added since must match the tree's packs (`marketplace-integrity`).
19
21
  *
20
- * `--check` exits 1 on any finding (`marketplace-missing`, `marketplace-integrity`,
21
- * `marketplace-append-only`, `marketplace-invalid`); `--write` appends the missing entries and
22
- * exits 1 only on `marketplace-invalid`.
22
+ * `--check` exits 1 on any finding that is not a warning (`marketplace-missing`,
23
+ * `marketplace-display-stale`, `marketplace-integrity`, `marketplace-version-decrease`,
24
+ * `marketplace-invalid`); `--write` writes each plugin's entry (replacing its other versions,
25
+ * refreshing a listed entry's summary, displayName and icon) and exits 1 only on
26
+ * `marketplace-invalid`.
23
27
  * Relative paths resolve against `cwd`. Returns the exit code.
24
28
  */
25
29
  export async function marketplaceCommand(args, cwd) {
@@ -59,14 +63,20 @@ export async function marketplaceCommand(args, cwd) {
59
63
  });
60
64
  for (const f of r.findings)
61
65
  console.error(formatFinding(f, root));
62
- if (r.findings.length > 0) {
63
- console.error(`\n${r.findings.length} marketplace finding(s)`);
66
+ const errors = errorsOf(r.findings);
67
+ if (errors.length > 0) {
68
+ console.error(`\n${errors.length} marketplace finding(s)`);
64
69
  return 1;
65
70
  }
66
71
  if (values.write) {
67
72
  for (const e of r.added)
68
73
  console.log(`added ${e.name}@${e.version}`);
69
- console.log(r.added.length === 0 ? "marketplace is up to date" : `wrote ${file}`);
74
+ for (const k of r.removed)
75
+ console.log(`removed ${k}`);
76
+ for (const k of r.refreshed)
77
+ console.log(`refreshed ${k}`);
78
+ const changed = r.added.length + r.removed.length + r.refreshed.length > 0;
79
+ console.log(changed ? `wrote ${file}` : "marketplace is up to date");
70
80
  }
71
81
  else {
72
82
  console.log("marketplace is up to date");
@@ -9,44 +9,66 @@ export interface SyncMarketplaceOptions {
9
9
  file: string;
10
10
  /** Plugin directories relative to `root` (`*` within one segment); default as `discoverPlugins`. */
11
11
  plugins?: string;
12
- /** Append the missing entries to `file` (`--write`); otherwise only report them (`--check`). */
12
+ /** Write the plugins' entries into `file` (`--write`); otherwise only report them (`--check`). */
13
13
  write: boolean;
14
14
  /**
15
15
  * A git revision (`--check --base <rev>`, pull requests): the entries the file had there are
16
- * published and must still be there, unchanged and in the same order (only appending is
17
- * allowed; `marketplace-append-only`), and every entry added since must match the tree's pack
18
- * (`marketplace-integrity`). A file the base does not have constrains nothing.
16
+ * published. An entry of a version the base lists must keep its source and integrity
17
+ * (`marketplace-integrity`); an entry of a version the base does not list must be newer than
18
+ * every version the base lists for that plugin (`marketplace-version-decrease`) and, when it is
19
+ * the tree's version, match the tree's pack (`marketplace-integrity`). A file the base does not
20
+ * have constrains nothing.
19
21
  */
20
22
  base?: string;
21
23
  }
22
24
  /** The result of {@link syncMarketplace}. */
23
25
  export interface SyncMarketplaceResult {
24
- /** Entries for `(name, version)` pairs the file does not list yet (appended with `write`). */
26
+ /** New entries: plugins not listed at their current version (written with `write`). */
25
27
  added: MarketplaceEntry[];
28
+ /** `name@version` of the other entries of the tree's plugins (dropped with `write`). */
29
+ removed: string[];
26
30
  /**
27
- * `marketplace-invalid` (the file does not parse, or would not after appending),
28
- * `marketplace-integrity` (with a base: an entry added since names another package or integrity
29
- * than the tree's pack), `marketplace-append-only` (with a base: a published entry changed, moved
30
- * or went away) and, without `write`, `marketplace-missing` (one per added entry).
31
+ * `name@version` of listed entries whose `summary` / `displayName` / `icon` differ from plugin.json
32
+ * (rewritten with `write`; source and integrity never change).
33
+ */
34
+ refreshed: string[];
35
+ /**
36
+ * `marketplace-invalid` (the file does not parse, or would not after writing),
37
+ * `marketplace-integrity` and `marketplace-version-decrease` (with a base, see
38
+ * {@link SyncMarketplaceOptions.base}) and, without `write`, `marketplace-missing` (one per
39
+ * plugin whose current version is not listed, or not as its only entry) and
40
+ * `marketplace-display-stale` (a listed entry's summary / displayName / icon differ from plugin.json).
31
41
  */
32
42
  findings: Finding[];
33
43
  }
34
44
  /**
35
- * `capybara-plugin marketplace`: keeps a plugin repository's `marketplace.json` listing every
36
- * published version of its public plugins (package ecosystem spec §5.2; one entry per
37
- * `(name, version)`, Decision 7 of phase A3b). Each public plugin (not `"private": true`) is packed
38
- * reproducibly with {@link packPlugin}; when the file has no entry for its name and version, an
39
- * entry `{ name, version, summary, homepage?, permissions, source: { npm: { package, version,
40
- * integrity } } }` is added (`integrity` the pack's sha512, which the release then publishes
41
- * byte for byte; `permissions` display only). A version already listed is not packed or compared:
42
- * once published, its bytes are the release gate's business (`release --marketplace`), so a later
43
- * source or toolchain change at the same version is not a finding. With `base`, published entries
44
- * must be unchanged and first in order, and entries added since must match the tree's packs.
45
- * Entries of versions no longer in the tree are kept, so a pinned commit lists what it pins and
46
- * older versions stay importable. The file is written formatted as Biome formats it
47
- * ({@link formatMarketplaceJson}).
48
- * With `write`, the new entries are appended in plugin order (nothing is written when there is any
49
- * other finding).
45
+ * The repository path of a plugin's icon: the plugin.json `icon` (relative to the plugin's
46
+ * directory `dir`) as `./<path from the root>` (an entry's `icon`, read at the marketplace's
47
+ * commit, so it follows every release without naming a tag).
48
+ */
49
+ export declare function repoIconPath(root: string, dir: string, icon: string): string;
50
+ /**
51
+ * `capybara-plugin marketplace`: keeps a plugin repository's `marketplace.json` listing one entry
52
+ * per public plugin, its current version (plugin component management spec §13.2, replacing the
53
+ * one-entry-per-version rule of the package ecosystem spec §5.2). Each public plugin (not
54
+ * `"private": true`) not listed at its version is packed reproducibly with {@link packPlugin} and
55
+ * gets an entry `{ name, version, summary, displayName?, icon?, homepage?, permissions, source: {
56
+ * npm: { package, version, integrity } } }` (`summary` from the plugin.json `description`, a string
57
+ * being `en`; `displayName` copied from plugin.json; `icon` the plugin.json `icon` as a path in the
58
+ * repository, `./<plugin dir>/<icon>`, which a git marketplace reads at its refreshed commit;
59
+ * `integrity` the pack's sha512, which the release
60
+ * then publishes byte for byte; `permissions` display only). The new entry takes the place of the
61
+ * plugin's first entry (a version that moved), the plugin's other entries are dropped (also those
62
+ * of a file written in the older one-entry-per-version form), and a plugin not listed yet is
63
+ * appended. Entries of plugins not in the tree are kept. A version already listed is not packed or
64
+ * compared: once published, its bytes are the release gate's business (`release --marketplace`),
65
+ * so a later source or toolchain change at the same version is not a finding; only its display
66
+ * fields (`summary`, `displayName`, `icon`) are refreshed from the source plugin.json. Every other field
67
+ * of a kept entry stays as written, among them `deprecated`, which the maintainer sets by hand on
68
+ * a published version's entry (not in plugin.json: that would change the packed bytes); a new
69
+ * version's entry has none, and no rule compares it with the base. With `base`, see
70
+ * {@link SyncMarketplaceOptions.base}. The file is written formatted as Biome formats it
71
+ * ({@link formatMarketplaceJson}); nothing is written when there is any other finding.
50
72
  */
51
73
  export declare function syncMarketplace(opts: SyncMarketplaceOptions): Promise<SyncMarketplaceResult>;
52
74
  /**
@@ -4,16 +4,22 @@ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join, relative } from "node:path";
6
6
  import { isSensitivePermissionKey, PACKAGES_MAX_BYTES_DEFAULT, permissionKeys, permissionSetOfPackage, readExtensionPackage, WARNINGS_KEY_PREFIX, } from "@capybara-hq/kernel";
7
- import { MARKETPLACE_LIMITS, PLUGIN_MANIFEST_FILE, parseMarketplaceManifest, SDK_VERSION, } from "@capybara-hq/plugin-sdk";
7
+ import { MARKETPLACE_LIMITS, PLUGIN_MANIFEST_FILE, parseMarketplaceManifest, parsePluginManifest, pluginDescription, SDK_VERSION, } from "@capybara-hq/plugin-sdk";
8
8
  import { discoverPlugins } from "../discover.js";
9
9
  import { integrityOf } from "../integrity.js";
10
10
  import { packPlugin } from "../pack/pack.js";
11
+ import { compareSemver, parseSemver } from "../release/semver.js";
11
12
  export { MARKETPLACE_FILE } from "./file.js";
12
13
  const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
13
- /** The marketplace summary of a plugin: its plugin.json description, cut to the summary limit. */
14
- function summaryOf(description) {
14
+ /** The marketplace summary text: a description cut to the summary limit. */
15
+ function cut(text) {
15
16
  const max = MARKETPLACE_LIMITS.summaryChars;
16
- return description.length <= max ? description : `${description.slice(0, max - 1)}…`;
17
+ return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
18
+ }
19
+ /** The marketplace summary of a plugin: its plugin.json description (`{ en, zh? }`), each cut. */
20
+ function summaryOf(description) {
21
+ const d = pluginDescription(description);
22
+ return { en: cut(d.en), ...(d.zh !== undefined && { zh: cut(d.zh) }) };
17
23
  }
18
24
  /** The permission summary of a packed plugin, as the kernel computes it at import (display only). */
19
25
  async function permissionsOf(bytes, hasExtension) {
@@ -31,28 +37,92 @@ async function permissionsOf(bytes, hasExtension) {
31
37
  /** An entry's version: `version`, else its npm source's. */
32
38
  const versionOf = (e) => e.version ?? ("npm" in e.source ? e.source.npm.version : undefined);
33
39
  /**
34
- * `capybara-plugin marketplace`: keeps a plugin repository's `marketplace.json` listing every
35
- * published version of its public plugins (package ecosystem spec §5.2; one entry per
36
- * `(name, version)`, Decision 7 of phase A3b). Each public plugin (not `"private": true`) is packed
37
- * reproducibly with {@link packPlugin}; when the file has no entry for its name and version, an
38
- * entry `{ name, version, summary, homepage?, permissions, source: { npm: { package, version,
39
- * integrity } } }` is added (`integrity` the pack's sha512, which the release then publishes
40
- * byte for byte; `permissions` display only). A version already listed is not packed or compared:
41
- * once published, its bytes are the release gate's business (`release --marketplace`), so a later
42
- * source or toolchain change at the same version is not a finding. With `base`, published entries
43
- * must be unchanged and first in order, and entries added since must match the tree's packs.
44
- * Entries of versions no longer in the tree are kept, so a pinned commit lists what it pins and
45
- * older versions stay importable. The file is written formatted as Biome formats it
46
- * ({@link formatMarketplaceJson}).
47
- * With `write`, the new entries are appended in plugin order (nothing is written when there is any
48
- * other finding).
40
+ * The repository path of a plugin's icon: the plugin.json `icon` (relative to the plugin's
41
+ * directory `dir`) as `./<path from the root>` (an entry's `icon`, read at the marketplace's
42
+ * commit, so it follows every release without naming a tag).
43
+ */
44
+ export function repoIconPath(root, dir, icon) {
45
+ const rel = relative(root, dir).split("\\").join("/");
46
+ const file = icon.replace(/^\.\//, "");
47
+ return `./${rel === "" ? file : `${rel}/${file}`}`;
48
+ }
49
+ /**
50
+ * The display fields of a plugin's source plugin.json (`dir` its directory under `root`); undefined
51
+ * when it does not parse.
52
+ */
53
+ function displayOf(json, root, dir) {
54
+ const m = parsePluginManifest(json);
55
+ if ("errors" in m)
56
+ return undefined;
57
+ return {
58
+ summary: summaryOf(m.description),
59
+ ...(m.displayName !== undefined && { displayName: m.displayName }),
60
+ ...(m.icon !== undefined && { icon: repoIconPath(root, dir, m.icon) }),
61
+ };
62
+ }
63
+ /** The display fields after `summary`, in this order. */
64
+ const AFTER_SUMMARY = ["displayName", "icon"];
65
+ /**
66
+ * `raw` with its `summary`, `displayName` and `icon` set to `d` (in place; a new `displayName` /
67
+ * `icon` right after `summary`, removed when plugin.json has none); undefined when they already
68
+ * equal.
69
+ */
70
+ function refreshDisplay(raw, d) {
71
+ if (!isObj(raw))
72
+ return undefined;
73
+ if (canonical(raw.summary) === canonical(d.summary) &&
74
+ AFTER_SUMMARY.every((k) => canonical(raw[k]) === canonical(d[k]))) {
75
+ return undefined;
76
+ }
77
+ const after = (out) => {
78
+ for (const k of AFTER_SUMMARY)
79
+ if (d[k] !== undefined)
80
+ out[k] = d[k];
81
+ };
82
+ const out = {};
83
+ for (const [k, v] of Object.entries(raw)) {
84
+ if (AFTER_SUMMARY.includes(k))
85
+ continue;
86
+ out[k] = k === "summary" ? d.summary : v;
87
+ if (k === "summary")
88
+ after(out);
89
+ }
90
+ if (!("summary" in raw)) {
91
+ out.summary = d.summary;
92
+ after(out);
93
+ }
94
+ return out;
95
+ }
96
+ /**
97
+ * `capybara-plugin marketplace`: keeps a plugin repository's `marketplace.json` listing one entry
98
+ * per public plugin, its current version (plugin component management spec §13.2, replacing the
99
+ * one-entry-per-version rule of the package ecosystem spec §5.2). Each public plugin (not
100
+ * `"private": true`) not listed at its version is packed reproducibly with {@link packPlugin} and
101
+ * gets an entry `{ name, version, summary, displayName?, icon?, homepage?, permissions, source: {
102
+ * npm: { package, version, integrity } } }` (`summary` from the plugin.json `description`, a string
103
+ * being `en`; `displayName` copied from plugin.json; `icon` the plugin.json `icon` as a path in the
104
+ * repository, `./<plugin dir>/<icon>`, which a git marketplace reads at its refreshed commit;
105
+ * `integrity` the pack's sha512, which the release
106
+ * then publishes byte for byte; `permissions` display only). The new entry takes the place of the
107
+ * plugin's first entry (a version that moved), the plugin's other entries are dropped (also those
108
+ * of a file written in the older one-entry-per-version form), and a plugin not listed yet is
109
+ * appended. Entries of plugins not in the tree are kept. A version already listed is not packed or
110
+ * compared: once published, its bytes are the release gate's business (`release --marketplace`),
111
+ * so a later source or toolchain change at the same version is not a finding; only its display
112
+ * fields (`summary`, `displayName`, `icon`) are refreshed from the source plugin.json. Every other field
113
+ * of a kept entry stays as written, among them `deprecated`, which the maintainer sets by hand on
114
+ * a published version's entry (not in plugin.json: that would change the packed bytes); a new
115
+ * version's entry has none, and no rule compares it with the base. With `base`, see
116
+ * {@link SyncMarketplaceOptions.base}. The file is written formatted as Biome formats it
117
+ * ({@link formatMarketplaceJson}); nothing is written when there is any other finding.
49
118
  */
50
119
  export async function syncMarketplace(opts) {
51
120
  const findings = [];
52
121
  const at = (rule, message) => findings.push({ file: opts.file, line: 1, rule, message });
122
+ const none = () => ({ added: [], removed: [], refreshed: [], findings });
53
123
  if (!existsSync(opts.file)) {
54
124
  at("marketplace-invalid", `${opts.file} does not exist: create it with schemaVersion, name, owner and "plugins": []`);
55
- return { added: [], findings };
125
+ return none();
56
126
  }
57
127
  let raw;
58
128
  try {
@@ -60,22 +130,23 @@ export async function syncMarketplace(opts) {
60
130
  }
61
131
  catch (e) {
62
132
  at("marketplace-invalid", `not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
63
- return { added: [], findings };
133
+ return none();
64
134
  }
65
135
  const parsed = parseMarketplaceManifest(raw);
66
136
  if ("errors" in parsed) {
67
137
  at("marketplace-invalid", parsed.errors.join("; "));
68
- return { added: [], findings };
138
+ return none();
69
139
  }
140
+ // The parser keeps the order, so parsed.plugins[i] is rawEntries[i].
70
141
  const rawEntries = isObj(raw) && Array.isArray(raw.plugins) ? raw.plugins : [];
71
- /** `name@version` of the entries added since `opts.base` (all of them without a base). */
72
- let addedSinceBase;
142
+ /** `name@version` of the entries the base lists (undefined without a base). */
143
+ let baseKeys;
73
144
  if (opts.base !== undefined) {
74
145
  const baseEntries = entriesAt(opts.root, opts.file, opts.base);
75
- findings.push(...appendOnly(baseEntries, rawEntries, opts.file));
76
- addedSinceBase = new Set(rawEntries.slice(baseEntries.length).map((e) => keyOfRaw(e)));
146
+ findings.push(...publishedRules(baseEntries, rawEntries, opts.file));
147
+ baseKeys = new Set(baseEntries.map(keyOfRaw));
77
148
  }
78
- const added = [];
149
+ const updates = [];
79
150
  const out = await mkdtemp(join(tmpdir(), "capybara-marketplace-"));
80
151
  try {
81
152
  for (const dir of discoverPlugins(opts.root, opts.plugins)) {
@@ -87,15 +158,33 @@ export async function syncMarketplace(opts) {
87
158
  if (pkg.private === true)
88
159
  continue;
89
160
  const source = JSON.parse(await readFile(manifestFile, "utf8"));
161
+ // (the display fields of a listed entry are refreshed from this source plugin.json)
90
162
  const name = String(source.name);
91
163
  const version = String(pkg.version);
92
- const listed = parsed.plugins.filter((e) => e.name === name && versionOf(e) === version);
164
+ const mine = parsed.plugins.flatMap((e, i) => (e.name === name ? [{ e, i }] : []));
165
+ const listed = mine.filter(({ e }) => versionOf(e) === version);
166
+ const dropped = mine
167
+ .filter(({ e }) => versionOf(e) !== version)
168
+ .map(({ e }) => `${name}@${versionOf(e) ?? "(no version)"}`);
93
169
  // A version already listed is published (or about to be): its bytes are the release gate's
94
170
  // business, so a later source or toolchain change at the same version is not a finding here.
95
171
  // Only an entry added since the base is compared with the tree's pack.
96
- const toCompare = listed.filter((e) => addedSinceBase?.has(`${e.name}@${versionOf(e)}`));
97
- if (listed.length > 0 && toCompare.length === 0)
172
+ const toCompare = listed.filter(({ e }) => baseKeys !== undefined && !baseKeys.has(keyOf(e)));
173
+ const kept = listed[0];
174
+ /** Keeps the listed entry: drops the other versions, refreshes its display fields. */
175
+ const keep = (raw) => {
176
+ const display = displayOf(source, opts.root, dir);
177
+ const fresh = display === undefined ? undefined : refreshDisplay(raw, display);
178
+ if (dropped.length > 0 || fresh !== undefined) {
179
+ const entry = fresh ?? raw;
180
+ updates.push({ name, version, entry, isNew: false, dropped, refreshed: !!fresh });
181
+ }
182
+ };
183
+ if (kept !== undefined && toCompare.length === 0) {
184
+ keep(rawEntries[kept.i]);
98
185
  continue;
186
+ }
187
+ // Packed: the plugin is not listed at its version, or its entry is new since the base.
99
188
  const r = await packPlugin(dir, out);
100
189
  const bytes = new Uint8Array(await readFile(r.file));
101
190
  const npm = {
@@ -103,7 +192,7 @@ export async function syncMarketplace(opts) {
103
192
  version: r.manifest.version,
104
193
  integrity: integrityOf(bytes),
105
194
  };
106
- for (const e of toCompare) {
195
+ for (const { e } of toCompare) {
107
196
  const src = "npm" in e.source ? e.source.npm : undefined;
108
197
  if (src?.package !== npm.package || src.integrity !== npm.integrity) {
109
198
  const was = src === undefined
@@ -113,39 +202,79 @@ export async function syncMarketplace(opts) {
113
202
  `with integrity ${npm.integrity}: run capybara-plugin marketplace --write`);
114
203
  }
115
204
  }
116
- if (listed.length > 0)
205
+ if (kept !== undefined) {
206
+ keep(rawEntries[kept.i]);
117
207
  continue;
118
- added.push({
208
+ }
209
+ const entry = {
119
210
  name: r.manifest.name,
120
211
  version: npm.version,
121
- summary: { en: summaryOf(r.manifest.description) },
212
+ summary: summaryOf(r.manifest.description),
213
+ ...(r.manifest.displayName !== undefined && { displayName: r.manifest.displayName }),
214
+ ...(r.manifest.icon !== undefined && {
215
+ icon: repoIconPath(opts.root, dir, r.manifest.icon),
216
+ }),
122
217
  ...(r.manifest.homepage !== undefined && { homepage: r.manifest.homepage }),
123
218
  permissions: await permissionsOf(bytes, r.manifest.extension !== undefined),
124
219
  source: { npm },
125
- });
220
+ };
221
+ updates.push({ name, version, entry, isNew: true, dropped, refreshed: false });
126
222
  }
127
223
  }
128
224
  finally {
129
225
  await rm(out, { recursive: true, force: true });
130
226
  }
227
+ const added = updates.filter((u) => u.isNew).map((u) => u.entry);
228
+ const removed = updates.flatMap((u) => u.dropped);
229
+ const refreshed = updates.filter((u) => u.refreshed).map((u) => `${u.name}@${u.version}`);
131
230
  if (!opts.write) {
132
- for (const e of added) {
133
- at("marketplace-missing", `${e.name}@${e.version} is not listed: run capybara-plugin marketplace --write`);
231
+ for (const u of updates) {
232
+ if (u.refreshed) {
233
+ at("marketplace-display-stale", `${u.name}@${u.version} has a summary or displayName other than its plugin.json's: ` +
234
+ "run capybara-plugin marketplace --write");
235
+ }
236
+ if (!u.isNew && u.dropped.length === 0)
237
+ continue;
238
+ const also = u.dropped.map((d) => d.slice(u.name.length + 1)).join(", ");
239
+ at("marketplace-missing", u.isNew
240
+ ? `${u.name}@${u.version} is not listed${also === "" ? "" : ` (the marketplace lists ${also})`}: ` +
241
+ "one entry per plugin, its current version; run capybara-plugin marketplace --write"
242
+ : `${u.name}@${u.version} is not the plugin's only entry (also ${also}): ` +
243
+ "one entry per plugin, its current version; run capybara-plugin marketplace --write");
134
244
  }
135
- return { added, findings };
245
+ return { added, removed, refreshed, findings };
136
246
  }
137
- if (findings.length > 0 || added.length === 0)
138
- return { added, findings };
247
+ if (findings.length > 0 || updates.length === 0)
248
+ return { added, removed, refreshed, findings };
249
+ const byName = new Map(updates.map((u) => [u.name, u]));
250
+ const placed = new Set();
251
+ const plugins = [];
252
+ for (const e of rawEntries) {
253
+ const name = isObj(e) ? String(e.name) : "";
254
+ const u = byName.get(name);
255
+ if (u === undefined) {
256
+ plugins.push(e);
257
+ }
258
+ else if (!placed.has(name)) {
259
+ placed.add(name);
260
+ plugins.push(u.entry);
261
+ }
262
+ }
263
+ for (const u of updates)
264
+ if (!placed.has(u.name))
265
+ plugins.push(u.entry);
139
266
  const next = isObj(raw) ? { ...raw } : {};
140
- next.plugins = [...(Array.isArray(next.plugins) ? next.plugins : []), ...added];
267
+ next.plugins = plugins;
141
268
  const check = parseMarketplaceManifest(next);
142
269
  if ("errors" in check) {
143
270
  at("marketplace-invalid", `the new entries do not parse: ${check.errors.join("; ")}`);
144
- return { added, findings };
271
+ return { added, removed, refreshed, findings };
145
272
  }
146
273
  await writeFile(opts.file, formatMarketplaceJson(next));
147
- return { added, findings };
274
+ return { added, removed, refreshed, findings };
148
275
  }
276
+ /** `name@version` of a parsed entry (`?` without a version). */
277
+ const keyOf = (e) => `${e.name}@${versionOf(e) ?? "?"}`;
149
278
  /** `name@version` of a raw entry (`?` for what is missing). */
150
279
  function keyOfRaw(e) {
151
280
  if (!isObj(e))
@@ -153,7 +282,7 @@ function keyOfRaw(e) {
153
282
  const npm = isObj(e.source) && isObj(e.source.npm) ? e.source.npm : {};
154
283
  return `${String(e.name ?? "?")}@${String(e.version ?? npm.version ?? "?")}`;
155
284
  }
156
- /** JSON with object keys sorted (deep equality of entries regardless of key order). */
285
+ /** JSON with object keys sorted (deep equality regardless of key order). */
157
286
  function canonical(v) {
158
287
  if (Array.isArray(v))
159
288
  return `[${v.map(canonical).join(",")}]`;
@@ -184,25 +313,68 @@ function entriesAt(root, file, base) {
184
313
  const raw = JSON.parse(text);
185
314
  return isObj(raw) && Array.isArray(raw.plugins) ? raw.plugins : [];
186
315
  }
316
+ /** A raw entry's version when it is a plain semver (else undefined: not ordered). */
317
+ function semverOfRaw(e) {
318
+ const v = keyOfRaw(e).split("@").pop();
319
+ return v !== undefined && parseSemver(v) !== undefined ? v : undefined;
320
+ }
187
321
  /**
188
- * `marketplace-append-only`: the base's entries are published, so the current list must start with
189
- * exactly them, in the same order (an entry is never edited, removed or moved; new ones go last).
322
+ * The rules on the entries published at the base: `marketplace-integrity`, a version the base
323
+ * lists keeps its source (package and integrity); `marketplace-version-decrease`, a version the
324
+ * base does not list must be newer than every version the base lists for that plugin, and a
325
+ * plugin's newest listed version may not be older than the newest the base lists. Display
326
+ * fields (summary, displayName, permissions, homepage) may change, entries may move, and a
327
+ * plugin's older versions may go (one entry per plugin).
190
328
  */
191
- function appendOnly(base, current, file) {
329
+ function publishedRules(base, current, file) {
192
330
  const out = [];
193
- base.forEach((b, i) => {
194
- const c = current[i];
195
- if (c !== undefined && canonical(c) === canonical(b))
196
- return;
197
- const key = keyOfRaw(b);
198
- const moved = current.some((x) => canonical(x) === canonical(b));
199
- out.push({
200
- file,
201
- line: 1,
202
- rule: "marketplace-append-only",
203
- message: `${key} is published at the base and ${moved ? "moved" : c !== undefined && keyOfRaw(c) === key ? "was edited" : "went away"}: published entries never change and new ones are appended at the end`,
204
- });
205
- });
331
+ const finding = (rule, message) => out.push({ file, line: 1, rule, message });
332
+ const baseByKey = new Map(base.map((b) => [keyOfRaw(b), b]));
333
+ const newest = new Map();
334
+ for (const b of base) {
335
+ const v = semverOfRaw(b);
336
+ const name = isObj(b) ? String(b.name) : "?";
337
+ const top = newest.get(name);
338
+ if (v !== undefined && (top === undefined || compareSemver(v, top) > 0))
339
+ newest.set(name, v);
340
+ }
341
+ /** `name@version` of the entries reported as `marketplace-version-decrease`. */
342
+ const decreased = new Set();
343
+ for (const c of current) {
344
+ const key = keyOfRaw(c);
345
+ const b = baseByKey.get(key);
346
+ const src = (e) => (isObj(e) ? e.source : undefined);
347
+ if (b !== undefined) {
348
+ if (canonical(src(c)) !== canonical(src(b))) {
349
+ finding("marketplace-integrity", `${key} is published at the base with the source ${canonical(src(b))}, now ` +
350
+ `${canonical(src(c))}: a published version keeps its integrity (bump the version instead)`);
351
+ }
352
+ continue;
353
+ }
354
+ const v = semverOfRaw(c);
355
+ const top = newest.get(isObj(c) ? String(c.name) : "?");
356
+ if (v !== undefined && top !== undefined && compareSemver(v, top) < 0)
357
+ decreased.add(key);
358
+ }
359
+ // A plugin's newest listed version may not fall below the newest the base lists, also when it
360
+ // moves back to an older version the base lists too (a base in the older form).
361
+ const newestNow = new Map();
362
+ for (const c of current) {
363
+ const v = semverOfRaw(c);
364
+ const name = isObj(c) ? String(c.name) : "?";
365
+ const top = newestNow.get(name);
366
+ if (v !== undefined && (top === undefined || compareSemver(v, top) > 0))
367
+ newestNow.set(name, v);
368
+ }
369
+ for (const [name, v] of newestNow) {
370
+ const top = newest.get(name);
371
+ if (top !== undefined && compareSemver(v, top) < 0)
372
+ decreased.add(`${name}@${v}`);
373
+ }
374
+ for (const key of decreased) {
375
+ const name = key.slice(0, key.lastIndexOf("@"));
376
+ finding("marketplace-version-decrease", `${key} is older than ${newest.get(name)}, which the base lists: an entry's version may only increase`);
377
+ }
206
378
  return out;
207
379
  }
208
380
  const INDENT = " ";
@@ -1,19 +1,12 @@
1
- export declare const PACK_USAGE: string;
1
+ export declare const PACK_USAGE = "capybara-plugin pack [<dir>...] [--all <root>] [--out <dir>]";
2
2
  /**
3
3
  * `capybara-plugin pack`: packs plugin directories into plugin tgz files.
4
4
  *
5
5
  * - `<dir>...`: the plugin directories to pack;
6
6
  * - `--all <root>`: also every plugin of the repository at `<root>` (`<root>/plugins/*`, or `<root>`
7
7
  * itself when it is a plugin);
8
- * - `--bootstrap <manifest>`: the bootstrap directory an image bakes: every entry of the bootstrap
9
- * manifest as `<name>-<version>.tgz`, checked against its pins (workspace entries' `path` is
10
- * relative to `--root`, default the current directory; npm entries are downloaded from
11
- * `--registry`, default `CAPYBARA_PACKAGES__NPM_REGISTRY` (the kernel's `packages.npmRegistry`)
12
- * or else the public npm registry, and must match their pinned sha512; `--npm-token`, default
13
- * `CAPYBARA_PACKAGES__NPM_TOKEN` (the kernel's `packages.npmToken`), is sent as a Bearer token to
14
- * the registry origin only);
15
- * - `--out <dir>`: output directory, default `artifacts/plugins` (`artifacts/bootstrap` with
16
- * `--bootstrap`) under the current directory. The last `--out` wins.
8
+ * - `--out <dir>`: output directory, default `artifacts/plugins` under the current directory. The
9
+ * last `--out` wins.
17
10
  *
18
11
  * Relative paths resolve against `cwd` (the current directory). Returns the exit code.
19
12
  */