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

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 (73) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +2 -0
  3. package/README.md +111 -2
  4. package/bin/capybara-plugin.mjs +12 -0
  5. package/dist/check/applied-migrations.d.ts +28 -0
  6. package/dist/check/applied-migrations.js +65 -0
  7. package/dist/check/cli.d.ts +16 -0
  8. package/dist/check/cli.js +69 -0
  9. package/dist/check/deps.d.ts +23 -0
  10. package/dist/check/deps.js +207 -0
  11. package/dist/check/index.d.ts +15 -0
  12. package/dist/check/index.js +14 -0
  13. package/dist/check/migrations.d.ts +11 -0
  14. package/dist/check/migrations.js +93 -0
  15. package/dist/check/plugin-pack.d.ts +17 -0
  16. package/dist/check/plugin-pack.js +56 -0
  17. package/dist/check/plugin-permissions.d.ts +22 -0
  18. package/dist/check/plugin-permissions.js +392 -0
  19. package/dist/check/plugin-state.d.ts +19 -0
  20. package/dist/check/plugin-state.js +181 -0
  21. package/dist/check/plugin-versions.d.ts +13 -0
  22. package/dist/check/plugin-versions.js +56 -0
  23. package/dist/check/release-metadata.d.ts +13 -0
  24. package/dist/check/release-metadata.js +80 -0
  25. package/dist/check/report.d.ts +8 -0
  26. package/dist/check/report.js +17 -0
  27. package/dist/check/run.d.ts +36 -0
  28. package/dist/check/run.js +60 -0
  29. package/dist/check/source.d.ts +53 -0
  30. package/dist/check/source.js +153 -0
  31. package/dist/check/sync-versions-cli.d.ts +10 -0
  32. package/dist/check/sync-versions-cli.js +44 -0
  33. package/dist/check/test-imports.d.ts +20 -0
  34. package/dist/check/test-imports.js +158 -0
  35. package/dist/check/tool-design.d.ts +13 -0
  36. package/dist/check/tool-design.js +227 -0
  37. package/dist/cli.d.ts +1 -0
  38. package/dist/cli.js +48 -0
  39. package/dist/discover.d.ts +21 -0
  40. package/dist/discover.js +69 -0
  41. package/dist/index.d.ts +10 -0
  42. package/dist/index.js +10 -0
  43. package/dist/integrity.d.ts +2 -0
  44. package/dist/integrity.js +3 -0
  45. package/dist/marketplace/cli.d.ts +20 -0
  46. package/dist/marketplace/cli.js +80 -0
  47. package/dist/marketplace/file.d.ts +2 -0
  48. package/dist/marketplace/file.js +2 -0
  49. package/dist/marketplace/index.d.ts +2 -0
  50. package/dist/marketplace/index.js +2 -0
  51. package/dist/marketplace/marketplace.d.ts +57 -0
  52. package/dist/marketplace/marketplace.js +245 -0
  53. package/dist/pack/bootstrap.d.ts +25 -0
  54. package/dist/pack/bootstrap.js +70 -0
  55. package/dist/pack/cli.d.ts +20 -0
  56. package/dist/pack/cli.js +109 -0
  57. package/dist/pack/index.d.ts +4 -0
  58. package/dist/pack/index.js +4 -0
  59. package/dist/pack/npm.d.ts +66 -0
  60. package/dist/pack/npm.js +177 -0
  61. package/dist/pack/pack.d.ts +44 -0
  62. package/dist/pack/pack.js +518 -0
  63. package/dist/release/cli.d.ts +30 -0
  64. package/dist/release/cli.js +93 -0
  65. package/dist/release/index.d.ts +4 -0
  66. package/dist/release/index.js +4 -0
  67. package/dist/release/publish-plan.d.ts +68 -0
  68. package/dist/release/publish-plan.js +117 -0
  69. package/dist/release/publish.d.ts +91 -0
  70. package/dist/release/publish.js +274 -0
  71. package/dist/release/semver.d.ts +11 -0
  72. package/dist/release/semver.js +44 -0
  73. package/package.json +61 -3
@@ -0,0 +1,68 @@
1
+ /** A workspace package as the release sees it (`dir` is relative to the repo root, POSIX separators). */
2
+ export interface WorkspacePackage {
3
+ name: string;
4
+ version: string;
5
+ dir: string;
6
+ private: boolean;
7
+ /** Lives under `plugins/`: published as its plugin tgz (`packPlugin`), not as a pnpm pack of the source tree. */
8
+ isPlugin: boolean;
9
+ }
10
+ /**
11
+ * One publish. `pnpm`: `pnpm pack` (rewrites `workspace:` ranges) then `npm publish <tgz>`;
12
+ * `plugin-tgz`: `packPlugin(dir)` then `npm publish <tgz>`.
13
+ */
14
+ export interface PublishStep {
15
+ name: string;
16
+ version: string;
17
+ kind: "pnpm" | "plugin-tgz";
18
+ dir: string;
19
+ /** `npm publish --tag`: see `distTagFor`. */
20
+ tag: string;
21
+ }
22
+ /**
23
+ * The npm dist-tag a version is published under, following the image `latest` rule: a prerelease
24
+ * goes to `next`; a stable version below the highest stable version already on the registry (a
25
+ * backport) goes to `release-<major>.<minor>`, so `latest` never moves back (and npm >= 11, which
26
+ * refuses an untagged publish below `latest`, accepts it); anything else is `latest`. Installing
27
+ * `npm:@capybara-hq/plugin-x` without a version therefore never picks up an rc or a backport.
28
+ */
29
+ export declare function distTagFor(version: string, published: readonly string[]): string;
30
+ /**
31
+ * Library packages that must reach the registry before the ones that depend on them: kernel and testing
32
+ * depend on the SDK, testing depends on the kernel, plugin-tools depends on the SDK and the kernel
33
+ * (testing only peer-depends on it, optionally). Other non-plugin packages follow, then plugins (which
34
+ * only peer-depend on the SDK). Names a repository does not have are skipped.
35
+ */
36
+ export declare const LIBRARY_ORDER: string[];
37
+ /**
38
+ * The publish plan: every non-private package whose current version is not yet on npm, SDK first, kernel
39
+ * before testing, plugins last (each group by name). `published` maps a package name to the versions the
40
+ * registry already has (missing = never published). Skipping already-published versions makes a rerun of
41
+ * a half-finished release idempotent.
42
+ */
43
+ export declare function planPublish(pkgs: readonly WorkspacePackage[], published: ReadonlyMap<string, readonly string[]>): PublishStep[];
44
+ /** The versions listed in a registry packument (`GET <registry>/<name>`); anything else counts as none. */
45
+ export declare function publishedVersionsOf(packument: unknown): string[];
46
+ /**
47
+ * True while none of the packages that would be published has any version on the registry: the first
48
+ * release. Automatic (push-triggered) releases refuse to do it; the first publish is a manual
49
+ * `workflow_dispatch` once the prerequisites are confirmed (P-16).
50
+ */
51
+ export declare function isFirstRelease(pkgs: readonly WorkspacePackage[], published: ReadonlyMap<string, readonly string[]>): boolean;
52
+ /**
53
+ * Whether publishing `version` promotes a release candidate: `version` is stable and the highest
54
+ * version on the registry is a prerelease of the same major.minor.patch (0.1.0 after 0.1.0-rc.1).
55
+ */
56
+ export declare function isPromotion(version: string, published: readonly string[]): boolean;
57
+ /**
58
+ * The packages a release would promote from a release candidate (see {@link isPromotion}), as
59
+ * `name@version`. Automatic (push) releases refuse them: the promotion, like the first release, is a
60
+ * manual `workflow_dispatch`.
61
+ */
62
+ export declare function promotions(pkgs: readonly WorkspacePackage[], published: ReadonlyMap<string, readonly string[]>): string[];
63
+ /**
64
+ * Problems in a library manifest as packed for npm: a leftover `workspace:` range (pnpm pack failed to
65
+ * rewrite it) or the dev-only `@capybara-hq/source` export condition, which points at `src/` that is
66
+ * not shipped (`publishConfig.exports` replaces it).
67
+ */
68
+ export declare function packedManifestIssues(pkg: Record<string, unknown>): string[];
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Pure planning for the npm release: which workspace packages get published, in which order and how.
3
+ * No I/O here; `publish.ts` discovers the workspace and asks the registry. Used by core's release
4
+ * (`pnpm release`) and by any plugin repository (`capybara-plugin release`).
5
+ */
6
+ import { compareSemver, isPrerelease, parseSemver, SEMVER_RE } from "./semver.js";
7
+ /**
8
+ * The npm dist-tag a version is published under, following the image `latest` rule: a prerelease
9
+ * goes to `next`; a stable version below the highest stable version already on the registry (a
10
+ * backport) goes to `release-<major>.<minor>`, so `latest` never moves back (and npm >= 11, which
11
+ * refuses an untagged publish below `latest`, accepts it); anything else is `latest`. Installing
12
+ * `npm:@capybara-hq/plugin-x` without a version therefore never picks up an rc or a backport.
13
+ */
14
+ export function distTagFor(version, published) {
15
+ if (isPrerelease(version))
16
+ return "next";
17
+ const below = published.some((v) => SEMVER_RE.test(v) && !isPrerelease(v) && compareSemver(version, v) < 0);
18
+ if (!below)
19
+ return "latest";
20
+ const [major, minor] = version.split(".");
21
+ return `release-${major}.${minor}`;
22
+ }
23
+ /**
24
+ * Library packages that must reach the registry before the ones that depend on them: kernel and testing
25
+ * depend on the SDK, testing depends on the kernel, plugin-tools depends on the SDK and the kernel
26
+ * (testing only peer-depends on it, optionally). Other non-plugin packages follow, then plugins (which
27
+ * only peer-depend on the SDK). Names a repository does not have are skipped.
28
+ */
29
+ export const LIBRARY_ORDER = [
30
+ "@capybara-hq/plugin-sdk",
31
+ "@capybara-hq/kernel",
32
+ "@capybara-hq/testing",
33
+ "@capybara-hq/plugin-tools",
34
+ ];
35
+ function rank(p) {
36
+ if (p.isPlugin)
37
+ return LIBRARY_ORDER.length + 1;
38
+ const i = LIBRARY_ORDER.indexOf(p.name);
39
+ return i < 0 ? LIBRARY_ORDER.length : i;
40
+ }
41
+ /**
42
+ * The publish plan: every non-private package whose current version is not yet on npm, SDK first, kernel
43
+ * before testing, plugins last (each group by name). `published` maps a package name to the versions the
44
+ * registry already has (missing = never published). Skipping already-published versions makes a rerun of
45
+ * a half-finished release idempotent.
46
+ */
47
+ export function planPublish(pkgs, published) {
48
+ return pkgs
49
+ .filter((p) => !p.private && !(published.get(p.name) ?? []).includes(p.version))
50
+ .sort((a, b) => rank(a) - rank(b) || a.name.localeCompare(b.name))
51
+ .map((p) => ({
52
+ name: p.name,
53
+ version: p.version,
54
+ kind: p.isPlugin ? "plugin-tgz" : "pnpm",
55
+ dir: p.dir,
56
+ tag: distTagFor(p.version, published.get(p.name) ?? []),
57
+ }));
58
+ }
59
+ /** The versions listed in a registry packument (`GET <registry>/<name>`); anything else counts as none. */
60
+ export function publishedVersionsOf(packument) {
61
+ if (typeof packument !== "object" || packument === null)
62
+ return [];
63
+ const versions = packument.versions;
64
+ if (typeof versions !== "object" || versions === null)
65
+ return [];
66
+ return Object.keys(versions);
67
+ }
68
+ /**
69
+ * True while none of the packages that would be published has any version on the registry: the first
70
+ * release. Automatic (push-triggered) releases refuse to do it; the first publish is a manual
71
+ * `workflow_dispatch` once the prerequisites are confirmed (P-16).
72
+ */
73
+ export function isFirstRelease(pkgs, published) {
74
+ return pkgs.every((p) => p.private || (published.get(p.name) ?? []).length === 0);
75
+ }
76
+ /**
77
+ * Whether publishing `version` promotes a release candidate: `version` is stable and the highest
78
+ * version on the registry is a prerelease of the same major.minor.patch (0.1.0 after 0.1.0-rc.1).
79
+ */
80
+ export function isPromotion(version, published) {
81
+ const v = parseSemver(version);
82
+ if (v === undefined || v.pre.length > 0)
83
+ return false;
84
+ const semvers = published.filter((p) => SEMVER_RE.test(p));
85
+ if (semvers.length === 0)
86
+ return false;
87
+ const highest = semvers.reduce((a, b) => (compareSemver(a, b) >= 0 ? a : b));
88
+ const h = parseSemver(highest);
89
+ return h !== undefined && h.pre.length > 0 && h.core.join(".") === v.core.join(".");
90
+ }
91
+ /**
92
+ * The packages a release would promote from a release candidate (see {@link isPromotion}), as
93
+ * `name@version`. Automatic (push) releases refuse them: the promotion, like the first release, is a
94
+ * manual `workflow_dispatch`.
95
+ */
96
+ export function promotions(pkgs, published) {
97
+ return pkgs
98
+ .filter((p) => !p.private && isPromotion(p.version, published.get(p.name) ?? []))
99
+ .map((p) => `${p.name}@${p.version}`)
100
+ .sort();
101
+ }
102
+ /**
103
+ * Problems in a library manifest as packed for npm: a leftover `workspace:` range (pnpm pack failed to
104
+ * rewrite it) or the dev-only `@capybara-hq/source` export condition, which points at `src/` that is
105
+ * not shipped (`publishConfig.exports` replaces it).
106
+ */
107
+ export function packedManifestIssues(pkg) {
108
+ const issues = [];
109
+ const text = JSON.stringify(pkg);
110
+ if (text.includes('"workspace:'))
111
+ issues.push("has a workspace: dependency range");
112
+ if (text.includes('"@capybara-hq/source"'))
113
+ issues.push('exports the "@capybara-hq/source" condition');
114
+ if (pkg.publishConfig !== undefined)
115
+ issues.push("still has publishConfig (not applied)");
116
+ return issues;
117
+ }
@@ -0,0 +1,91 @@
1
+ import { integrityOf } from "../integrity.js";
2
+ import { type WorkspacePackage } from "./publish-plan.js";
3
+ /** Reads every workspace package.json under `root`. Plugins are the packages under `plugins/`. */
4
+ export declare function discoverWorkspace(root: string): WorkspacePackage[];
5
+ /**
6
+ * Keeps the libraries and the plugins whose directory matches one of `patterns` (see
7
+ * {@link RunPublishOptions.plugins}); throws on a pattern that matches no plugin.
8
+ */
9
+ export declare function selectPlugins(pkgs: readonly WorkspacePackage[], patterns: readonly string[] | undefined): WorkspacePackage[];
10
+ /** Registry to compare against; npm reads the same variable when publishing. */
11
+ export declare function registryUrl(): string;
12
+ /**
13
+ * Versions of `name` already on the registry (an anonymous read: every published package is public).
14
+ * 404 means never published. Any other failure stops the release rather than guessing.
15
+ */
16
+ export declare function fetchPublishedVersions(name: string): Promise<string[]>;
17
+ export { integrityOf };
18
+ /** Runs a command; the release passes `cwd` and expects a non-zero exit to throw. */
19
+ export type Exec = (cmd: string, args: string[], cwd: string) => Promise<void>;
20
+ /**
21
+ * Guard for automatic (push) releases: the checkout must be exactly the pushed commit (`expectedHead`,
22
+ * i.e. GITHUB_SHA) with no uncommitted changes to any workspace package.json. What gets published is
23
+ * then always the committed main tree, whose versions only change when a reviewed PR (the Version
24
+ * Packages PR) merges; output of a `changeset version` run can never be published (C1).
25
+ */
26
+ export declare function assertCommittedTree(opts?: {
27
+ root?: string;
28
+ expectedHead?: string | undefined;
29
+ }): void;
30
+ export interface RunPublishOptions {
31
+ /** Pack everything and run `npm publish --dry-run`; nothing reaches the registry. */
32
+ dryRun: boolean;
33
+ /** Pass `--provenance` (needs GitHub Actions with `id-token: write`). */
34
+ provenance: boolean;
35
+ /** Output directory for the tgz files and the plugin checksums (emptied first). */
36
+ out: string;
37
+ /** Repository root (default: the current directory). */
38
+ root?: string;
39
+ /**
40
+ * Refuse the first release (nothing public on the registry yet) and publish nothing: automatic
41
+ * releases set it so the first publish stays a manual `workflow_dispatch` (P-16).
42
+ */
43
+ refuseFirstRelease?: boolean;
44
+ /**
45
+ * Refuse, and publish nothing, when any package would be promoted from a release candidate to its
46
+ * stable version ({@link promotions}): automatic releases set it so the promotion stays manual.
47
+ */
48
+ refusePromotion?: boolean;
49
+ /** Registry lookup (tests). */
50
+ publishedVersions?: (name: string) => Promise<readonly string[]>;
51
+ /** Command runner for `pnpm pack` / `npm publish` (tests). */
52
+ exec?: Exec;
53
+ /** Plugin packer (tests); returns the tgz path. */
54
+ packPluginTgz?: (dir: string, outDir: string) => Promise<string>;
55
+ /** Reads `package/package.json` out of a packed library tgz (tests). */
56
+ readPackedManifest?: (tgz: string) => Record<string, unknown>;
57
+ /** Called after each successful publish with the running count (the CLI writes $GITHUB_OUTPUT). */
58
+ onPublished?: (count: number) => void;
59
+ /**
60
+ * Limits the plugins (packages under `plugins/`) the release looks at to those whose directory,
61
+ * relative to `root`, matches one of these patterns (`*` matches within one path segment); the
62
+ * other plugins are neither looked up nor published. Libraries are unaffected. A pattern that
63
+ * matches no plugin is an error. Unset = every plugin.
64
+ */
65
+ plugins?: readonly string[];
66
+ /**
67
+ * The repository's `marketplace.json`: each plugin pack's sha512 must equal the integrity of that
68
+ * version's npm entry there before it is published ({@link ReleaseIntegrityError} otherwise), so
69
+ * what reaches npm is byte for byte what the reviewed marketplace lists.
70
+ */
71
+ marketplace?: string;
72
+ }
73
+ /**
74
+ * A plugin pack that differs from its `marketplace.json` entry (or has none): every planned plugin
75
+ * is packed and checked before the first publish, so the release stops (`code`
76
+ * `release.integrity_mismatch`) with nothing published.
77
+ */
78
+ export declare class ReleaseIntegrityError extends Error {
79
+ readonly code = "release.integrity_mismatch";
80
+ constructor(message: string);
81
+ }
82
+ /**
83
+ * Publishes every workspace package whose version is not on npm yet, in plan order. After each plugin
84
+ * publish it rewrites `<out>/plugins/SHA512SUMS` (hex, `sha512sum -c` format) and
85
+ * `<out>/plugins/integrity.json` (SRI), so a release that fails half-way still leaves the checksums of
86
+ * what it did publish.
87
+ */
88
+ export declare function runPublish(opts: RunPublishOptions): Promise<{
89
+ published: string[];
90
+ skipped: string[];
91
+ }>;
@@ -0,0 +1,274 @@
1
+ import { execFileSync, spawn } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
4
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
5
+ import { basename, join, resolve } from "node:path";
6
+ import { parseMarketplaceManifest } from "@capybara-hq/plugin-sdk";
7
+ import { integrityOf } from "../integrity.js";
8
+ import { packPlugin } from "../pack/pack.js";
9
+ import { isFirstRelease, packedManifestIssues, planPublish, promotions, publishedVersionsOf, } from "./publish-plan.js";
10
+ /**
11
+ * The workspace groups a release looks at (one level deep, like `pnpm-workspace.yaml` `<group>/*`
12
+ * entries): core's layout, which a plugin repository (`plugins/*`, `tools/*`) is a subset of.
13
+ */
14
+ const WORKSPACE_DIRS = ["packages", "apps", "plugins", "tools"];
15
+ /** Workspace package directories (relative, POSIX) in the working tree. */
16
+ function workspaceDirs(root) {
17
+ const dirs = [];
18
+ for (const group of WORKSPACE_DIRS) {
19
+ const base = join(root, group);
20
+ if (!existsSync(base))
21
+ continue;
22
+ for (const e of readdirSync(base, { withFileTypes: true })) {
23
+ if (e.isDirectory() && existsSync(join(base, e.name, "package.json"))) {
24
+ dirs.push(`${group}/${e.name}`);
25
+ }
26
+ }
27
+ }
28
+ return dirs.sort();
29
+ }
30
+ function toWorkspacePackage(dir, text) {
31
+ const pkg = JSON.parse(text);
32
+ if (typeof pkg.name !== "string" || typeof pkg.version !== "string") {
33
+ throw new Error(`${dir}/package.json needs a name and a version`);
34
+ }
35
+ return {
36
+ name: pkg.name,
37
+ version: pkg.version,
38
+ dir,
39
+ private: pkg.private === true,
40
+ isPlugin: dir.startsWith("plugins/"),
41
+ };
42
+ }
43
+ /** Reads every workspace package.json under `root`. Plugins are the packages under `plugins/`. */
44
+ export function discoverWorkspace(root) {
45
+ return workspaceDirs(root).map((dir) => toWorkspacePackage(dir, readFileSync(join(root, dir, "package.json"), "utf8")));
46
+ }
47
+ /** `--plugins` patterns: `*` matches within one path segment, everything else literally. */
48
+ function pluginPattern(pattern) {
49
+ const body = pattern
50
+ .replace(/\/+$/, "")
51
+ .split("*")
52
+ .map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&"))
53
+ .join("[^/]*");
54
+ return new RegExp(`^${body}$`);
55
+ }
56
+ /**
57
+ * Keeps the libraries and the plugins whose directory matches one of `patterns` (see
58
+ * {@link RunPublishOptions.plugins}); throws on a pattern that matches no plugin.
59
+ */
60
+ export function selectPlugins(pkgs, patterns) {
61
+ if (patterns === undefined)
62
+ return [...pkgs];
63
+ const res = patterns.map((p) => ({ p, re: pluginPattern(p) }));
64
+ for (const { p, re } of res) {
65
+ if (!pkgs.some((k) => k.isPlugin && re.test(k.dir))) {
66
+ throw new Error(`--plugins ${p} matches no plugin under plugins/`);
67
+ }
68
+ }
69
+ return pkgs.filter((k) => !k.isPlugin || res.some(({ re }) => re.test(k.dir)));
70
+ }
71
+ /** Registry to compare against; npm reads the same variable when publishing. */
72
+ export function registryUrl() {
73
+ const r = process.env.NPM_CONFIG_REGISTRY ?? process.env.npm_config_registry;
74
+ return (r === undefined || r === "" ? "https://registry.npmjs.org" : r).replace(/\/+$/, "");
75
+ }
76
+ /**
77
+ * Versions of `name` already on the registry (an anonymous read: every published package is public).
78
+ * 404 means never published. Any other failure stops the release rather than guessing.
79
+ */
80
+ export async function fetchPublishedVersions(name) {
81
+ const url = `${registryUrl()}/${name.replace("/", "%2f")}`;
82
+ const res = await fetch(url, { headers: { accept: "application/json" } });
83
+ if (res.status === 404)
84
+ return [];
85
+ if (!res.ok)
86
+ throw new Error(`registry lookup for ${name} failed: HTTP ${res.status}`);
87
+ return publishedVersionsOf(await res.json());
88
+ }
89
+ export { integrityOf };
90
+ /** npm pack's file name: `@scope/name` + version -> `scope-name-<version>.tgz`. */
91
+ const tgzName = (name, version) => `${name.replace(/^@/, "").replace("/", "-")}-${version}.tgz`;
92
+ /** Runs a command with inherited stdio (its output is the publish log; it never echoes tokens). */
93
+ const run = (cmd, args, cwd) => new Promise((res, rej) => {
94
+ const child = spawn(cmd, args, { cwd, stdio: "inherit", env: process.env });
95
+ child.on("error", rej);
96
+ child.on("exit", (code, signal) => code === 0
97
+ ? res()
98
+ : rej(new Error(`${cmd} ${args.join(" ")} exited with ${signal ?? `code ${code}`}`)));
99
+ });
100
+ /** `package/package.json` of an npm tgz, via the system tar. */
101
+ function readTgzManifest(tgz) {
102
+ return JSON.parse(execFileSync("tar", ["-xzOf", tgz, "package/package.json"], { encoding: "utf8" }));
103
+ }
104
+ /**
105
+ * Guard for automatic (push) releases: the checkout must be exactly the pushed commit (`expectedHead`,
106
+ * i.e. GITHUB_SHA) with no uncommitted changes to any workspace package.json. What gets published is
107
+ * then always the committed main tree, whose versions only change when a reviewed PR (the Version
108
+ * Packages PR) merges; output of a `changeset version` run can never be published (C1).
109
+ */
110
+ export function assertCommittedTree(opts = {}) {
111
+ const root = opts.root ?? process.cwd();
112
+ if (opts.expectedHead !== undefined && opts.expectedHead !== "") {
113
+ const head = execFileSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8" }).trim();
114
+ if (head !== opts.expectedHead) {
115
+ throw new Error(`the release checkout is at ${head}, not the pushed commit ${opts.expectedHead}: refusing to publish`);
116
+ }
117
+ }
118
+ const dirty = execFileSync("git", ["status", "--porcelain", "--", ...workspaceDirs(root).map((d) => `${d}/package.json`)], { cwd: root, encoding: "utf8" }).trim();
119
+ if (dirty !== "") {
120
+ throw new Error(`uncommitted package.json changes in the release checkout (publish only committed versions):\n${dirty}`);
121
+ }
122
+ }
123
+ /**
124
+ * A plugin pack that differs from its `marketplace.json` entry (or has none): every planned plugin
125
+ * is packed and checked before the first publish, so the release stops (`code`
126
+ * `release.integrity_mismatch`) with nothing published.
127
+ */
128
+ export class ReleaseIntegrityError extends Error {
129
+ code = "release.integrity_mismatch";
130
+ constructor(message) {
131
+ super(`release.integrity_mismatch: ${message}`);
132
+ this.name = "ReleaseIntegrityError";
133
+ }
134
+ }
135
+ /** Reads and parses a marketplace manifest for the release; throws on an unreadable one. */
136
+ function readMarketplace(file) {
137
+ let raw;
138
+ try {
139
+ raw = JSON.parse(readFileSync(file, "utf8"));
140
+ }
141
+ catch (e) {
142
+ throw new Error(`cannot read the marketplace ${file}: ${e instanceof Error ? e.message : e}`);
143
+ }
144
+ const m = parseMarketplaceManifest(raw);
145
+ if ("errors" in m)
146
+ throw new Error(`the marketplace ${file} is invalid: ${m.errors.join("; ")}`);
147
+ return m.plugins;
148
+ }
149
+ /** The npm source a marketplace lists for `<pkg>@<version>`, if any. */
150
+ function marketplaceNpm(entries, pkg, version) {
151
+ for (const e of entries) {
152
+ if ("npm" in e.source && e.source.npm.package === pkg && e.source.npm.version === version) {
153
+ return e.source.npm;
154
+ }
155
+ }
156
+ return undefined;
157
+ }
158
+ /**
159
+ * Publishes every workspace package whose version is not on npm yet, in plan order. After each plugin
160
+ * publish it rewrites `<out>/plugins/SHA512SUMS` (hex, `sha512sum -c` format) and
161
+ * `<out>/plugins/integrity.json` (SRI), so a release that fails half-way still leaves the checksums of
162
+ * what it did publish.
163
+ */
164
+ export async function runPublish(opts) {
165
+ const root = opts.root ?? process.cwd();
166
+ const out = resolve(opts.out);
167
+ const lookup = opts.publishedVersions ?? fetchPublishedVersions;
168
+ const exec = opts.exec ?? run;
169
+ const packTgz = opts.packPluginTgz ?? (async (dir, o) => (await packPlugin(dir, o)).file);
170
+ const readManifest = opts.readPackedManifest ?? readTgzManifest;
171
+ const pkgs = selectPlugins(discoverWorkspace(root), opts.plugins);
172
+ const listed = opts.marketplace === undefined ? undefined : readMarketplace(opts.marketplace);
173
+ if (pkgs.length === 0) {
174
+ throw new Error(`no workspace package found under ${root}: the release publishes the packages in ` +
175
+ `${WORKSPACE_DIRS.map((d) => `${d}/*`).join(", ")} (each a directory with a package.json; ` +
176
+ "plugins are the ones under plugins/); pass --root <repository root>");
177
+ }
178
+ const published = new Map();
179
+ for (const p of pkgs)
180
+ if (!p.private)
181
+ published.set(p.name, await lookup(p.name));
182
+ if (opts.refuseFirstRelease === true && isFirstRelease(pkgs, published)) {
183
+ console.log("nothing is on the registry yet: the first release is published only by running the " +
184
+ "release workflow manually (workflow_dispatch)");
185
+ return { published: [], skipped: [] };
186
+ }
187
+ const promoting = opts.refusePromotion === true ? promotions(pkgs, published) : [];
188
+ if (promoting.length > 0) {
189
+ console.log(`refusing to promote release candidates on push (${promoting.join(", ")}): the stable ` +
190
+ "release after an rc is published only by running the release workflow manually " +
191
+ "(workflow_dispatch)");
192
+ return { published: [], skipped: [] };
193
+ }
194
+ const steps = planPublish(pkgs, published);
195
+ const planned = new Set(steps.map((s) => s.name));
196
+ const skipped = pkgs
197
+ .filter((p) => !p.private && !planned.has(p.name))
198
+ .map((p) => `${p.name}@${p.version}`)
199
+ .sort();
200
+ for (const s of skipped)
201
+ console.log(`skip ${s} (already on the registry)`);
202
+ for (const s of steps)
203
+ console.log(`publish ${s.name}@${s.version} (${s.kind}, ${s.dir}, tag ${s.tag})`);
204
+ if (steps.length === 0) {
205
+ console.log("nothing to publish");
206
+ return { published: [], skipped };
207
+ }
208
+ await rm(out, { recursive: true, force: true });
209
+ const pluginsOut = join(out, "plugins");
210
+ const packagesOut = join(out, "packages");
211
+ await mkdir(pluginsOut, { recursive: true });
212
+ await mkdir(packagesOut, { recursive: true });
213
+ const done = [];
214
+ const sums = [];
215
+ const integrity = [];
216
+ // Every plugin is packed and checked against the marketplace before anything is published, so a
217
+ // mismatch stops the run with nothing new on the registry (dry runs check the same way).
218
+ const pluginFiles = new Map();
219
+ for (const step of steps.filter((s) => s.kind === "plugin-tgz")) {
220
+ const file = await packTgz(join(root, step.dir), pluginsOut);
221
+ if (listed !== undefined) {
222
+ const actual = integrityOf(new Uint8Array(await readFile(file)));
223
+ const entry = marketplaceNpm(listed, step.name, step.version);
224
+ if (entry === undefined) {
225
+ throw new ReleaseIntegrityError(`${step.name}@${step.version} (${step.dir}) has no npm entry in ${opts.marketplace}; its pack is ${actual} (capybara-plugin marketplace --write); nothing was published`);
226
+ }
227
+ if (entry.integrity !== actual) {
228
+ throw new ReleaseIntegrityError(`${step.name}@${step.version} (${step.dir}) packs ${actual} but ${opts.marketplace} lists ${entry.integrity}; nothing was published`);
229
+ }
230
+ }
231
+ pluginFiles.set(step.dir, file);
232
+ }
233
+ for (const step of steps) {
234
+ const dir = join(root, step.dir);
235
+ let file;
236
+ if (step.kind === "plugin-tgz") {
237
+ file = pluginFiles.get(step.dir);
238
+ }
239
+ else {
240
+ if (!existsSync(join(dir, "dist"))) {
241
+ throw new Error(`${step.dir}/dist is missing: run \`pnpm build\` before releasing`);
242
+ }
243
+ // pnpm pack rewrites `workspace:` ranges and applies publishConfig; npm publish adds provenance.
244
+ await exec("pnpm", ["pack", "--pack-destination", packagesOut], dir);
245
+ file = join(packagesOut, tgzName(step.name, step.version));
246
+ if (!existsSync(file))
247
+ throw new Error(`pnpm pack did not produce ${file}`);
248
+ const issues = packedManifestIssues(readManifest(file));
249
+ if (issues.length > 0)
250
+ throw new Error(`${basename(file)}: ${issues.join("; ")}`);
251
+ }
252
+ const args = ["publish", file, "--access", "public", "--tag", step.tag];
253
+ if (opts.provenance)
254
+ args.push("--provenance");
255
+ if (opts.dryRun)
256
+ args.push("--dry-run");
257
+ await exec("npm", args, root);
258
+ done.push(`${step.name}@${step.version}`);
259
+ opts.onPublished?.(done.length);
260
+ if (step.kind === "plugin-tgz") {
261
+ const bytes = await readFile(file);
262
+ sums.push(`${createHash("sha512").update(bytes).digest("hex")} ${basename(file)}`);
263
+ integrity.push({
264
+ name: step.name,
265
+ version: step.version,
266
+ file: basename(file),
267
+ integrity: integrityOf(bytes),
268
+ });
269
+ await writeFile(join(pluginsOut, "SHA512SUMS"), sums.map((l) => `${l}\n`).join(""));
270
+ await writeFile(join(pluginsOut, "integrity.json"), `${JSON.stringify(integrity, null, 2)}\n`);
271
+ }
272
+ }
273
+ return { published: done, skipped };
274
+ }
@@ -0,0 +1,11 @@
1
+ /** Plain semver helpers shared by the npm dist-tag rule and core's image `latest` rule. */
2
+ /** A plain semver without build metadata (also a valid image tag: `+` is not allowed in tags). */
3
+ export declare const SEMVER_RE: RegExp;
4
+ export declare const isPrerelease: (version: string) => boolean;
5
+ /** Parses a plain semver (`latest`, digests and other strings give undefined). */
6
+ export declare function parseSemver(v: string): {
7
+ core: number[];
8
+ pre: string[];
9
+ } | undefined;
10
+ /** Semver precedence (no build metadata): negative when a < b. Both must be plain semvers. */
11
+ export declare function compareSemver(a: string, b: string): number;
@@ -0,0 +1,44 @@
1
+ /** Plain semver helpers shared by the npm dist-tag rule and core's image `latest` rule. */
2
+ /** A plain semver without build metadata (also a valid image tag: `+` is not allowed in tags). */
3
+ export const SEMVER_RE = /^\d+\.\d+\.\d+(-[0-9A-Za-z]+(\.[0-9A-Za-z-]+)*)?$/;
4
+ export const isPrerelease = (version) => version.includes("-");
5
+ /** Parses a plain semver (`latest`, digests and other strings give undefined). */
6
+ export function parseSemver(v) {
7
+ if (!SEMVER_RE.test(v))
8
+ return undefined;
9
+ const [core = "", pre] = v.split(/-(.*)/s);
10
+ return { core: core.split(".").map(Number), pre: pre === undefined ? [] : pre.split(".") };
11
+ }
12
+ /** Semver precedence (no build metadata): negative when a < b. Both must be plain semvers. */
13
+ export function compareSemver(a, b) {
14
+ const x = parseSemver(a);
15
+ const y = parseSemver(b);
16
+ if (x === undefined || y === undefined)
17
+ throw new Error(`not a semver: ${a} / ${b}`);
18
+ for (let i = 0; i < 3; i++) {
19
+ const d = (x.core[i] ?? 0) - (y.core[i] ?? 0);
20
+ if (d !== 0)
21
+ return d;
22
+ }
23
+ // A version without a prerelease part ranks above any prerelease of it.
24
+ if (x.pre.length === 0 || y.pre.length === 0)
25
+ return y.pre.length - x.pre.length;
26
+ for (let i = 0; i < Math.max(x.pre.length, y.pre.length); i++) {
27
+ const p = x.pre[i];
28
+ const q = y.pre[i];
29
+ if (p === undefined)
30
+ return -1;
31
+ if (q === undefined)
32
+ return 1;
33
+ if (p === q)
34
+ continue;
35
+ const pn = /^\d+$/.test(p);
36
+ const qn = /^\d+$/.test(q);
37
+ if (pn && qn)
38
+ return Number(p) - Number(q);
39
+ if (pn !== qn)
40
+ return pn ? -1 : 1;
41
+ return p < q ? -1 : 1;
42
+ }
43
+ return 0;
44
+ }