@decohere/ci-tools 0.0.1-dev.20 → 0.0.1-dev.20261105.103

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 (43) hide show
  1. package/LICENSE +15 -0
  2. package/package.json +17 -3
  3. package/src/audit/enforce.mjs +7 -5
  4. package/src/audit/format.mjs +133 -0
  5. package/src/audit/summarize.mjs +10 -32
  6. package/src/audit/upsert-issue.mjs +25 -74
  7. package/src/bench/bench.mjs +135 -368
  8. package/src/bench/file-regressions.mjs +76 -0
  9. package/src/bench/history.mjs +248 -0
  10. package/src/bench/nightly.mjs +118 -0
  11. package/src/bench/regression-issue.mjs +44 -0
  12. package/src/bench/trace.mjs +18 -14
  13. package/src/github/first-publish-check.mjs +70 -0
  14. package/src/github/npm-bootstrap.mjs +182 -0
  15. package/src/github/npm-trust-promote.mjs +139 -0
  16. package/src/github/publish-verify.mjs +204 -0
  17. package/src/github/smoke-create-launcher.mjs +85 -0
  18. package/src/lib/github-issue.mjs +75 -0
  19. package/src/lib/lcov.mjs +1 -1
  20. package/src/lib/workspace-packages.mjs +30 -0
  21. package/src/pages/artifact.mjs +38 -183
  22. package/src/pages/bench-page.mjs +324 -0
  23. package/src/pages/restore-badges.mjs +35 -0
  24. package/src/pages/restore-bench-history.mjs +15 -28
  25. package/src/release/npm-links.mjs +6 -13
  26. package/src/release/promote-latest.mjs +122 -0
  27. package/src/release/publish-to-temporary-registry.mjs +65 -0
  28. package/src/release/sync-dependencies.mjs +1 -2
  29. package/tests/audit/format.test.mjs +115 -0
  30. package/tests/bench/history.test.mjs +279 -0
  31. package/tests/bench/regression-issue.test.mjs +42 -0
  32. package/tests/github/ci-workflow.test.mjs +30 -0
  33. package/tests/github/first-publish-check.test.mjs +51 -0
  34. package/tests/github/npm-bootstrap.test.mjs +125 -0
  35. package/tests/github/package-metadata.test.mjs +69 -0
  36. package/tests/github/publish-verify.test.mjs +115 -0
  37. package/tests/github/smoke-create-launcher.test.mjs +132 -0
  38. package/tests/lib/github-issue.test.mjs +85 -0
  39. package/tests/lib/lcov.test.mjs +8 -8
  40. package/tests/pages/bench-page.test.mjs +123 -0
  41. package/src/coverage/enforce-thresholds.mjs +0 -144
  42. package/src/pages/clear-bench-history.mjs +0 -27
  43. package/src/pages/keep-prior-bench-badge.mjs +0 -38
@@ -0,0 +1,182 @@
1
+ /**
2
+ * First-publish a new package name (public) and give it its Trusted Publisher,
3
+ * so `publish.yml` can release it over OIDC from then on.
4
+ *
5
+ * pnpm npm:bootstrap [--otp <code>] [--dry-run] [--channel dev] [<package-name>...]
6
+ *
7
+ * Without names: every publishable package whose name is not on npm yet.
8
+ * Run from the repo root after `npm login` to the org. Each step is skipped when
9
+ * it is already done (the name exists, the Trusted Publisher is set).
10
+ *
11
+ * npm runs on this terminal, so it does its own 2FA: it asks for a code, or
12
+ * opens the browser for web authentication. `--otp` hands it a code up front
13
+ * through `NPM_CONFIG_OTP`; `npm trust` takes no `--otp` flag of its own.
14
+ */
15
+ import { spawnSync } from "node:child_process";
16
+ import { resolve } from "node:path";
17
+ import { parseArgs } from "node:util";
18
+ import { pathToFileURL } from "node:url";
19
+ import { listPublishablePackages, npmNameExists } from "./publish-verify.mjs";
20
+
21
+ /** The Trusted Publisher every package gets: `publish.yml`'s npm job. */
22
+ export const TRUSTED_PUBLISHER = {
23
+ repo: "typesmiths/decohere",
24
+ file: "publish.yml",
25
+ environment: "npm-publish",
26
+ };
27
+
28
+ /** @param {string | undefined} version */
29
+ export function needsPrereleaseTag(version) {
30
+ return String(version ?? "").includes("-");
31
+ }
32
+
33
+ /**
34
+ * npm arguments for a name-only first publish. Scoped names default to restricted,
35
+ * so `--access public`. `./` stops npm reading the path as a GitHub `owner/repo`.
36
+ *
37
+ * @param {{ dir: string, version?: string }} pkg
38
+ * @param {string} channel
39
+ */
40
+ export function publishArgs(pkg, channel) {
41
+ const tag = needsPrereleaseTag(pkg.version) ? ["--tag", channel] : [];
42
+ return ["publish", `./projects/${pkg.dir}`, "--access", "public", ...tag];
43
+ }
44
+
45
+ /** @param {string} name */
46
+ export function trustArgs(name) {
47
+ return [
48
+ "trust",
49
+ "github",
50
+ name,
51
+ "--file",
52
+ TRUSTED_PUBLISHER.file,
53
+ "--repo",
54
+ TRUSTED_PUBLISHER.repo,
55
+ "--env",
56
+ TRUSTED_PUBLISHER.environment,
57
+ "--allow-publish",
58
+ "--yes",
59
+ ];
60
+ }
61
+
62
+ /** @param {string} output `npm trust list <name> --json` */
63
+ export function hasTrustedPublisher(output) {
64
+ return output.includes(TRUSTED_PUBLISHER.file) && output.includes(TRUSTED_PUBLISHER.repo);
65
+ }
66
+
67
+ /**
68
+ * @typedef {{ capture?: boolean, otp?: string }} NpmOptions
69
+ * `capture` keeps stdout to read it (npm can then not prompt for 2FA);
70
+ * otherwise npm owns the terminal.
71
+ */
72
+
73
+ /**
74
+ * @param {string[]} args
75
+ * @param {NpmOptions} [options]
76
+ * @returns {{ status: number, output: string }}
77
+ */
78
+ function runNpm(args, { capture = false, otp } = {}) {
79
+ const result = spawnSync("npm", args, {
80
+ encoding: "utf8",
81
+ stdio: capture ? ["inherit", "pipe", "inherit"] : "inherit",
82
+ env: otp ? { ...process.env, NPM_CONFIG_OTP: otp } : process.env,
83
+ });
84
+ return { status: result.status ?? 1, output: result.stdout ?? "" };
85
+ }
86
+
87
+ /**
88
+ * @param {{
89
+ * argv?: string[],
90
+ * root?: string,
91
+ * io?: {
92
+ * npm?: (args: string[], options?: NpmOptions) => { status: number, output: string },
93
+ * nameExists?: (name: string) => boolean,
94
+ * log?: (line: string) => void,
95
+ * },
96
+ * }} [options]
97
+ * @returns {Promise<number>} exit code
98
+ */
99
+ export async function runBootstrap(options = {}) {
100
+ const { values, positionals } = parseArgs({
101
+ args: options.argv ?? process.argv.slice(2),
102
+ options: {
103
+ otp: { type: "string" },
104
+ "dry-run": { type: "boolean", default: false },
105
+ channel: { type: "string", default: "dev" },
106
+ },
107
+ allowPositionals: true,
108
+ });
109
+ const io = options.io ?? {};
110
+ const npm = io.npm ?? runNpm;
111
+ const nameExists = io.nameExists ?? ((name) => npmNameExists(name));
112
+ const log = io.log ?? ((line) => process.stdout.write(`${line}\n`));
113
+ const dryRun = values["dry-run"];
114
+
115
+ // Even on a dry run: the names are private, so `npm view` 404s without a login
116
+ // and every package would look unpublished.
117
+ if (npm(["whoami"]).status !== 0) {
118
+ log("Not logged in to npm: run `npm login` first.");
119
+ return 1;
120
+ }
121
+
122
+ const publishable = listPublishablePackages(options.root ?? process.cwd());
123
+ const unknown = positionals.filter((name) => !publishable.some((pkg) => pkg.name === name));
124
+ if (unknown.length > 0) {
125
+ log(`Not a publishable package in projects/: ${unknown.join(", ")}`);
126
+ return 1;
127
+ }
128
+ const packages =
129
+ positionals.length > 0
130
+ ? publishable.filter((pkg) => positionals.includes(pkg.name))
131
+ : publishable.filter((pkg) => !nameExists(pkg.name));
132
+ if (packages.length === 0) {
133
+ log("Every package name is on npm already. Name one to check its Trusted Publisher.");
134
+ return 0;
135
+ }
136
+
137
+ // A code npm rejects (they last about 30 seconds) makes npm ask for a new one.
138
+ /** @param {string[]} args */
139
+ const change = (args) => {
140
+ if (dryRun) {
141
+ log(`[dry run] npm ${args.join(" ")}`);
142
+ return true;
143
+ }
144
+ return npm(args, { otp: values.otp }).status === 0;
145
+ };
146
+
147
+ for (const pkg of packages) {
148
+ log(`\n${pkg.name} (projects/${pkg.dir}, ${pkg.version})`);
149
+
150
+ if (nameExists(pkg.name)) {
151
+ log(" name is on npm: skip the first publish");
152
+ } else if (!change(publishArgs(pkg, values.channel))) {
153
+ log(` first publish failed: stopping`);
154
+ return 1;
155
+ }
156
+
157
+ const listed = dryRun
158
+ ? { status: 1, output: "" }
159
+ : npm(["trust", "list", pkg.name, "--json"], { capture: true, otp: values.otp });
160
+ if (listed.status === 0 && hasTrustedPublisher(listed.output)) {
161
+ log(" Trusted Publisher is set: skip");
162
+ } else if (!change(trustArgs(pkg.name))) {
163
+ log(" Trusted Publisher failed: stopping");
164
+ return 1;
165
+ }
166
+ }
167
+
168
+ log(
169
+ "\nFor each new package, also run `pnpm npm:trust-promote --otp <code>`, so Promote to latest can move its tag.",
170
+ );
171
+ log(
172
+ "\nDone. Add the PR to the merge queue. If Publish already failed on this, rerun it: `gh run rerun --failed <run-id>`.",
173
+ );
174
+ return 0;
175
+ }
176
+
177
+ const isCli =
178
+ process.argv[1] != null && import.meta.url === pathToFileURL(resolve(process.argv[1])).href;
179
+
180
+ if (isCli) {
181
+ process.exit(await runBootstrap());
182
+ }
@@ -0,0 +1,139 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Gives every public @decohere package the Trusted Publisher `promote-latest.yml` needs, by copying
4
+ * it from one package an owner set up on npmjs.com. `npm trust` has no flag for the "Allow npm
5
+ * dist-tag" permission (`manageDistTags` in the registry), so this calls the same registry endpoint
6
+ * it uses. npm doesn't document that endpoint. It only adds entries, and packages that already have
7
+ * one are skipped.
8
+ *
9
+ * Reads the owner's npm login from NPM_TOKEN, and never prints it.
10
+ *
11
+ * NPM_TOKEN="$(sed -n 's#^//registry.npmjs.org/:_authToken=##p' ~/.npmrc)" \
12
+ * pnpm npm:trust-promote --otp <code> [--from @decohere/cli]
13
+ */
14
+ import { resolve } from "node:path";
15
+ import { pathToFileURL } from "node:url";
16
+ import { parseArgs } from "node:util";
17
+ import { listPublishablePackages } from "./publish-verify.mjs";
18
+
19
+ export const PROMOTE_WORKFLOW = "promote-latest.yml";
20
+ const REGISTRY = "https://registry.npmjs.org";
21
+
22
+ /**
23
+ * @typedef {{ type?: string, claims?: { workflow_ref?: { file?: string } }, permissions?: string[] }} TrustEntry
24
+ */
25
+
26
+ /**
27
+ * @param {TrustEntry[]} entries
28
+ * @returns {TrustEntry | undefined}
29
+ */
30
+ function findPromoteEntry(entries) {
31
+ return entries.find((entry) => entry.claims?.workflow_ref?.file === PROMOTE_WORKFLOW);
32
+ }
33
+
34
+ /**
35
+ * @param {{
36
+ * packages: string[],
37
+ * from: string,
38
+ * token: string,
39
+ * otp: string,
40
+ * fetch: typeof globalThis.fetch,
41
+ * log?: (message: string) => void,
42
+ * }} options
43
+ * @returns {Promise<number>} exit code
44
+ */
45
+ export async function copyPromoteTrust(options) {
46
+ const { packages, from, token, otp, fetch } = options;
47
+ const log = options.log ?? console.log;
48
+ const headers = {
49
+ authorization: `Bearer ${token}`,
50
+ "npm-otp": otp,
51
+ "content-type": "application/json",
52
+ };
53
+ const trustUrl = (name) => `${REGISTRY}/-/package/${name.replace("/", "%2f")}/trust`;
54
+
55
+ const list = async (name) => {
56
+ const response = await fetch(trustUrl(name), { headers });
57
+ if (!response.ok) {
58
+ throw new Error(`reading ${name}: ${response.status} ${await response.text()}`);
59
+ }
60
+ return /** @type {TrustEntry[]} */ (await response.json());
61
+ };
62
+
63
+ let source;
64
+ try {
65
+ source = findPromoteEntry(await list(from));
66
+ } catch (error) {
67
+ log(`Nothing changed. ${String(error)}`);
68
+ return 1;
69
+ }
70
+ if (source == null) {
71
+ log(
72
+ `${from} has no Trusted Publisher for ${PROMOTE_WORKFLOW}. On npmjs.com, in its settings, add ` +
73
+ `one: GitHub Actions, organization typesmiths, repository decohere, workflow ${PROMOTE_WORKFLOW}, ` +
74
+ "no environment, Allow npm publish off, Allow npm dist-tag on. Then run this again. " +
75
+ "If you set it up on another package, pass that one with --from. Nothing changed.",
76
+ );
77
+ return 1;
78
+ }
79
+ log(`Copying from ${from}, with permissions: ${(source.permissions ?? []).join(", ")}`);
80
+
81
+ const entry = { type: source.type, claims: source.claims, permissions: source.permissions };
82
+ const others = packages.filter((name) => name !== from);
83
+ const failures = [];
84
+ // All at once, so one OTP code covers them before it expires.
85
+ await Promise.all(
86
+ others.map(async (name) => {
87
+ try {
88
+ if (findPromoteEntry(await list(name)) != null) {
89
+ log(`already set: ${name}`);
90
+ return;
91
+ }
92
+ const response = await fetch(trustUrl(name), {
93
+ method: "POST",
94
+ headers,
95
+ body: JSON.stringify([entry]),
96
+ });
97
+ if (!response.ok) {
98
+ failures.push(`${name}: ${response.status} ${await response.text()}`);
99
+ return;
100
+ }
101
+ log(`set: ${name}`);
102
+ } catch (error) {
103
+ failures.push(`${name}: ${String(error)}`);
104
+ }
105
+ }),
106
+ );
107
+
108
+ if (failures.length > 0) {
109
+ log(`Failed:\n${failures.join("\n")}\nRun it again with a new code to finish.`);
110
+ return 1;
111
+ }
112
+ log(`Done: ${packages.length} packages have a Trusted Publisher for ${PROMOTE_WORKFLOW}.`);
113
+ return 0;
114
+ }
115
+
116
+ if (process.argv[1] != null && import.meta.url === pathToFileURL(process.argv[1]).href) {
117
+ const { values } = parseArgs({
118
+ options: {
119
+ otp: { type: "string" },
120
+ from: { type: "string", default: "@decohere/cli" },
121
+ },
122
+ });
123
+ const token = process.env.NPM_TOKEN;
124
+ if (!token || !values.otp) {
125
+ console.error(
126
+ `Usage: NPM_TOKEN="$(sed -n 's#^//registry.npmjs.org/:_authToken=##p' ~/.npmrc)" pnpm npm:trust-promote --otp <code>`,
127
+ );
128
+ process.exit(1);
129
+ }
130
+ process.exit(
131
+ await copyPromoteTrust({
132
+ packages: listPublishablePackages(resolve(".")).map((pkg) => pkg.name),
133
+ from: values.from,
134
+ token,
135
+ otp: values.otp,
136
+ fetch: globalThis.fetch,
137
+ }),
138
+ );
139
+ }
@@ -0,0 +1,204 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { readFileSync } from "node:fs";
3
+ import { join, relative, resolve } from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+ import { listProjectDirs } from "../lib/workspace-packages.mjs";
6
+
7
+ /**
8
+ * Workspace packages that `forge publish` will upload (not `"private": true`), including
9
+ * grouped ones such as `projects/data/data-typeorm`.
10
+ * @param {string} root
11
+ */
12
+ export function listPublishablePackages(root) {
13
+ const packages = [];
14
+ for (const projectDir of listProjectDirs(root)) {
15
+ const pkg = JSON.parse(readFileSync(join(projectDir, "package.json"), "utf8"));
16
+ if (typeof pkg.name !== "string" || pkg.private === true) {
17
+ continue;
18
+ }
19
+ packages.push({
20
+ name: pkg.name,
21
+ version: pkg.version,
22
+ dir: relative(join(root, "projects"), projectDir),
23
+ });
24
+ }
25
+ return packages.sort((a, b) => a.name.localeCompare(b.name));
26
+ }
27
+
28
+ export function dockerRepositories(root) {
29
+ const create = JSON.parse(readFileSync(join(root, "projects/create/package.json"), "utf8"));
30
+ const devcontainer = JSON.parse(
31
+ readFileSync(join(root, "projects/devcontainer/package.json"), "utf8"),
32
+ );
33
+ return {
34
+ create: create.decohere?.dockerImageRepository ?? "ghcr.io/typesmiths/decohere/create",
35
+ devcontainer:
36
+ devcontainer.decohere?.dockerImageRepository ??
37
+ "ghcr.io/typesmiths/decohere/devcontainer",
38
+ createVersion: create.version,
39
+ workspaceVersion: JSON.parse(readFileSync(join(root, "package.json"), "utf8")).version,
40
+ };
41
+ }
42
+
43
+ /**
44
+ * @param {string} spec package or package@version
45
+ * @param {{ spawnSync?: typeof spawnSync }} [io]
46
+ */
47
+ export function npmNameExists(spec, io = {}) {
48
+ const spawn = io.spawnSync ?? spawnSync;
49
+ const result = spawn("npm", ["view", spec, "name"], {
50
+ encoding: "utf8",
51
+ stdio: ["ignore", "pipe", "pipe"],
52
+ });
53
+ return result.status === 0;
54
+ }
55
+
56
+ /**
57
+ * @param {string} spec package@version
58
+ * @param {{ spawnSync?: typeof spawnSync }} [io]
59
+ */
60
+ export function npmVersionExists(spec, io = {}) {
61
+ const spawn = io.spawnSync ?? spawnSync;
62
+ const result = spawn("npm", ["view", spec, "version"], {
63
+ encoding: "utf8",
64
+ stdio: ["ignore", "pipe", "pipe"],
65
+ });
66
+ return result.status === 0;
67
+ }
68
+
69
+ /**
70
+ * Parse `ghcr.io/org/name/image` into GitHub packages API coordinates.
71
+ * @param {string} repository
72
+ */
73
+ export function parseGhcrRepository(repository) {
74
+ const withoutHost = repository.replace(/^ghcr\.io\//, "");
75
+ const slash = withoutHost.indexOf("/");
76
+ if (slash < 0) {
77
+ throw new Error(`Not a GHCR repository: ${repository}`);
78
+ }
79
+ return {
80
+ org: withoutHost.slice(0, slash),
81
+ packageName: withoutHost.slice(slash + 1),
82
+ };
83
+ }
84
+
85
+ /**
86
+ * @param {string} repository ghcr.io/org/pkg
87
+ * @param {string} tag
88
+ * @param {{ token: string, fetch?: typeof fetch }} io
89
+ */
90
+ export async function ghcrTagExists(repository, tag, io) {
91
+ const { org, packageName } = parseGhcrRepository(repository);
92
+ const encoded = encodeURIComponent(packageName);
93
+ const headers = {
94
+ Authorization: `Bearer ${io.token}`,
95
+ Accept: "application/vnd.github+json",
96
+ "X-GitHub-Api-Version": "2022-11-28",
97
+ };
98
+ const fetchImpl = io.fetch ?? fetch;
99
+ for (let page = 1; page <= 20; page += 1) {
100
+ const url = `https://api.github.com/orgs/${org}/packages/container/${encoded}/versions?per_page=100&page=${page}`;
101
+ const response = await fetchImpl(url, { headers });
102
+ if (response.status === 404) {
103
+ return false;
104
+ }
105
+ if (!response.ok) {
106
+ throw new Error(`GHCR versions ${response.status} for ${repository}`);
107
+ }
108
+ const versions = await response.json();
109
+ if (!Array.isArray(versions) || versions.length === 0) {
110
+ return false;
111
+ }
112
+ for (const version of versions) {
113
+ const tags = version.metadata?.container?.tags ?? [];
114
+ if (tags.includes(tag)) {
115
+ return true;
116
+ }
117
+ }
118
+ if (versions.length < 100) {
119
+ return false;
120
+ }
121
+ }
122
+ return false;
123
+ }
124
+
125
+ /**
126
+ * @param {{
127
+ * root: string,
128
+ * spawnSync?: typeof spawnSync,
129
+ * fetch?: typeof fetch,
130
+ * githubToken?: string,
131
+ * }} options
132
+ */
133
+ export async function collectPublishVerify(options) {
134
+ const packages = listPublishablePackages(options.root);
135
+ const unpublished = [];
136
+ let publishedVersions = 0;
137
+ for (const pkg of packages) {
138
+ if (!npmNameExists(pkg.name, options)) {
139
+ unpublished.push(pkg.name);
140
+ continue;
141
+ }
142
+ if (npmVersionExists(`${pkg.name}@${pkg.version}`, options)) {
143
+ publishedVersions += 1;
144
+ }
145
+ }
146
+
147
+ const images = dockerRepositories(options.root);
148
+ const token = options.githubToken ?? "";
149
+ let skipDocker = false;
150
+ if (token) {
151
+ const createExists = await ghcrTagExists(images.create, images.workspaceVersion, {
152
+ token,
153
+ fetch: options.fetch,
154
+ });
155
+ const devExists = await ghcrTagExists(images.devcontainer, images.workspaceVersion, {
156
+ token,
157
+ fetch: options.fetch,
158
+ });
159
+ skipDocker = createExists && devExists;
160
+ }
161
+
162
+ return {
163
+ unpublished,
164
+ skipNpm: packages.length > 0 && publishedVersions === packages.length,
165
+ skipDocker,
166
+ imageTag: images.workspaceVersion,
167
+ createVersion: images.createVersion,
168
+ repository: images.create,
169
+ devcontainerRepository: images.devcontainer,
170
+ packageCount: packages.length,
171
+ publishedVersions,
172
+ };
173
+ }
174
+
175
+ export function formatGithubOutput(result) {
176
+ return [
177
+ `unpublished=${result.unpublished.join(" ")}`,
178
+ `skip_npm=${result.skipNpm ? "true" : "false"}`,
179
+ `skip_docker=${result.skipDocker ? "true" : "false"}`,
180
+ `image_tag=${result.imageTag}`,
181
+ `create_version=${result.createVersion}`,
182
+ `repository=${result.repository}`,
183
+ `devcontainer_repository=${result.devcontainerRepository}`,
184
+ ].join("\n");
185
+ }
186
+
187
+ const isCli =
188
+ process.argv[1] != null && import.meta.url === pathToFileURL(resolve(process.argv[1])).href;
189
+
190
+ if (isCli) {
191
+ const result = await collectPublishVerify({
192
+ root: process.cwd(),
193
+ githubToken: process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN,
194
+ });
195
+ const body = formatGithubOutput(result);
196
+ process.stdout.write(`${body}\n`);
197
+ if (process.env.GITHUB_OUTPUT) {
198
+ const { appendFileSync } = await import("node:fs");
199
+ appendFileSync(process.env.GITHUB_OUTPUT, `${body}\n`);
200
+ }
201
+ if (result.unpublished.length) {
202
+ process.stderr.write(`Not on npm yet: ${result.unpublished.join(", ")}\n`);
203
+ }
204
+ }
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env node
2
+ import { spawnSync } from "node:child_process";
3
+ import { mkdtempSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join, resolve } from "node:path";
6
+ import { pathToFileURL } from "node:url";
7
+
8
+ /**
9
+ * Run `npx @decohere/create@version --help` from an empty temp directory.
10
+ * Must not use the repo cwd — after `@decohere/cli` is pinned, pnpm installs
11
+ * `@decohere/create` into the workspace tree and npm's npx misses the bin.
12
+ * No token: the packages are public, so this installs the launcher the way a user does.
13
+ * Anonymous reads go through npm's cache, which can lag minutes behind a publish, so it
14
+ * first waits until npm serves the version (`attempts` tries, `intervalMs` apart).
15
+ *
16
+ * @param {{
17
+ * createVersion: string,
18
+ * attempts?: number,
19
+ * intervalMs?: number,
20
+ * sleep?: (ms: number) => void,
21
+ * mkdtempSync?: typeof mkdtempSync,
22
+ * writeFileSync?: typeof writeFileSync,
23
+ * spawnSync?: typeof spawnSync,
24
+ * tmpdir?: () => string,
25
+ * stdio?: import("node:child_process").StdioOptions,
26
+ * }} options
27
+ */
28
+ export function runCreateLauncherSmoke(options) {
29
+ const version = options.createVersion?.trim() ?? "";
30
+ if (!version) {
31
+ throw new Error("CREATE_VERSION is required");
32
+ }
33
+
34
+ const mkdtemp = options.mkdtempSync ?? mkdtempSync;
35
+ const writeFile = options.writeFileSync ?? writeFileSync;
36
+ const spawn = options.spawnSync ?? spawnSync;
37
+ const tmp = options.tmpdir ?? tmpdir;
38
+ const sleep = options.sleep ?? sleepSync;
39
+ const attempts = options.attempts ?? 20;
40
+ const intervalMs = options.intervalMs ?? 15_000;
41
+
42
+ const workDir = mkdtemp(join(tmp(), "decohere-create-smoke-"));
43
+ const npmrc = join(workDir, ".npmrc");
44
+ writeFile(npmrc, "@decohere:registry=https://registry.npmjs.org/\n");
45
+
46
+ const env = { ...process.env, NPM_CONFIG_USERCONFIG: npmrc };
47
+ const spec = `@decohere/create@${version}`;
48
+
49
+ for (let attempt = 1; ; attempt++) {
50
+ const view = spawn("npm", ["view", spec, "version"], { cwd: workDir, env, stdio: "pipe" });
51
+ if (view.status === 0) break;
52
+ if (attempt >= attempts) {
53
+ console.error(`${spec} is still not served by npm after ${attempts} tries`);
54
+ return { workDir, status: 1 };
55
+ }
56
+ console.log(`${spec} not served by npm yet (try ${attempt} of ${attempts}); waiting`);
57
+ sleep(intervalMs);
58
+ }
59
+
60
+ const result = spawn("npx", ["--yes", spec, "--help"], {
61
+ cwd: workDir,
62
+ env,
63
+ stdio: options.stdio ?? "inherit",
64
+ });
65
+
66
+ return {
67
+ workDir,
68
+ status: result.status ?? 1,
69
+ };
70
+ }
71
+
72
+ /** @param {number} ms */
73
+ function sleepSync(ms) {
74
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
75
+ }
76
+
77
+ const isCli =
78
+ process.argv[1] != null && import.meta.url === pathToFileURL(resolve(process.argv[1])).href;
79
+
80
+ if (isCli) {
81
+ const { status } = runCreateLauncherSmoke({
82
+ createVersion: process.env.CREATE_VERSION ?? "",
83
+ });
84
+ process.exit(status);
85
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * One GitHub issue per subject, found again by its exact title and updated in place: CI files
3
+ * a reminder once and keeps it current instead of opening a new issue every run. Needs `gh` and
4
+ * GH_TOKEN (or GITHUB_TOKEN) with issues: write.
5
+ */
6
+
7
+ import { spawnSync } from "node:child_process";
8
+
9
+ /**
10
+ * @param {string[]} args
11
+ * @param {string} [input]
12
+ */
13
+ export function gh(args, input) {
14
+ const result = spawnSync("gh", args, { encoding: "utf8", input, env: process.env });
15
+ if (result.status !== 0) {
16
+ throw new Error(result.stderr || result.stdout || `gh ${args.join(" ")} failed`);
17
+ }
18
+ return result.stdout;
19
+ }
20
+
21
+ /**
22
+ * The issue titled exactly `title`, preferring an open one over a closed one.
23
+ *
24
+ * @param {{ number: number, title: string, state: string, url: string }[]} issues
25
+ * @param {string} title
26
+ */
27
+ export function pickIssue(issues, title) {
28
+ const matches = issues.filter((issue) => issue.title === title);
29
+ return matches.find((issue) => issue.state === "OPEN") ?? matches[0] ?? null;
30
+ }
31
+
32
+ /**
33
+ * @param {{ title: string, body: string, assignees?: string[], repo?: string }} options
34
+ * @param {typeof gh} [run]
35
+ * @returns {{ number: number, url: string, created: boolean }}
36
+ */
37
+ export function upsertIssue(options, run = gh) {
38
+ const { title, body, assignees = [] } = options;
39
+ const repo = options.repo ? ["--repo", options.repo] : [];
40
+ const issues = JSON.parse(
41
+ run([
42
+ "issue",
43
+ "list",
44
+ ...repo,
45
+ "--state",
46
+ "all",
47
+ "--limit",
48
+ "200",
49
+ "--search",
50
+ // Quoted: GitHub's search can't match a title like `security(ci): …` unquoted.
51
+ // The exact-title match in pickIssue still decides.
52
+ `"${title.replaceAll('"', "")}" in:title`,
53
+ "--json",
54
+ "number,title,state,url",
55
+ ]),
56
+ );
57
+ const assign = assignees.flatMap((login) => ["--add-assignee", login]);
58
+ const existing = pickIssue(issues, title);
59
+ if (existing != null) {
60
+ if (existing.state !== "OPEN") {
61
+ run(["issue", "reopen", String(existing.number), ...repo]);
62
+ }
63
+ run(
64
+ ["issue", "edit", String(existing.number), ...repo, "--body-file", "-", ...assign],
65
+ body,
66
+ );
67
+ return { number: existing.number, url: existing.url, created: false };
68
+ }
69
+ const url = run(
70
+ ["issue", "create", ...repo, "--title", title, "--body-file", "-", ...assign],
71
+ body,
72
+ ).trim();
73
+ const number = Number(url.split("/").pop());
74
+ return { number, url, created: true };
75
+ }
package/src/lib/lcov.mjs CHANGED
@@ -13,7 +13,7 @@ function normalizePath(filePath) {
13
13
 
14
14
  /**
15
15
  * Extracts the top-level project name from a source path like
16
- * `/workspace/projects/hearth/src/foo.ts` → `"hearth"`.
16
+ * `/workspace/projects/core/src/foo.ts` → `"core"`.
17
17
  * @param {string} sourcePath
18
18
  * @returns {string | undefined}
19
19
  */