@decohere/ci-tools 0.0.1-dev.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.
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Shared LCOV file parsing utilities.
3
+ */
4
+
5
+ /**
6
+ * Normalizes a file path to use forward slashes.
7
+ * @param {string} filePath
8
+ * @returns {string}
9
+ */
10
+ function normalizePath(filePath) {
11
+ return filePath.replaceAll("\\", "/");
12
+ }
13
+
14
+ /**
15
+ * Extracts the top-level project name from a source path like
16
+ * `/workspace/projects/hearth/src/foo.ts` → `"hearth"`.
17
+ * @param {string} sourcePath
18
+ * @returns {string | undefined}
19
+ */
20
+ export function getProjectFromPath(sourcePath) {
21
+ const match = sourcePath.match(/(?:^|\/)projects\/([^/]+)\//);
22
+ return match?.[1];
23
+ }
24
+
25
+ /**
26
+ * Parses an LCOV file and returns per-project line coverage totals.
27
+ * Uses the LF (lines found) and LH (lines hit) summary fields.
28
+ *
29
+ * @param {string} content Raw LCOV file content.
30
+ * @returns {Map<string, { totalLines: number; coveredLines: number }>}
31
+ */
32
+ export function parseLcovByProject(content) {
33
+ const records = content
34
+ .split("end_of_record")
35
+ .map((record) => record.trim())
36
+ .filter(Boolean);
37
+
38
+ const byProject = new Map();
39
+
40
+ for (const record of records) {
41
+ const lines = record.split("\n");
42
+ let sourcePath = "";
43
+ let totalLines = 0;
44
+ let coveredLines = 0;
45
+
46
+ for (const line of lines) {
47
+ if (line.startsWith("SF:")) {
48
+ sourcePath = normalizePath(line.slice(3));
49
+ } else if (line.startsWith("LF:")) {
50
+ totalLines = Number(line.slice(3));
51
+ } else if (line.startsWith("LH:")) {
52
+ coveredLines = Number(line.slice(3));
53
+ }
54
+ }
55
+
56
+ const project = getProjectFromPath(sourcePath);
57
+ if (project == null || totalLines === 0) continue;
58
+
59
+ const current = byProject.get(project) ?? { totalLines: 0, coveredLines: 0 };
60
+ current.totalLines += totalLines;
61
+ current.coveredLines += coveredLines;
62
+ byProject.set(project, current);
63
+ }
64
+
65
+ return byProject;
66
+ }
67
+
68
+ /**
69
+ * Parses an LCOV file into per-file records including uncovered line numbers.
70
+ * Suitable for detailed reporting.
71
+ *
72
+ * @param {string} content Raw LCOV file content.
73
+ * @returns {Array<{
74
+ * sourcePath: string;
75
+ * project: string | undefined;
76
+ * totalLines: number;
77
+ * coveredLines: number;
78
+ * uncoveredLines: number;
79
+ * uncoveredLineNumbers: number[];
80
+ * }>}
81
+ */
82
+ export function parseLcovFiles(content) {
83
+ const records = content
84
+ .split("end_of_record")
85
+ .map((record) => record.trim())
86
+ .filter(Boolean);
87
+
88
+ const files = [];
89
+
90
+ for (const record of records) {
91
+ const lines = record.split("\n");
92
+ let sourcePath = "";
93
+ let totalLines = 0;
94
+ let coveredLines = 0;
95
+ const uncoveredLineNumbers = [];
96
+
97
+ for (const line of lines) {
98
+ if (line.startsWith("SF:")) {
99
+ sourcePath = normalizePath(line.slice(3));
100
+ } else if (line.startsWith("LF:")) {
101
+ totalLines = Number(line.slice(3));
102
+ } else if (line.startsWith("LH:")) {
103
+ coveredLines = Number(line.slice(3));
104
+ } else if (line.startsWith("DA:")) {
105
+ const [lineNumberText, hitCountText] = line.slice(3).split(",");
106
+ const lineNumber = Number(lineNumberText);
107
+ const hitCount = Number(hitCountText);
108
+ if (!Number.isNaN(lineNumber) && !Number.isNaN(hitCount) && hitCount === 0) {
109
+ uncoveredLineNumbers.push(lineNumber);
110
+ }
111
+ }
112
+ }
113
+
114
+ if (sourcePath.length === 0 || totalLines === 0) continue;
115
+
116
+ files.push({
117
+ sourcePath,
118
+ project: getProjectFromPath(sourcePath),
119
+ totalLines,
120
+ coveredLines,
121
+ uncoveredLines: totalLines - coveredLines,
122
+ uncoveredLineNumbers: uncoveredLineNumbers.sort((a, b) => a - b),
123
+ });
124
+ }
125
+
126
+ return files;
127
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Shared SVG badge generation utilities.
3
+ */
4
+
5
+ /**
6
+ * Returns a badge color for a given line-coverage percentage.
7
+ * @param {number} percentage
8
+ * @returns {string}
9
+ */
10
+ export function getColor(percentage) {
11
+ if (percentage >= 90) return "#2ea043";
12
+ if (percentage >= 80) return "#3fb950";
13
+ if (percentage >= 70) return "#9a6700";
14
+ if (percentage >= 60) return "#db6d28";
15
+ return "#cf222e";
16
+ }
17
+
18
+ /**
19
+ * Creates a two-section labeled badge SVG string.
20
+ * @param {string} label
21
+ * @param {string} value
22
+ * @param {string} color Hex color for the value section.
23
+ * @returns {string}
24
+ */
25
+ export function createBadgeSvg(label, value, color) {
26
+ const labelWidth = Math.max(50, 10 + label.length * 7);
27
+ const valueWidth = Math.max(44, 10 + value.length * 7);
28
+ const totalWidth = labelWidth + valueWidth;
29
+
30
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="20" role="img" aria-label="${label}: ${value}">
31
+ <title>${label}: ${value}</title>
32
+ <rect width="${labelWidth}" height="20" fill="#24292f"/>
33
+ <rect x="${labelWidth}" width="${valueWidth}" height="20" fill="${color}"/>
34
+ <text x="${labelWidth / 2}" y="14" fill="#ffffff" font-family="Verdana,DejaVu Sans,sans-serif" font-size="11" text-anchor="middle">${label}</text>
35
+ <text x="${labelWidth + valueWidth / 2}" y="14" fill="#ffffff" font-family="Verdana,DejaVu Sans,sans-serif" font-size="11" text-anchor="middle">${value}</text>
36
+ </svg>\n`;
37
+ }
38
+
39
+ /**
40
+ * Maps a GitHub Actions workflow run status/conclusion to a badge color.
41
+ * @param {string} status
42
+ * @param {string | null} conclusion
43
+ * @returns {string}
44
+ */
45
+ export function statusToColor(status, conclusion) {
46
+ if (status === "in_progress" || status === "queued") return "#fb8500";
47
+ if (conclusion === "success") return "#2ea043";
48
+ if (conclusion === "failure") return "#cf222e";
49
+ if (conclusion === "cancelled") return "#6e7781";
50
+ return "#6e7781";
51
+ }
52
+
53
+ /**
54
+ * Maps a GitHub Actions workflow run status/conclusion to a human-readable label.
55
+ * @param {string} status
56
+ * @param {string | null} conclusion
57
+ * @returns {string}
58
+ */
59
+ export function statusToText(status, conclusion) {
60
+ if (status === "in_progress") return "running";
61
+ if (status === "queued") return "queued";
62
+ if (conclusion === "success") return "passing";
63
+ if (conclusion === "failure") return "failing";
64
+ if (conclusion === "cancelled") return "cancelled";
65
+ return "unknown";
66
+ }
@@ -0,0 +1,208 @@
1
+ import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ const branch = process.env.GITHUB_REF_NAME ?? "local";
5
+ const sha = process.env.GITHUB_SHA ?? "local";
6
+ const outputDir = process.env.PAGES_OUTPUT_DIR ?? "site";
7
+
8
+ const badgesDir = "projects/ci-tools/dist/ci/badges";
9
+ const benchHistoryPath = "projects/ci-tools/dist/ci/bench/bench-history.json";
10
+
11
+ const numericFields = ["hz", "mean", "p99", "rme", "sampleCount"];
12
+
13
+ function fmtMs(ms) {
14
+ if (typeof ms !== "number" || Number.isNaN(ms)) return "-";
15
+ if (ms >= 100) return `${Math.round(ms)} ms`;
16
+ return `${ms.toFixed(1)} ms`;
17
+ }
18
+
19
+ function fmtHz(hz) {
20
+ if (typeof hz !== "number" || Number.isNaN(hz)) return "-";
21
+ return `${hz.toFixed(2)}/s`;
22
+ }
23
+
24
+ function averageBenchmarks(runs) {
25
+ const first = runs[0]?.vitestOutput;
26
+ if (first == null) return [];
27
+
28
+ const latestBenchmarks = first.files
29
+ .flatMap((file) => file.groups)
30
+ .flatMap((group) => group.benchmarks);
31
+
32
+ return latestBenchmarks.map((benchmark) => {
33
+ const peers = runs
34
+ .flatMap((run) => run.vitestOutput?.files ?? [])
35
+ .flatMap((file) => file.groups ?? [])
36
+ .flatMap((group) => group.benchmarks ?? [])
37
+ .filter((candidate) => candidate.name === benchmark.name);
38
+
39
+ const averaged = { name: benchmark.name };
40
+ for (const field of numericFields) {
41
+ const values = peers
42
+ .map((peer) => peer[field])
43
+ .filter((value) => typeof value === "number");
44
+ if (values.length > 0) {
45
+ averaged[field] = values.reduce((sum, value) => sum + value, 0) / values.length;
46
+ }
47
+ }
48
+
49
+ return averaged;
50
+ });
51
+ }
52
+
53
+ export function buildBenchmarkHtml(benchHistory) {
54
+ const contexts = Object.entries(benchHistory.runs ?? {})
55
+ .filter(([, entry]) => Array.isArray(entry.runs) && entry.runs.length > 0)
56
+ .sort((a, b) => new Date(b[1].runs[0].date) - new Date(a[1].runs[0].date));
57
+
58
+ if (contexts.length === 0) {
59
+ return `<!doctype html>
60
+ <html lang="en">
61
+ <head>
62
+ <meta charset="utf-8" />
63
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
64
+ <title>Ark - Benchmarks</title>
65
+ <link rel="stylesheet" href="./styles.css" />
66
+ </head>
67
+ <body>
68
+ <main class="container">
69
+ <header class="hero">
70
+ <h1>Benchmarks</h1>
71
+ <p class="note">No benchmark history is available yet.</p>
72
+ </header>
73
+ </main>
74
+ </body>
75
+ </html>
76
+ `;
77
+ }
78
+
79
+ const rows = [];
80
+ for (const [, entry] of contexts) {
81
+ const benchmarks = averageBenchmarks(entry.runs);
82
+ const ctx = entry.context;
83
+ const environment = `${ctx.benchEnv ?? "unknown"}-${ctx.os.arch}`;
84
+ for (const benchmark of benchmarks) {
85
+ rows.push(`<tr>
86
+ <td>${environment}</td>
87
+ <td><code>${benchmark.name}</code></td>
88
+ <td>${fmtMs(benchmark.mean)}</td>
89
+ <td>${typeof benchmark.rme === "number" ? `±${benchmark.rme.toFixed(1)}%` : "-"}</td>
90
+ <td>${fmtMs(benchmark.p99)}</td>
91
+ <td>${fmtHz(benchmark.hz)}</td>
92
+ <td>${typeof benchmark.sampleCount === "number" ? Math.round(benchmark.sampleCount) : "-"}</td>
93
+ </tr>`);
94
+ }
95
+ }
96
+
97
+ const details = contexts
98
+ .map(([, entry]) => {
99
+ const ctx = entry.context;
100
+ return `<li><strong>${ctx.benchEnv ?? "unknown"}-${ctx.os.arch}</strong> - ${ctx.os.platform} - ${ctx.cpu.model} x ${ctx.cpu.count} - ${ctx.memoryGb} GB - Node ${ctx.node} - rolling mean of ${entry.runs.length} run(s)</li>`;
101
+ })
102
+ .join("\n");
103
+
104
+ const updatedDate = contexts[0][1].runs[0].date.slice(0, 10);
105
+
106
+ return `<!doctype html>
107
+ <html lang="en">
108
+ <head>
109
+ <meta charset="utf-8" />
110
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
111
+ <title>Ark - Benchmarks</title>
112
+ <link rel="stylesheet" href="./styles.css" />
113
+ </head>
114
+ <body>
115
+ <main class="container">
116
+ <div class="hero">
117
+ <h1>Benchmarks</h1>
118
+ <p class="note">Updated ${updatedDate} · Branch: ${branch} · Commit: ${sha.slice(0, 12)}</p>
119
+ </div>
120
+ <section class="section">
121
+ <h2>Performance Table</h2>
122
+ <table>
123
+ <thead>
124
+ <tr>
125
+ <th>Environment</th>
126
+ <th>Benchmark</th>
127
+ <th>Mean</th>
128
+ <th>±RME</th>
129
+ <th>P99</th>
130
+ <th>Ops/s</th>
131
+ <th>Runs</th>
132
+ </tr>
133
+ </thead>
134
+ <tbody>
135
+ ${rows.join("\n")}
136
+ </tbody>
137
+ </table>
138
+ </section>
139
+ <section class="section">
140
+ <h2>Environment Notes</h2>
141
+ <ul>
142
+ ${details}
143
+ </ul>
144
+ </section>
145
+ </main>
146
+ </body>
147
+ </html>
148
+ `;
149
+ }
150
+
151
+ function buildStylesCss() {
152
+ return `:root {
153
+ --bg: #f4f7f3;
154
+ --surface: #ffffff;
155
+ --text: #132b24;
156
+ --muted: #4f6f65;
157
+ --accent: #0f9d72;
158
+ --accent-strong: #0d7f5c;
159
+ --border: #d6e3dd;
160
+ --shadow: 0 14px 40px rgba(12, 58, 45, 0.08);
161
+ }
162
+ * { box-sizing: border-box; }
163
+ body { margin: 0; font-family: "Segoe UI", "Helvetica Neue", sans-serif; color: var(--text); background: radial-gradient(circle at top right, #dbefe7, var(--bg) 40%); }
164
+ a { color: var(--accent-strong); }
165
+ .container { max-width: 1100px; margin: 0 auto; padding: 28px 18px 60px; }
166
+ .hero { background: linear-gradient(145deg, #183a31, #0e7f5c); color: #f3fff8; padding: 26px; border-radius: 18px; box-shadow: var(--shadow); }
167
+ .hero h1 { margin: 6px 0 10px; }
168
+ .note { color: #d8f5eb; margin: 0; }
169
+ .section { margin-top: 26px; }
170
+ table { width: 100%; border-collapse: collapse; background: var(--surface); border-radius: 12px; overflow: hidden; }
171
+ th, td { border-bottom: 1px solid var(--border); padding: 9px 10px; text-align: left; }
172
+ th { color: var(--muted); font-weight: 600; background: #f3faf7; }
173
+ td code { color: var(--accent-strong); }
174
+ @media (max-width: 700px) { .hero { padding: 20px; } .container { padding: 18px 12px 40px; } }
175
+ `;
176
+ }
177
+
178
+ await rm(outputDir, { recursive: true, force: true });
179
+ await mkdir(outputDir, { recursive: true });
180
+
181
+ await writeFile(path.join(outputDir, "styles.css"), buildStylesCss());
182
+
183
+ // index.html redirects to benchmark.html — no separate dashboard needed
184
+ await writeFile(
185
+ path.join(outputDir, "index.html"),
186
+ `<!doctype html><html><head><meta charset="utf-8"/><meta http-equiv="refresh" content="0;url=./benchmark.html"/><title>Ark Pages</title></head><body><p><a href="./benchmark.html">Benchmarks</a></p></body></html>\n`,
187
+ );
188
+
189
+ try {
190
+ const benchHistory = JSON.parse(await readFile(benchHistoryPath, "utf8"));
191
+ await writeFile(path.join(outputDir, "benchmark.html"), buildBenchmarkHtml(benchHistory));
192
+ const benchmarkDataDir = path.join(outputDir, "data");
193
+ await mkdir(benchmarkDataDir, { recursive: true });
194
+ await writeFile(
195
+ path.join(benchmarkDataDir, "bench-history.json"),
196
+ `${JSON.stringify(benchHistory, null, 2)}\n`,
197
+ );
198
+ } catch {
199
+ await writeFile(path.join(outputDir, "benchmark.html"), buildBenchmarkHtml({ runs: {} }));
200
+ }
201
+
202
+ try {
203
+ await cp(badgesDir, path.join(outputDir, "badges"), { recursive: true });
204
+ } catch {
205
+ // Badges may not exist yet for some runs.
206
+ }
207
+
208
+ await writeFile(path.join(outputDir, ".nojekyll"), "");
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env node
2
+
3
+ // Clear benchmark history for this publish (workflow_dispatch with clear-history=true)
4
+
5
+ import { writeFileSync } from "node:fs";
6
+ import { mkdir } from "node:fs/promises";
7
+
8
+ const HISTORY_DIR = "projects/ci-tools/dist/ci/bench";
9
+ const HISTORY_FILE = `${HISTORY_DIR}/bench-history.json`;
10
+
11
+ try {
12
+ await mkdir(HISTORY_DIR, { recursive: true });
13
+ writeFileSync(
14
+ HISTORY_FILE,
15
+ JSON.stringify(
16
+ {
17
+ runs: {},
18
+ },
19
+ null,
20
+ 2,
21
+ ),
22
+ );
23
+ console.log("✓ Cleared benchmark history");
24
+ } catch (error) {
25
+ console.error("Error clearing benchmark history:", error.message);
26
+ process.exit(1);
27
+ }
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env node
2
+
3
+ // Keep prior bench badge if no code changes; create fallback if none exists
4
+
5
+ import { execSync } from "node:child_process";
6
+ import { existsSync, writeFileSync } from "node:fs";
7
+ import { mkdir } from "node:fs/promises";
8
+
9
+ const BADGES_DIR = "projects/ci-tools/dist/ci/badges";
10
+ const BADGE_FILE = `${BADGES_DIR}/bench.svg`;
11
+ const PAGES_URL = "https://typesmiths.github.io/decohere/badges/bench.svg";
12
+
13
+ const FALLBACK_BADGE = `<svg xmlns="http://www.w3.org/2000/svg" width="120" height="20" role="img" aria-label="bench: unavailable">
14
+ <title>bench: unavailable</title>
15
+ <rect width="120" height="20" fill="#555"/>
16
+ <rect x="46" width="74" height="20" fill="#9f9f9f"/>
17
+ <text x="24" y="14" fill="#fff" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11" text-anchor="middle">bench</text>
18
+ <text x="83" y="14" fill="#fff" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11" text-anchor="middle">unavailable</text>
19
+ </svg>`;
20
+
21
+ try {
22
+ if (!existsSync(BADGE_FILE)) {
23
+ await mkdir(BADGES_DIR, { recursive: true });
24
+
25
+ try {
26
+ execSync(`curl -fsSL "${PAGES_URL}" -o "${BADGE_FILE}"`, {
27
+ stdio: "pipe",
28
+ });
29
+ console.log("✓ Restored prior bench badge from Pages");
30
+ } catch {
31
+ console.warn("::warning::No prior bench badge found on Pages; creating fallback badge.");
32
+ writeFileSync(BADGE_FILE, FALLBACK_BADGE);
33
+ }
34
+ }
35
+ } catch (error) {
36
+ console.error("Error keeping prior bench badge:", error.message);
37
+ process.exit(1);
38
+ }
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env node
2
+
3
+ // Restore benchmark history from Pages, or initialize empty if not found
4
+
5
+ import { execSync } from "node:child_process";
6
+ import { writeFileSync } from "node:fs";
7
+ import { mkdir } from "node:fs/promises";
8
+
9
+ const HISTORY_DIR = "projects/ci-tools/dist/ci/bench";
10
+ const HISTORY_FILE = `${HISTORY_DIR}/bench-history.json`;
11
+ const PAGES_URL = "https://typesmiths.github.io/decohere/data/bench-history.json";
12
+
13
+ try {
14
+ await mkdir(HISTORY_DIR, { recursive: true });
15
+
16
+ try {
17
+ execSync(`curl -fsSL "${PAGES_URL}" -o "${HISTORY_FILE}"`, {
18
+ stdio: "pipe",
19
+ });
20
+ console.log("✓ Restored benchmark history from Pages");
21
+ } catch {
22
+ console.log("No benchmark history on Pages; starting from empty history");
23
+ writeFileSync(
24
+ HISTORY_FILE,
25
+ JSON.stringify(
26
+ {
27
+ runs: {},
28
+ },
29
+ null,
30
+ 2,
31
+ ),
32
+ );
33
+ }
34
+ } catch (error) {
35
+ console.error("Error restoring benchmark history:", error.message);
36
+ process.exit(1);
37
+ }
@@ -0,0 +1,37 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ const branch = process.env.GITHUB_REF_NAME ?? "local";
5
+ const sha = process.env.GITHUB_SHA ?? "local";
6
+ const generatedAt = new Date().toISOString();
7
+ const outputDir = process.env.PAGES_OUTPUT_DIR ?? "site";
8
+
9
+ const smokeDir = path.join(outputDir, "smoke", branch);
10
+
11
+ await mkdir(smokeDir, { recursive: true });
12
+
13
+ await writeFile(
14
+ path.join(smokeDir, "index.html"),
15
+ `<!doctype html>
16
+ <html lang="en">
17
+ <head>
18
+ <meta charset="utf-8" />
19
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
20
+ <title>Ark Pages Smoke</title>
21
+ </head>
22
+ <body>
23
+ <h1>Pages Smoke Test</h1>
24
+ <p>Branch: ${branch}</p>
25
+ <p>Commit: ${sha}</p>
26
+ <p>Generated: ${generatedAt}</p>
27
+ </body>
28
+ </html>
29
+ `,
30
+ );
31
+
32
+ await writeFile(
33
+ path.join(smokeDir, "meta.json"),
34
+ `${JSON.stringify({ branch, sha, generatedAt, path: `smoke/${branch}/` }, null, 2)}\n`,
35
+ );
36
+
37
+ await writeFile(path.join(outputDir, ".nojekyll"), "");
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Capture final version metadata from package.json
4
+ * Outputs version and is_prerelease flags for GitHub Actions
5
+ */
6
+
7
+ import { readFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+
10
+ try {
11
+ const packageJson = JSON.parse(readFileSync(join(process.cwd(), "package.json"), "utf8"));
12
+ const version = packageJson.version;
13
+
14
+ if (!version) {
15
+ console.error("No version found in package.json");
16
+ process.exit(1);
17
+ }
18
+
19
+ console.log(`version=${version}`);
20
+
21
+ // Check if prerelease (has hyphen in version)
22
+ const isPrerelease = version.includes("-");
23
+ console.log(`is_prerelease=${isPrerelease}`);
24
+ } catch (e) {
25
+ console.error(`Failed to capture version: ${e.message}`);
26
+ process.exit(1);
27
+ }
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Generate npm package links for all @decohere packages
4
+ * Outputs markdown formatted links for GitHub release notes
5
+ */
6
+
7
+ import { readdirSync, readFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+
10
+ const VERSION = process.argv[2] || process.env.VERSION;
11
+
12
+ if (!VERSION) {
13
+ console.error("VERSION not provided as argument or env var");
14
+ process.exit(1);
15
+ }
16
+
17
+ let links = "## Published Packages\n\n";
18
+ const projectsDir = join(process.cwd(), "projects");
19
+
20
+ try {
21
+ const projects = readdirSync(projectsDir);
22
+
23
+ for (const project of projects) {
24
+ const pkgPath = join(projectsDir, project, "package.json");
25
+ try {
26
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
27
+ if (pkg.name?.startsWith("@decohere/")) {
28
+ links += `- [\`${pkg.name}@${VERSION}\`](https://www.npmjs.com/package/${pkg.name}/v/${VERSION})\n`;
29
+ }
30
+ } catch {
31
+ // Skip projects without package.json
32
+ }
33
+ }
34
+
35
+ console.log(links);
36
+ } catch (e) {
37
+ console.error(`Failed to generate npm links: ${e.message}`);
38
+ process.exit(1);
39
+ }