mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 10:26:26 +01:00
fix: fail closed on unusable Codex auth during sandbox auth merge
When reusing a Codex sandbox, the workspace-restore step now performs an auth-merge check before adopting the sandbox's auth.json. The merge fails closed when: - host or sandbox Codex auth is unusable (missing token or account) - the subscription payload shapes differ between host and sandbox - subscription account identities do not match Additional hardening: node absence on PATH during the embedded decision script now exits non-zero instead of silently falling through. Co-change comments link parseAuth (the embedded script) to hasUsableAuthPayload (codex-home.ts) so a future auth-format change cannot silently diverge between the two implementations. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
98df214917
3422 files changed
+1614966
No files matched your search
@@ -0,0 +1,131 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
resolveConcurrency,
|
||||
runWithConcurrency,
|
||||
} from "../build-standalone-public-packages.mjs";
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
let reject;
|
||||
const promise = new Promise((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
test("runs every item and preserves result order", async () => {
|
||||
const items = [1, 2, 3, 4, 5];
|
||||
const results = await runWithConcurrency(items, 2, async (value) => value * 10);
|
||||
assert.deepEqual(results, [10, 20, 30, 40, 50]);
|
||||
});
|
||||
|
||||
test("never exceeds the concurrency limit", async () => {
|
||||
const items = Array.from({ length: 8 }, (_, index) => index);
|
||||
const limit = 3;
|
||||
let active = 0;
|
||||
let peak = 0;
|
||||
const gates = items.map(() => deferred());
|
||||
|
||||
const run = runWithConcurrency(items, limit, async (value) => {
|
||||
active += 1;
|
||||
peak = Math.max(peak, active);
|
||||
assert.ok(active <= limit, `active ${active} exceeded limit ${limit}`);
|
||||
await gates[value].promise;
|
||||
active -= 1;
|
||||
return value;
|
||||
});
|
||||
|
||||
// Release gates progressively; the pool must keep at most `limit` in flight.
|
||||
for (const gate of gates) {
|
||||
await Promise.resolve();
|
||||
gate.resolve();
|
||||
}
|
||||
|
||||
await run;
|
||||
assert.equal(peak, limit);
|
||||
});
|
||||
|
||||
test("aggregates failures and still runs remaining items", async () => {
|
||||
const items = [0, 1, 2, 3];
|
||||
const processed = [];
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
runWithConcurrency(items, 2, async (value) => {
|
||||
processed.push(value);
|
||||
if (value === 1) {
|
||||
throw new Error(`boom-${value}`);
|
||||
}
|
||||
return value;
|
||||
}),
|
||||
(error) => {
|
||||
assert.match(error.message, /1 standalone package build\(s\) failed/);
|
||||
assert.equal(error.failures.length, 1);
|
||||
assert.equal(error.failures[0].index, 1);
|
||||
assert.match(error.failures[0].error.message, /boom-1/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
// A single failure must not abort the rest of the queue.
|
||||
assert.deepEqual(processed.sort((a, b) => a - b), [0, 1, 2, 3]);
|
||||
});
|
||||
|
||||
test("reports failures sorted by original index", async () => {
|
||||
const items = [0, 1, 2, 3, 4];
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
runWithConcurrency(items, 5, async (value) => {
|
||||
if (value === 3 || value === 1) {
|
||||
throw new Error(`fail-${value}`);
|
||||
}
|
||||
return value;
|
||||
}),
|
||||
(error) => {
|
||||
assert.deepEqual(
|
||||
error.failures.map(({ index }) => index),
|
||||
[1, 3],
|
||||
);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveConcurrency honors a valid env override, capped by package count", () => {
|
||||
const previous = process.env.STANDALONE_BUILD_CONCURRENCY;
|
||||
try {
|
||||
process.env.STANDALONE_BUILD_CONCURRENCY = "2";
|
||||
assert.equal(resolveConcurrency(7), 2);
|
||||
assert.equal(resolveConcurrency(1), 1);
|
||||
assert.equal(resolveConcurrency(0), 1);
|
||||
|
||||
process.env.STANDALONE_BUILD_CONCURRENCY = "0";
|
||||
assert.ok(resolveConcurrency(7) >= 1);
|
||||
|
||||
process.env.STANDALONE_BUILD_CONCURRENCY = "not-a-number";
|
||||
assert.ok(resolveConcurrency(7) >= 1);
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env.STANDALONE_BUILD_CONCURRENCY;
|
||||
} else {
|
||||
process.env.STANDALONE_BUILD_CONCURRENCY = previous;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("resolveConcurrency never returns more than the package count", () => {
|
||||
const previous = process.env.STANDALONE_BUILD_CONCURRENCY;
|
||||
try {
|
||||
delete process.env.STANDALONE_BUILD_CONCURRENCY;
|
||||
assert.equal(resolveConcurrency(0), 1);
|
||||
assert.ok(resolveConcurrency(3) <= 3);
|
||||
} finally {
|
||||
if (previous !== undefined) {
|
||||
process.env.STANDALONE_BUILD_CONCURRENCY = previous;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import test from "node:test";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||
const script = path.join(repoRoot, "scripts", "run-vitest-stable.mjs");
|
||||
|
||||
function dryRun(args) {
|
||||
const result = spawnSync(process.execPath, [script, ...args, "--dry-run"], {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function dryRunJson(args) {
|
||||
const result = dryRun(args);
|
||||
assert.equal(result.status, 0, `expected success for ${args.join(" ")}: ${result.stderr}`);
|
||||
return JSON.parse(result.stdout);
|
||||
}
|
||||
|
||||
const SHARD_COUNT = 3;
|
||||
|
||||
test("the general-server shards form a complete, non-overlapping partition", () => {
|
||||
const shards = Array.from({ length: SHARD_COUNT }, (_, index) =>
|
||||
dryRunJson(["--mode", "general", "--group", "general-server", "--shard-index", String(index), "--shard-count", String(SHARD_COUNT)]),
|
||||
);
|
||||
|
||||
const total = shards[0].generalServerSuiteCount;
|
||||
assert.ok(total > 0, "expected a non-empty general-server suite set");
|
||||
|
||||
const seen = new Set();
|
||||
let selectedTotal = 0;
|
||||
for (const shard of shards) {
|
||||
assert.equal(shard.generalServerSuiteCount, total, "suite count must be stable across shards");
|
||||
for (const file of shard.selectedGeneralServerSuites) {
|
||||
assert.ok(!seen.has(file), `suite assigned to more than one shard: ${file}`);
|
||||
seen.add(file);
|
||||
selectedTotal += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Every suite runs exactly once: union covers the whole set with no overlap.
|
||||
assert.equal(selectedTotal, total, "every suite must be selected exactly once");
|
||||
assert.equal(seen.size, total, "union of shards must cover the whole suite set");
|
||||
});
|
||||
|
||||
test("a route/authz suite never leaks into the general-server shards", () => {
|
||||
const shard = dryRunJson(["--mode", "general", "--group", "general-server", "--shard-index", "0", "--shard-count", SHARD_COUNT.toString()]);
|
||||
for (const file of shard.selectedGeneralServerSuites) {
|
||||
assert.ok(
|
||||
!/[^/]*(?:route|routes|authz)[^/]*\.test\.ts$/.test(file),
|
||||
`route/authz suite must stay in the serialized lane, not general-server: ${file}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("shard flags are rejected for the parallel workspace groups", () => {
|
||||
const result = dryRun(["--mode", "general", "--group", "general-workspaces-a", "--shard-index", "0", "--shard-count", "3"]);
|
||||
assert.notEqual(result.status, 0, "workspace groups must not accept shard flags");
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import test from "node:test";
|
||||
|
||||
const script = new URL("../serve-storybook-static.mjs", import.meta.url).pathname;
|
||||
|
||||
test("--port followed by another flag is not parsed as the port value", () => {
|
||||
const result = spawnSync(process.execPath, [script, "--port", "--unused-flag"], {
|
||||
env: { ...process.env, PORT: "65536" },
|
||||
encoding: "utf8",
|
||||
});
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /Invalid Storybook static server port: 65536/);
|
||||
assert.doesNotMatch(result.stderr, /--unused-flag/);
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import test from "node:test";
|
||||
|
||||
const script = new URL("../storybook-visual-baseline.mjs", import.meta.url).pathname;
|
||||
|
||||
test("downloads and verifies a checksum-pinned local baseline archive", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "storybook-visual-baseline-test-"));
|
||||
try {
|
||||
const source = join(root, "source");
|
||||
const cache = join(root, "cache");
|
||||
const snapshots = join(root, "snapshots");
|
||||
const manifest = join(root, "manifest.json");
|
||||
mkdirSync(source, { recursive: true });
|
||||
writeFileSync(join(source, "one.png"), "png-one");
|
||||
writeFileSync(join(source, "two.png"), "png-two");
|
||||
const archive = join(root, "snapshots.tgz");
|
||||
run("tar", ["--sort=name", "--mtime=@0", "--owner=0", "--group=0", "--numeric-owner", "-czf", archive, "-C", source, "."]);
|
||||
const sha256 = createHash("sha256").update(readFileSync(archive)).digest("hex");
|
||||
const byteSize = statSync(archive).size;
|
||||
writeFileSync(
|
||||
manifest,
|
||||
JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
baselineId: "test-baseline",
|
||||
snapshotCount: 2,
|
||||
archive: {
|
||||
url: `file://${archive}`,
|
||||
sha256,
|
||||
byteSize,
|
||||
},
|
||||
environment: {
|
||||
browser: "chromium",
|
||||
viewport: "1200x800",
|
||||
deviceScaleFactor: 1,
|
||||
platform: "test",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
STORYBOOK_VISUAL_BASELINE_MANIFEST: manifest,
|
||||
STORYBOOK_VISUAL_BASELINE_CACHE_DIR: cache,
|
||||
STORYBOOK_VISUAL_SNAPSHOT_DIR: snapshots,
|
||||
};
|
||||
const download = spawnSync(process.execPath, [script, "download"], { env, encoding: "utf8" });
|
||||
assert.equal(download.status, 0, download.stderr);
|
||||
const verify = spawnSync(process.execPath, [script, "verify"], { env, encoding: "utf8" });
|
||||
assert.equal(verify.status, 0, verify.stderr);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("verify fails closed on snapshot count mismatch", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "storybook-visual-baseline-test-"));
|
||||
try {
|
||||
const snapshots = join(root, "snapshots");
|
||||
const manifest = join(root, "manifest.json");
|
||||
mkdirSync(snapshots, { recursive: true });
|
||||
writeFileSync(join(snapshots, "one.png"), "png-one");
|
||||
const archive = join(root, "snapshots.tgz");
|
||||
run("tar", ["--sort=name", "--mtime=@0", "--owner=0", "--group=0", "--numeric-owner", "-czf", archive, "-C", snapshots, "."]);
|
||||
const sha256 = createHash("sha256").update(readFileSync(archive)).digest("hex");
|
||||
const manifestBody = {
|
||||
version: 1,
|
||||
baselineId: "test-baseline",
|
||||
snapshotCount: 1,
|
||||
archive: {
|
||||
url: `file://${archive}`,
|
||||
sha256,
|
||||
byteSize: statSync(archive).size,
|
||||
},
|
||||
};
|
||||
writeFileSync(manifest, JSON.stringify(manifestBody));
|
||||
const env = {
|
||||
...process.env,
|
||||
STORYBOOK_VISUAL_BASELINE_MANIFEST: manifest,
|
||||
STORYBOOK_VISUAL_BASELINE_CACHE_DIR: root,
|
||||
STORYBOOK_VISUAL_SNAPSHOT_DIR: snapshots,
|
||||
};
|
||||
const download = spawnSync(process.execPath, [script, "download"], { env, encoding: "utf8" });
|
||||
assert.equal(download.status, 0, download.stderr);
|
||||
writeFileSync(manifest, JSON.stringify({ ...manifestBody, snapshotCount: 2 }));
|
||||
unlinkSync(join(snapshots, "one.png"));
|
||||
const result = spawnSync(process.execPath, [script, "verify"], { env, encoding: "utf8" });
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /snapshot count mismatch/i);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function run(cmd, args) {
|
||||
const result = spawnSync(cmd, args, { stdio: "pipe", encoding: "utf8" });
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { companies, createDb } from "../packages/db/src/index.js";
|
||||
import { loadConfig } from "../server/src/config.js";
|
||||
import { issueReferenceService } from "../server/src/services/issue-references.js";
|
||||
|
||||
function parseFlag(name: string): string | null {
|
||||
const index = process.argv.indexOf(name);
|
||||
if (index < 0) return null;
|
||||
const value = process.argv[index + 1];
|
||||
return value && !value.startsWith("--") ? value : null;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const config = loadConfig();
|
||||
const dbUrl =
|
||||
process.env.DATABASE_URL?.trim()
|
||||
|| config.databaseUrl
|
||||
|| `postgres://paperclip:paperclip@127.0.0.1:${config.embeddedPostgresPort}/paperclip`;
|
||||
|
||||
const db = createDb(dbUrl);
|
||||
const refs = issueReferenceService(db);
|
||||
const companyId = parseFlag("--company");
|
||||
const companyRows = companyId
|
||||
? [{ id: companyId }]
|
||||
: await db.select({ id: companies.id }).from(companies);
|
||||
|
||||
if (companyRows.length === 0) {
|
||||
console.log("No companies found; nothing to backfill.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Backfilling issue reference mentions for ${companyRows.length} compan${companyRows.length === 1 ? "y" : "ies"}...`);
|
||||
for (const company of companyRows) {
|
||||
console.log(`- ${company.id}`);
|
||||
await refs.syncAllForCompany(company.id);
|
||||
}
|
||||
console.log("Issue reference backfill complete.");
|
||||
}
|
||||
|
||||
void main().catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`Issue reference backfill failed: ${message}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
Executable
+17
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Backup the configured Paperclip database to the configured backup directory
|
||||
# (default: ~/.paperclip/instances/<instance-id>/data/backups)
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/backup-db.sh
|
||||
# pnpm db:backup
|
||||
#
|
||||
# The embedded postgres must be running (start with: pnpm dev)
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
cd "$PROJECT_ROOT"
|
||||
exec pnpm paperclipai db:backup "$@"
|
||||
@@ -0,0 +1,306 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
import { buildReleasePackagePlan } from "./release-package-map.mjs";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = resolve(__dirname, "..");
|
||||
|
||||
function normalizePath(filePath) {
|
||||
return filePath.replace(/\\/g, "/").replace(/^\.\//, "");
|
||||
}
|
||||
|
||||
function usage() {
|
||||
process.stderr.write(
|
||||
[
|
||||
"Usage:",
|
||||
" node scripts/bootstrap-npm-package.mjs <package-name-or-dir> [--publish --otp <code>] [--skip-build]",
|
||||
"",
|
||||
"Examples:",
|
||||
" node scripts/bootstrap-npm-package.mjs @paperclipai/plugin-workspace-diff",
|
||||
" node scripts/bootstrap-npm-package.mjs packages/plugins/plugin-workspace-diff --publish",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const flags = new Set();
|
||||
let selector = null;
|
||||
let otp = null;
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === "--") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--publish" || arg === "--skip-build") {
|
||||
flags.add(arg);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--otp") {
|
||||
const value = argv[index + 1];
|
||||
if (!value || value.startsWith("--")) {
|
||||
throw new Error("expected a one-time password after --otp");
|
||||
}
|
||||
otp = value;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
return { help: true, selector: null, publish: false, skipBuild: false, otp: null };
|
||||
}
|
||||
|
||||
if (arg.startsWith("--")) {
|
||||
throw new Error(`unknown option: ${arg}`);
|
||||
}
|
||||
|
||||
if (selector) {
|
||||
throw new Error("expected exactly one package selector");
|
||||
}
|
||||
|
||||
selector = arg;
|
||||
}
|
||||
|
||||
return {
|
||||
help: false,
|
||||
selector,
|
||||
publish: flags.has("--publish"),
|
||||
skipBuild: flags.has("--skip-build"),
|
||||
otp,
|
||||
};
|
||||
}
|
||||
|
||||
function runCommand(command, args, options = {}) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
stdio: ["inherit", "pipe", "pipe"],
|
||||
...options,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function runChecked(command, args, options = {}) {
|
||||
const result = runCommand(command, args, options);
|
||||
const stdout = result.stdout ?? "";
|
||||
const stderr = result.stderr ?? "";
|
||||
|
||||
if (stdout) process.stdout.write(stdout);
|
||||
if (stderr) process.stderr.write(stderr);
|
||||
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`${command} ${args.join(" ")} failed with status ${result.status ?? "unknown"}`);
|
||||
}
|
||||
}
|
||||
|
||||
function formatCommand(command, args) {
|
||||
return `${command} ${args.join(" ")}`;
|
||||
}
|
||||
|
||||
function ensureNpmAuth() {
|
||||
const result = runCommand("npm", ["whoami"]);
|
||||
const stdout = result.stdout ?? "";
|
||||
const stderr = result.stderr ?? "";
|
||||
|
||||
if (stdout) process.stdout.write(stdout);
|
||||
if (stderr) process.stderr.write(stderr);
|
||||
|
||||
if (result.status === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const output = `${stdout}\n${stderr}`.trim();
|
||||
if (/\bE401\b|401 Unauthorized/i.test(output)) {
|
||||
throw new Error(
|
||||
[
|
||||
"npm auth check failed.",
|
||||
"This usually means the machine is either not logged into npm yet or has a stale token in ~/.npmrc.",
|
||||
"Run `npm logout --registry=https://registry.npmjs.org/` and then `npm login` or `npm adduser` on this maintainer machine with an npm account that can publish to the @paperclipai scope, then rerun with --publish.",
|
||||
"Do not use this auth flow in CI; it is only for the one-time human bootstrap publish.",
|
||||
].join(" "),
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error("npm whoami failed");
|
||||
}
|
||||
|
||||
function inspectNpmPackage(packageName) {
|
||||
const result = runCommand("npm", ["view", packageName, "version", "--json"]);
|
||||
|
||||
if (result.status === 0) {
|
||||
const version = JSON.parse((result.stdout ?? "").trim());
|
||||
return { exists: true, version };
|
||||
}
|
||||
|
||||
const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim();
|
||||
if (/\bE404\b|404 Not Found|could not be found/i.test(output)) {
|
||||
return { exists: false };
|
||||
}
|
||||
|
||||
process.stderr.write(output ? `${output}\n` : "");
|
||||
throw new Error(`failed to query npm for ${packageName}`);
|
||||
}
|
||||
|
||||
function resolveTargetPackage(selector, packages = buildReleasePackagePlan()) {
|
||||
const normalizedSelector = normalizePath(selector);
|
||||
const matches = packages.filter(
|
||||
(pkg) => pkg.name === selector || normalizePath(pkg.dir) === normalizedSelector,
|
||||
);
|
||||
|
||||
if (matches.length === 1) {
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
if (matches.length > 1) {
|
||||
throw new Error(`package selector is ambiguous: ${selector}`);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`unknown package selector: ${selector}\nKnown packages:\n- ${packages.map((pkg) => `${pkg.name} (${pkg.dir})`).join("\n- ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
function printNextSteps(pkg) {
|
||||
process.stdout.write(
|
||||
[
|
||||
"",
|
||||
"Publish succeeded. Next:",
|
||||
`1. Open https://www.npmjs.com/package/${pkg.name}`,
|
||||
"2. Go to Settings -> Trusted publishing",
|
||||
"3. Add repository paperclipai/paperclip",
|
||||
"4. Set workflow filename to release.yml",
|
||||
"5. Optionally enable Settings -> Publishing access -> Require two-factor authentication and disallow tokens",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
function buildPublishArgs(pkg, { dryRun = false, otp = null } = {}) {
|
||||
const args = ["publish", pkg.dir, "--no-git-checks", "--access", "public"];
|
||||
|
||||
if (dryRun) {
|
||||
args.push("--dry-run");
|
||||
}
|
||||
|
||||
if (otp) {
|
||||
args.push("--otp", otp);
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
function publishPackage(pkg, otp) {
|
||||
const publishArgs = buildPublishArgs(pkg, { otp });
|
||||
|
||||
const result = runCommand("pnpm", publishArgs);
|
||||
const stdout = result.stdout ?? "";
|
||||
const stderr = result.stderr ?? "";
|
||||
const output = `${stdout}\n${stderr}`.trim();
|
||||
|
||||
if (stdout) process.stdout.write(stdout);
|
||||
if (stderr) process.stderr.write(stderr);
|
||||
|
||||
if (result.status === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (/\bEOTP\b|one-time password/i.test(output)) {
|
||||
throw new Error(
|
||||
[
|
||||
"npm publish reached the publish-time 2FA check.",
|
||||
"Complete the browser auth URL printed by npm and rerun the helper, or rerun with `--otp <code>` if your npm account uses authenticator-app codes.",
|
||||
].join(" "),
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(`${formatCommand("pnpm", publishArgs)} failed with status ${result.status ?? "unknown"}`);
|
||||
}
|
||||
|
||||
function main(argv) {
|
||||
const { help, selector, publish, skipBuild, otp } = parseArgs(argv);
|
||||
|
||||
if (help) {
|
||||
usage();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selector) {
|
||||
usage();
|
||||
throw new Error("missing package selector");
|
||||
}
|
||||
|
||||
const pkg = resolveTargetPackage(selector);
|
||||
process.stdout.write(`Selected ${pkg.name} (${pkg.dir})\n`);
|
||||
|
||||
if (publish && !otp) {
|
||||
throw new Error("`--publish` requires `--otp <code>`. Generate a fresh npm one-time password and rerun.");
|
||||
}
|
||||
|
||||
const npmState = inspectNpmPackage(pkg.name);
|
||||
if (npmState.exists) {
|
||||
throw new Error(`${pkg.name} already exists on npm at version ${npmState.version}; bootstrap is only for first publish`);
|
||||
}
|
||||
|
||||
process.stdout.write(`${pkg.name} is not on npm yet; continuing with bootstrap flow.\n`);
|
||||
|
||||
if (publish) {
|
||||
process.stdout.write("Checking npm auth with npm whoami...\n");
|
||||
ensureNpmAuth();
|
||||
}
|
||||
|
||||
if (!skipBuild && typeof pkg.pkg?.scripts?.build === "string") {
|
||||
process.stdout.write(`Building ${pkg.name}...\n`);
|
||||
runChecked("pnpm", ["--filter", pkg.name, "build"]);
|
||||
}
|
||||
|
||||
process.stdout.write(`Previewing publish payload for ${pkg.name}...\n`);
|
||||
runChecked("pnpm", buildPublishArgs(pkg, { dryRun: true }));
|
||||
|
||||
if (!publish) {
|
||||
process.stdout.write(
|
||||
[
|
||||
"",
|
||||
"Dry run complete. To perform the first publish from an authenticated maintainer machine, run:",
|
||||
`node scripts/bootstrap-npm-package.mjs ${pkg.name} --publish --otp <code>`,
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
process.stdout.write(`Publishing ${pkg.name}...\n`);
|
||||
publishPackage(pkg, otp);
|
||||
printNextSteps(pkg);
|
||||
}
|
||||
|
||||
const isDirectRun = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
||||
|
||||
if (isDirectRun) {
|
||||
try {
|
||||
main(process.argv.slice(2));
|
||||
} catch (error) {
|
||||
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
buildPublishArgs,
|
||||
ensureNpmAuth,
|
||||
inspectNpmPackage,
|
||||
parseArgs,
|
||||
publishPackage,
|
||||
resolveTargetPackage,
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { buildPublishArgs, parseArgs, resolveTargetPackage } from "./bootstrap-npm-package.mjs";
|
||||
|
||||
test("parseArgs recognizes publish and skip-build flags", () => {
|
||||
assert.deepEqual(parseArgs(["@paperclipai/plugin-workspace-diff", "--publish", "--skip-build"]), {
|
||||
help: false,
|
||||
selector: "@paperclipai/plugin-workspace-diff",
|
||||
publish: true,
|
||||
skipBuild: true,
|
||||
otp: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("parseArgs accepts an explicit otp value", () => {
|
||||
assert.deepEqual(parseArgs(["packages/plugins/plugin-workspace-diff", "--publish", "--otp", "123456"]), {
|
||||
help: false,
|
||||
selector: "packages/plugins/plugin-workspace-diff",
|
||||
publish: true,
|
||||
skipBuild: false,
|
||||
otp: "123456",
|
||||
});
|
||||
});
|
||||
|
||||
test("parseArgs leaves otp null when omitted", () => {
|
||||
assert.deepEqual(parseArgs(["packages/plugins/plugin-workspace-diff", "--publish"]), {
|
||||
help: false,
|
||||
selector: "packages/plugins/plugin-workspace-diff",
|
||||
publish: true,
|
||||
skipBuild: false,
|
||||
otp: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("parseArgs returns help mode", () => {
|
||||
assert.deepEqual(parseArgs(["--help"]), {
|
||||
help: true,
|
||||
selector: null,
|
||||
publish: false,
|
||||
skipBuild: false,
|
||||
otp: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("resolveTargetPackage matches by package name or dir", () => {
|
||||
const packages = [
|
||||
{ dir: "packages/a", name: "@paperclipai/a", pkg: {} },
|
||||
{ dir: "packages/b", name: "@paperclipai/b", pkg: {} },
|
||||
];
|
||||
|
||||
assert.equal(resolveTargetPackage("@paperclipai/a", packages).dir, "packages/a");
|
||||
assert.equal(resolveTargetPackage("./packages/b", packages).name, "@paperclipai/b");
|
||||
});
|
||||
|
||||
test("resolveTargetPackage includes the workspace diff plugin bootstrap package", () => {
|
||||
const pkg = resolveTargetPackage("@paperclipai/plugin-workspace-diff");
|
||||
|
||||
assert.equal(pkg.dir, "packages/plugins/plugin-workspace-diff");
|
||||
});
|
||||
|
||||
test("buildPublishArgs publishes from the repo root through pnpm", () => {
|
||||
const pkg = { dir: "packages/adapters/hermes", name: "@paperclipai/hermes-paperclip-adapter" };
|
||||
|
||||
assert.deepEqual(buildPublishArgs(pkg), [
|
||||
"publish",
|
||||
"packages/adapters/hermes",
|
||||
"--no-git-checks",
|
||||
"--access",
|
||||
"public",
|
||||
]);
|
||||
});
|
||||
|
||||
test("buildPublishArgs includes dry-run and otp flags when requested", () => {
|
||||
const pkg = { dir: "packages/adapters/hermes", name: "@paperclipai/hermes-paperclip-adapter" };
|
||||
|
||||
assert.deepEqual(buildPublishArgs(pkg, { dryRun: true, otp: "123456" }), [
|
||||
"publish",
|
||||
"packages/adapters/hermes",
|
||||
"--no-git-checks",
|
||||
"--access",
|
||||
"public",
|
||||
"--dry-run",
|
||||
"--otp",
|
||||
"123456",
|
||||
]);
|
||||
});
|
||||
Executable
+80
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# build-npm.sh — Build the paperclipai CLI package for npm publishing.
|
||||
#
|
||||
# Uses esbuild to bundle all workspace code into a single file,
|
||||
# keeping external npm dependencies as regular package dependencies.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/build-npm.sh # full build
|
||||
# ./scripts/build-npm.sh --skip-checks # skip forbidden-token check (CI without token list)
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
CLI_DIR="$REPO_ROOT/cli"
|
||||
DIST_DIR="$CLI_DIR/dist"
|
||||
|
||||
skip_checks=false
|
||||
skip_typecheck=false
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--skip-checks) skip_checks=true ;;
|
||||
--skip-typecheck) skip_typecheck=true ;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo "==> Building paperclipai for npm"
|
||||
|
||||
# ── Step 1: Forbidden token check ──────────────────────────────────────────────
|
||||
if [ "$skip_checks" = false ]; then
|
||||
echo " [1/5] Running forbidden token check..."
|
||||
node "$REPO_ROOT/scripts/check-forbidden-tokens.mjs"
|
||||
else
|
||||
echo " [1/5] Skipping forbidden token check (--skip-checks)"
|
||||
fi
|
||||
|
||||
# ── Step 2: TypeScript type-check ──────────────────────────────────────────────
|
||||
if [ "$skip_typecheck" = false ]; then
|
||||
echo " [2/6] Type-checking..."
|
||||
cd "$REPO_ROOT"
|
||||
pnpm -r typecheck
|
||||
else
|
||||
echo " [2/6] Skipping type-check (--skip-typecheck)"
|
||||
fi
|
||||
|
||||
# ── Step 3: Bundle CLI with esbuild ────────────────────────────────────────────
|
||||
echo " [3/6] Bundling CLI with esbuild..."
|
||||
cd "$CLI_DIR"
|
||||
rm -rf dist
|
||||
|
||||
node --input-type=module -e "
|
||||
import esbuild from 'esbuild';
|
||||
import config from './esbuild.config.mjs';
|
||||
await esbuild.build(config);
|
||||
"
|
||||
|
||||
chmod +x dist/index.js
|
||||
|
||||
# ── Step 4: Validate bundled entrypoint syntax ─────────────────────────────────
|
||||
echo " [4/6] Verifying bundled entrypoint syntax..."
|
||||
node --check "$DIST_DIR/index.js"
|
||||
|
||||
# ── Step 5: Back up dev package.json, generate publishable one ─────────────────
|
||||
echo " [5/6] Generating publishable package.json..."
|
||||
cp "$CLI_DIR/package.json" "$CLI_DIR/package.dev.json"
|
||||
node "$REPO_ROOT/scripts/generate-npm-package-json.mjs"
|
||||
|
||||
# Copy root README so npm shows the repo README on the package page
|
||||
cp "$REPO_ROOT/README.md" "$CLI_DIR/README.md"
|
||||
|
||||
# ── Step 6: Summary ───────────────────────────────────────────────────────────
|
||||
BUNDLE_SIZE=$(wc -c < "$DIST_DIR/index.js" | xargs)
|
||||
echo " [6/6] Build verification..."
|
||||
echo ""
|
||||
echo "Build complete."
|
||||
echo " Bundle: cli/dist/index.js (${BUNDLE_SIZE} bytes)"
|
||||
echo " Source map: cli/dist/index.js.map"
|
||||
echo ""
|
||||
echo "To preview: cd cli && npm pack --dry-run"
|
||||
echo "To publish: cd cli && npm publish --access public"
|
||||
echo "To restore: mv cli/package.dev.json cli/package.json"
|
||||
@@ -0,0 +1,264 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { execFile, execFileSync } from "node:child_process";
|
||||
import { existsSync, readFileSync, rmSync } from "node:fs";
|
||||
import { availableParallelism } from "node:os";
|
||||
import path, { dirname } from "node:path";
|
||||
import process from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { linkSdkInto } from "./link-plugin-dev-sdk.mjs";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(scriptDir, "..");
|
||||
const workspacePath = path.join(repoRoot, "pnpm-workspace.yaml");
|
||||
const releasePackageMapPath = path.join(repoRoot, "scripts", "release-package-map.mjs");
|
||||
|
||||
function parseWorkspaceEntries(workspaceText) {
|
||||
// Keep this aligned with the repo's block-sequence `packages:` format in
|
||||
// pnpm-workspace.yaml. If that file moves to a more complex YAML shape,
|
||||
// switch this parser to a real YAML parser instead of line matching.
|
||||
return workspaceText
|
||||
.split("\n")
|
||||
.map((line) => line.match(/^\s*-\s+(.+)\s*$/)?.[1]?.trim() ?? null)
|
||||
.map((entry) => {
|
||||
if (!entry) return entry;
|
||||
return entry.replace(/^(['"])(.*)\1$/, "$2");
|
||||
})
|
||||
.filter(Boolean)
|
||||
.map((entry) => ({
|
||||
pattern: entry.startsWith("!") ? entry.slice(1) : entry,
|
||||
negated: entry.startsWith("!"),
|
||||
}));
|
||||
}
|
||||
|
||||
function globToRegExp(pattern) {
|
||||
let regex = "";
|
||||
|
||||
for (let index = 0; index < pattern.length; index += 1) {
|
||||
const char = pattern[index];
|
||||
const next = pattern[index + 1];
|
||||
|
||||
if (char === "*" && next === "*") {
|
||||
regex += ".*";
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === "*") {
|
||||
regex += "[^/]*";
|
||||
continue;
|
||||
}
|
||||
if (char === "?") {
|
||||
regex += "[^/]";
|
||||
continue;
|
||||
}
|
||||
regex += /[|\\{}()[\]^$+?.]/.test(char) ? `\\${char}` : char;
|
||||
}
|
||||
|
||||
return new RegExp(`^${regex}$`);
|
||||
}
|
||||
|
||||
function isWorkspacePackage(pkgDir, workspaceEntries) {
|
||||
let included = false;
|
||||
|
||||
for (const entry of workspaceEntries) {
|
||||
if (globToRegExp(entry.pattern).test(pkgDir)) {
|
||||
included = !entry.negated;
|
||||
}
|
||||
}
|
||||
|
||||
return included;
|
||||
}
|
||||
|
||||
function listPublicPackages() {
|
||||
const output = execFileSync(
|
||||
process.execPath,
|
||||
[releasePackageMapPath, "list"],
|
||||
{ cwd: repoRoot, encoding: "utf8" },
|
||||
);
|
||||
|
||||
return output
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const [dir, name] = line.split("\t");
|
||||
return { dir, name };
|
||||
});
|
||||
}
|
||||
|
||||
function readPackageJson(pkgDir) {
|
||||
return JSON.parse(
|
||||
readFileSync(path.join(repoRoot, pkgDir, "package.json"), "utf8"),
|
||||
);
|
||||
}
|
||||
|
||||
async function runCaptured(command, args, cwd, log) {
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync(command, args, {
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
CI: "true",
|
||||
},
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
});
|
||||
if (stdout?.trim()) log(stdout.trimEnd());
|
||||
if (stderr?.trim()) log(stderr.trimEnd());
|
||||
} catch (error) {
|
||||
if (error.stdout?.toString().trim()) log(error.stdout.toString().trimEnd());
|
||||
if (error.stderr?.toString().trim()) log(error.stderr.toString().trimEnd());
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Each standalone package installs into its own directory (`--ignore-workspace`)
|
||||
// and builds into its own `dist`, so there is no shared mutable state between
|
||||
// packages beyond pnpm's content-addressable global store, which is safe for
|
||||
// concurrent access. Buffer each package's output and flush it as one block so
|
||||
// interleaved parallel logs stay readable.
|
||||
async function prepareAndBuildPackage(pkg) {
|
||||
const logs = [];
|
||||
const log = (line) => logs.push(line);
|
||||
|
||||
const pkgDir = path.join(repoRoot, pkg.dir);
|
||||
const pkgJson = readPackageJson(pkg.dir);
|
||||
const nodeModulesDir = path.join(pkgDir, "node_modules");
|
||||
const packageLockfilePath = path.join(pkgDir, "pnpm-lock.yaml");
|
||||
|
||||
log(` Preparing standalone package ${pkg.name} (${pkg.dir})`);
|
||||
try {
|
||||
if (existsSync(nodeModulesDir)) {
|
||||
rmSync(nodeModulesDir, { force: true, recursive: true });
|
||||
}
|
||||
|
||||
const installArgs = existsSync(packageLockfilePath)
|
||||
? ["install", "--ignore-workspace", "--frozen-lockfile"]
|
||||
: [
|
||||
"install",
|
||||
"--ignore-workspace",
|
||||
"--no-lockfile",
|
||||
// Standalone packages intentionally avoid committed lockfile churn in the repo.
|
||||
];
|
||||
|
||||
await runCaptured("pnpm", installArgs, pkgDir, log);
|
||||
|
||||
// The fresh install above wipes node_modules and no longer fires a
|
||||
// per-plugin postinstall (removed for supply-chain safety), so link the
|
||||
// in-repo @paperclipai/plugin-sdk that the build's tsc resolves against.
|
||||
linkSdkInto(pkgDir);
|
||||
|
||||
if (pkgJson.scripts?.build) {
|
||||
await runCaptured("pnpm", ["run", "build"], pkgDir, log);
|
||||
} else {
|
||||
log(" i No build script; skipped build");
|
||||
}
|
||||
} finally {
|
||||
if (logs.length > 0) {
|
||||
console.log(logs.join("\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveConcurrency(packageCount) {
|
||||
const raw = process.env.STANDALONE_BUILD_CONCURRENCY;
|
||||
if (raw !== undefined && raw !== "") {
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
return Math.max(1, Math.min(parsed, packageCount));
|
||||
}
|
||||
}
|
||||
|
||||
let cpus = 4;
|
||||
try {
|
||||
cpus = availableParallelism();
|
||||
} catch {
|
||||
// Fall back to the default when parallelism cannot be determined.
|
||||
}
|
||||
return Math.max(1, Math.min(cpus, packageCount));
|
||||
}
|
||||
|
||||
// Bounded-concurrency task pool. Workers pull from a shared cursor so no more
|
||||
// than `limit` tasks run at once. Every task is awaited even if an earlier one
|
||||
// fails, and failures are aggregated (sorted by original index) so a single bad
|
||||
// package neither aborts the others mid-flight nor hides which one broke.
|
||||
export async function runWithConcurrency(items, limit, worker) {
|
||||
const results = new Array(items.length);
|
||||
const failures = [];
|
||||
let nextIndex = 0;
|
||||
|
||||
async function runWorker() {
|
||||
while (true) {
|
||||
const current = nextIndex;
|
||||
nextIndex += 1;
|
||||
if (current >= items.length) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
results[current] = await worker(items[current], current);
|
||||
} catch (error) {
|
||||
failures.push({ index: current, error });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const poolSize = Math.max(1, Math.min(limit, items.length));
|
||||
await Promise.all(Array.from({ length: poolSize }, () => runWorker()));
|
||||
|
||||
if (failures.length > 0) {
|
||||
failures.sort((a, b) => a.index - b.index);
|
||||
const aggregate = new Error(
|
||||
`${failures.length} standalone package build(s) failed`,
|
||||
);
|
||||
aggregate.failures = failures;
|
||||
throw aggregate;
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const workspaceEntries = parseWorkspaceEntries(readFileSync(workspacePath, "utf8"));
|
||||
const standalonePackages = listPublicPackages()
|
||||
.filter(({ dir }) => !isWorkspacePackage(dir, workspaceEntries));
|
||||
|
||||
if (standalonePackages.length === 0) {
|
||||
console.log(" i No standalone public packages detected outside the pnpm workspace");
|
||||
return;
|
||||
}
|
||||
|
||||
const concurrency = resolveConcurrency(standalonePackages.length);
|
||||
console.log(
|
||||
` Building ${standalonePackages.length} standalone package(s) with concurrency ${concurrency}`,
|
||||
);
|
||||
|
||||
try {
|
||||
await runWithConcurrency(
|
||||
standalonePackages,
|
||||
concurrency,
|
||||
prepareAndBuildPackage,
|
||||
);
|
||||
} catch (error) {
|
||||
if (Array.isArray(error.failures)) {
|
||||
const names = error.failures
|
||||
.map(({ index }) => standalonePackages[index]?.name)
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
console.error(` ✗ Failed standalone package build(s): ${names}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const invokedDirectly =
|
||||
process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
||||
|
||||
if (invokedDirectly) {
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env node
|
||||
// Captures the BindingPicker storybook screenshot for PAP-2351 re-review.
|
||||
// Boots a tiny static server over `ui/storybook-static` and screenshots the
|
||||
// happy-path picker grid in dark mode at 1440x900 (matches the original
|
||||
// PAP-2350 capture).
|
||||
|
||||
import { createRequire } from "node:module";
|
||||
const localRequire = createRequire(import.meta.url);
|
||||
const { chromium } = localRequire("playwright");
|
||||
import http from "node:http";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(__dirname, "..");
|
||||
const storybookRoot = path.join(repoRoot, "ui", "storybook-static");
|
||||
const outDir = process.argv[2]
|
||||
? path.resolve(process.argv[2])
|
||||
: path.join(repoRoot, "screenshots", "pap-2351");
|
||||
|
||||
const MIME = {
|
||||
".html": "text/html",
|
||||
".js": "application/javascript",
|
||||
".mjs": "application/javascript",
|
||||
".css": "text/css",
|
||||
".json": "application/json",
|
||||
".svg": "image/svg+xml",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
".ico": "image/x-icon",
|
||||
".map": "application/json",
|
||||
};
|
||||
|
||||
function startStaticServer(rootDir) {
|
||||
return new Promise((resolve) => {
|
||||
const server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
const urlPath = decodeURIComponent((req.url ?? "/").split("?")[0]);
|
||||
let filePath = path.join(rootDir, urlPath === "/" ? "index.html" : urlPath);
|
||||
let stat;
|
||||
try {
|
||||
stat = await fs.stat(filePath);
|
||||
} catch {
|
||||
stat = null;
|
||||
}
|
||||
if (stat?.isDirectory()) {
|
||||
filePath = path.join(filePath, "index.html");
|
||||
stat = await fs.stat(filePath).catch(() => null);
|
||||
}
|
||||
if (!stat) {
|
||||
res.statusCode = 404;
|
||||
res.end("not found");
|
||||
return;
|
||||
}
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
res.setHeader("content-type", MIME[ext] ?? "application/octet-stream");
|
||||
res.setHeader("cache-control", "no-cache");
|
||||
const data = await fs.readFile(filePath);
|
||||
res.end(data);
|
||||
} catch (err) {
|
||||
res.statusCode = 500;
|
||||
res.end(err.message);
|
||||
}
|
||||
});
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
const port = typeof address === "object" && address ? address.port : 0;
|
||||
resolve({ server, baseUrl: `http://127.0.0.1:${port}` });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const SHOTS = [
|
||||
{
|
||||
storyId: "product-secrets--binding-picker",
|
||||
label: "secrets-binding-picker",
|
||||
viewport: { width: 1440, height: 900 },
|
||||
theme: "dark",
|
||||
},
|
||||
];
|
||||
|
||||
async function main() {
|
||||
await fs.mkdir(outDir, { recursive: true });
|
||||
const { server, baseUrl } = await startStaticServer(storybookRoot);
|
||||
const browser = await chromium.launch();
|
||||
const ctx = await browser.newContext({ deviceScaleFactor: 1 });
|
||||
const page = await ctx.newPage();
|
||||
const captured = [];
|
||||
try {
|
||||
for (const shot of SHOTS) {
|
||||
await page.setViewportSize(shot.viewport);
|
||||
const url = `${baseUrl}/iframe.html?id=${encodeURIComponent(shot.storyId)}&viewMode=story&globals=theme:${shot.theme}`;
|
||||
await page.goto(url, { waitUntil: "networkidle", timeout: 30_000 });
|
||||
// Allow the storybook fixture to swap CompanyContext to the storybook id and
|
||||
// for the picker's useQuery to settle from cache.
|
||||
await page.waitForTimeout(1500);
|
||||
const dest = path.join(outDir, `${shot.label}.png`);
|
||||
await page.screenshot({ path: dest, fullPage: false });
|
||||
captured.push(dest);
|
||||
console.log("captured", dest);
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
server.close();
|
||||
}
|
||||
console.log(JSON.stringify({ captured }, null, 2));
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
const dockerfilePath = path.join(repoRoot, "Dockerfile");
|
||||
const workspacePath = path.join(repoRoot, "pnpm-workspace.yaml");
|
||||
|
||||
function extractDepsStage(dockerfileText) {
|
||||
const lines = dockerfileText.split("\n");
|
||||
const captured = [];
|
||||
let inDeps = false;
|
||||
|
||||
for (const line of lines) {
|
||||
if (!inDeps) {
|
||||
if (/^FROM .* AS deps$/i.test(line.trim())) inDeps = true;
|
||||
continue;
|
||||
}
|
||||
if (/^FROM /i.test(line.trim())) break;
|
||||
captured.push(line);
|
||||
}
|
||||
|
||||
return captured.join("\n");
|
||||
}
|
||||
|
||||
function parseWorkspaceRoots(workspaceText) {
|
||||
return workspaceText
|
||||
.split("\n")
|
||||
.map((line) => line.match(/^\s*-\s+(.+)\s*$/)?.[1]?.trim() ?? null)
|
||||
.map((entry) => {
|
||||
if (!entry) return entry;
|
||||
return entry.replace(/^(['"])(.*)\1$/, "$2");
|
||||
})
|
||||
.filter(Boolean)
|
||||
.filter((entry) => !entry.startsWith("!"))
|
||||
.map((entry) => entry.replace(/\*+$/, ""))
|
||||
.filter((entry) => entry.length > 0)
|
||||
.filter((entry) => !entry.includes("examples"))
|
||||
.filter((entry) => !entry.includes("create-paperclip-plugin"));
|
||||
}
|
||||
|
||||
function walkPackageJsonFiles(rootRelative, maxDepth) {
|
||||
const results = [];
|
||||
const rootAbsolute = path.join(repoRoot, rootRelative);
|
||||
|
||||
if (!existsSync(rootAbsolute)) return results;
|
||||
|
||||
function visit(currentAbsolute, depthFromRoot) {
|
||||
const entries = readdirSync(currentAbsolute, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.name === "node_modules") continue;
|
||||
|
||||
const absolute = path.join(currentAbsolute, entry.name);
|
||||
const relative = path.relative(repoRoot, absolute).split(path.sep).join("/");
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
if (depthFromRoot < maxDepth) visit(absolute, depthFromRoot + 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
entry.name === "package.json" &&
|
||||
!relative.includes("/examples/") &&
|
||||
!relative.includes("/create-paperclip-plugin/")
|
||||
) {
|
||||
results.push(relative);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
visit(rootAbsolute, 0);
|
||||
return results;
|
||||
}
|
||||
|
||||
function globToRegExp(pattern) {
|
||||
const normalized = pattern.replace("/./", "/");
|
||||
let regex = "";
|
||||
|
||||
for (let index = 0; index < normalized.length; index += 1) {
|
||||
const char = normalized[index];
|
||||
const next = normalized[index + 1];
|
||||
|
||||
if (char === "*" && next === "*") {
|
||||
regex += ".*";
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === "*") {
|
||||
regex += "[^/]*";
|
||||
continue;
|
||||
}
|
||||
if (char === "?") {
|
||||
regex += "[^/]";
|
||||
continue;
|
||||
}
|
||||
regex += /[|\\{}()[\]^$+?.]/.test(char) ? `\\${char}` : char;
|
||||
}
|
||||
|
||||
return new RegExp(`^${regex}$`);
|
||||
}
|
||||
|
||||
function parseCopySources(depsStage) {
|
||||
const sources = [];
|
||||
|
||||
for (const rawLine of depsStage.split("\n")) {
|
||||
const line = rawLine.trim();
|
||||
if (!line.startsWith("COPY ")) continue;
|
||||
|
||||
const tokens = line.split(/\s+/);
|
||||
let index = 1;
|
||||
while (tokens[index]?.startsWith("--")) index += 1;
|
||||
|
||||
const args = tokens.slice(index);
|
||||
if (args.length < 2) continue;
|
||||
|
||||
const lineSources = args.slice(0, -1);
|
||||
for (const source of lineSources) {
|
||||
sources.push(source);
|
||||
}
|
||||
}
|
||||
|
||||
return sources;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const depsStage = extractDepsStage(readFileSync(dockerfilePath, "utf8"));
|
||||
if (!depsStage.trim()) {
|
||||
console.error("Could not extract deps stage from Dockerfile (expected 'FROM ... AS deps').");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const workspaceRoots = parseWorkspaceRoots(readFileSync(workspacePath, "utf8"));
|
||||
if (workspaceRoots.length === 0) {
|
||||
console.error("Could not derive workspace roots from pnpm-workspace.yaml.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const requiredPackageJsons = [...new Set(
|
||||
workspaceRoots.flatMap((root) => walkPackageJsonFiles(root, 2)),
|
||||
)].sort();
|
||||
|
||||
const copySources = parseCopySources(depsStage);
|
||||
const copyMatchers = copySources.map((source) => ({
|
||||
source,
|
||||
regex: globToRegExp(source),
|
||||
}));
|
||||
|
||||
let missing = 0;
|
||||
for (const pkg of requiredPackageJsons) {
|
||||
const covered = copyMatchers.some(({ regex }) => regex.test(pkg));
|
||||
if (!covered) {
|
||||
console.error(`Dockerfile deps stage missing package manifest coverage for: ${pkg}`);
|
||||
missing = 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (existsSync(path.join(repoRoot, "patches"))) {
|
||||
const patchesCovered = copySources.includes("patches/");
|
||||
if (!patchesCovered) {
|
||||
console.error("Dockerfile deps stage missing: COPY patches/ patches/");
|
||||
missing = 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (missing) {
|
||||
console.error("Dockerfile deps stage is out of sync. Update it to cover the missing files.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("PASS");
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-forbidden-tokens.mjs
|
||||
*
|
||||
* Scans the codebase for forbidden tokens before publishing to npm.
|
||||
* Mirrors the git pre-commit hook logic, but runs against the full
|
||||
* working tree (not just staged changes).
|
||||
*
|
||||
* Token list: .git/hooks/forbidden-tokens.txt (one per line, # comments ok).
|
||||
* If the file is missing, the check still uses the active local username when
|
||||
* available. If username detection fails, the check degrades gracefully.
|
||||
*/
|
||||
|
||||
import { execSync } from "node:child_process";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import { resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
function uniqueNonEmpty(values) {
|
||||
return Array.from(new Set(values.map((value) => value?.trim() ?? "").filter(Boolean)));
|
||||
}
|
||||
|
||||
export function resolveDynamicForbiddenTokens(env = process.env, osModule = os) {
|
||||
const candidates = [env.USER, env.LOGNAME, env.USERNAME];
|
||||
|
||||
try {
|
||||
candidates.push(osModule.userInfo().username);
|
||||
} catch {
|
||||
// Some environments do not expose userInfo; env vars are enough fallback.
|
||||
}
|
||||
|
||||
return uniqueNonEmpty(candidates);
|
||||
}
|
||||
|
||||
export function readForbiddenTokensFile(tokensFile) {
|
||||
if (!existsSync(tokensFile)) return [];
|
||||
|
||||
return readFileSync(tokensFile, "utf8")
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line && !line.startsWith("#"));
|
||||
}
|
||||
|
||||
export function resolveForbiddenTokens(tokensFile, env = process.env, osModule = os) {
|
||||
return uniqueNonEmpty([
|
||||
...resolveDynamicForbiddenTokens(env, osModule),
|
||||
...readForbiddenTokensFile(tokensFile),
|
||||
]);
|
||||
}
|
||||
|
||||
export function runForbiddenTokenCheck({
|
||||
repoRoot,
|
||||
tokens,
|
||||
exec = execSync,
|
||||
log = console.log,
|
||||
error = console.error,
|
||||
}) {
|
||||
if (tokens.length === 0) {
|
||||
log(" ℹ Forbidden tokens list is empty — skipping check.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
let found = false;
|
||||
|
||||
for (const token of tokens) {
|
||||
try {
|
||||
const result = exec(
|
||||
`git grep -in --no-color -- ${JSON.stringify(token)} -- ':!pnpm-lock.yaml' ':!.git'`,
|
||||
{ encoding: "utf8", cwd: repoRoot, stdio: ["pipe", "pipe", "pipe"] },
|
||||
);
|
||||
if (result.trim()) {
|
||||
if (!found) {
|
||||
error("ERROR: Forbidden tokens found in tracked files:\n");
|
||||
}
|
||||
found = true;
|
||||
const lines = result.trim().split("\n");
|
||||
for (const line of lines) {
|
||||
error(` ${line}`);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// git grep returns exit code 1 when no matches — that's fine
|
||||
}
|
||||
}
|
||||
|
||||
if (found) {
|
||||
error("\nBuild blocked. Remove the forbidden token(s) before publishing.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
log(" ✓ No forbidden tokens found.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
function resolveRepoPaths(exec = execSync) {
|
||||
const repoRoot = exec("git rev-parse --show-toplevel", { encoding: "utf8" }).trim();
|
||||
const gitDir = exec("git rev-parse --git-dir", { encoding: "utf8", cwd: repoRoot }).trim();
|
||||
return {
|
||||
repoRoot,
|
||||
tokensFile: resolve(repoRoot, gitDir, "hooks/forbidden-tokens.txt"),
|
||||
};
|
||||
}
|
||||
|
||||
function main() {
|
||||
const { repoRoot, tokensFile } = resolveRepoPaths();
|
||||
const tokens = resolveForbiddenTokens(tokensFile);
|
||||
process.exit(runForbiddenTokenCheck({ repoRoot, tokens }));
|
||||
}
|
||||
|
||||
const isMainModule = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
||||
|
||||
if (isMainModule) {
|
||||
main();
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-no-git-push.mjs
|
||||
*
|
||||
* Static check that rejects `git push` (and equivalent remote-mutating git
|
||||
* invocations) inside adapter/runtime source code.
|
||||
*
|
||||
* Adapter and runtime code may never push to a git remote: the local
|
||||
* execution-workspace cwd is the only persistence boundary between runs
|
||||
* (see packages/adapters/AUTHORING.md and PAPA-432). Release tooling and
|
||||
* developer scripts that legitimately push are out of scope because they
|
||||
* live outside the directories scanned here.
|
||||
*
|
||||
* Opt-in mechanism: a line containing `paperclip:allow-git-push` (typically
|
||||
* inside a `// paperclip:allow-git-push: <reason>` comment on the line itself
|
||||
* or the line immediately above) suppresses the match. This is reserved for
|
||||
* operator-configured paths that legitimately push and must be reviewed.
|
||||
*/
|
||||
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const DEFAULT_SCAN_ROOTS = [
|
||||
"packages/adapters",
|
||||
"packages/adapter-utils",
|
||||
"server/src",
|
||||
"cli/src",
|
||||
];
|
||||
|
||||
const SCANNABLE_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".mjs", ".cjs"]);
|
||||
|
||||
const SKIP_DIRECTORY_NAMES = new Set([
|
||||
"node_modules",
|
||||
"dist",
|
||||
"build",
|
||||
".turbo",
|
||||
".next",
|
||||
"coverage",
|
||||
]);
|
||||
|
||||
const SKIP_FILENAME_SUFFIXES = [".d.ts"];
|
||||
|
||||
// Matches actual git push invocations in either:
|
||||
// `git push ...` (shell command string)
|
||||
// ["git", "push", ...] (args-array form for execSync)
|
||||
// execFile("git", ["push", ...]) / spawn("git", ["push", ...])
|
||||
export const GIT_PUSH_PATTERNS = [
|
||||
/\bgit[\s_-]+push\b/i,
|
||||
/["'`]git["'`]\s*,\s*\[?\s*["'`]push["'`]/i,
|
||||
];
|
||||
// Kept for backwards-compatibility with existing tests/importers.
|
||||
export const GIT_PUSH_PATTERN = GIT_PUSH_PATTERNS[0];
|
||||
export const ALLOW_MARKER = "paperclip:allow-git-push";
|
||||
|
||||
function lineMatchesGitPush(line) {
|
||||
return GIT_PUSH_PATTERNS.some((pattern) => pattern.test(line));
|
||||
}
|
||||
|
||||
function stripLineComment(line) {
|
||||
// Strip everything from the first `//` that is not inside a string literal.
|
||||
// This is a lightweight heuristic: we only need to remove obvious doc-style
|
||||
// mentions of "git push" so they do not trip the check. The check still
|
||||
// flags any match that survives comment stripping.
|
||||
let inSingle = false;
|
||||
let inDouble = false;
|
||||
let inBacktick = false;
|
||||
|
||||
for (let index = 0; index < line.length; index += 1) {
|
||||
const char = line[index];
|
||||
// A character is escaped only if it's preceded by an odd number of
|
||||
// backslashes; e.g. `"foo\\"` ends a string because the trailing `\\`
|
||||
// is a single escaped backslash, leaving the closing `"` unescaped.
|
||||
let backslashes = 0;
|
||||
for (let scan = index - 1; scan >= 0 && line[scan] === "\\"; scan -= 1) {
|
||||
backslashes += 1;
|
||||
}
|
||||
const isEscaped = backslashes % 2 === 1;
|
||||
|
||||
if (!inDouble && !inBacktick && char === "'" && !isEscaped) inSingle = !inSingle;
|
||||
else if (!inSingle && !inBacktick && char === '"' && !isEscaped) inDouble = !inDouble;
|
||||
else if (!inSingle && !inDouble && char === "`" && !isEscaped) inBacktick = !inBacktick;
|
||||
else if (!inSingle && !inDouble && !inBacktick && char === "/" && line[index + 1] === "/") {
|
||||
return line.slice(0, index);
|
||||
}
|
||||
}
|
||||
|
||||
return line;
|
||||
}
|
||||
|
||||
export function findGitPushOffenses(text) {
|
||||
const lines = text.split("\n");
|
||||
const offenses = [];
|
||||
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const line = lines[index];
|
||||
const stripped = stripLineComment(line);
|
||||
if (!lineMatchesGitPush(stripped)) continue;
|
||||
|
||||
const previousLine = index > 0 ? lines[index - 1] : "";
|
||||
const isAllowed = line.includes(ALLOW_MARKER) || previousLine.includes(ALLOW_MARKER);
|
||||
if (isAllowed) continue;
|
||||
|
||||
offenses.push({ lineNumber: index + 1, line: line.trimEnd() });
|
||||
}
|
||||
|
||||
return offenses;
|
||||
}
|
||||
|
||||
function shouldScanFile(relativePath) {
|
||||
if (SKIP_FILENAME_SUFFIXES.some((suffix) => relativePath.endsWith(suffix))) return false;
|
||||
const extension = path.extname(relativePath);
|
||||
return SCANNABLE_EXTENSIONS.has(extension);
|
||||
}
|
||||
|
||||
export function collectScannableFiles(absoluteRoot, repoRoot) {
|
||||
const results = [];
|
||||
let stats;
|
||||
try {
|
||||
stats = statSync(absoluteRoot);
|
||||
} catch {
|
||||
return results;
|
||||
}
|
||||
if (!stats.isDirectory()) return results;
|
||||
|
||||
const stack = [absoluteRoot];
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop();
|
||||
let entries;
|
||||
try {
|
||||
entries = readdirSync(current, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
if (SKIP_DIRECTORY_NAMES.has(entry.name)) continue;
|
||||
stack.push(path.join(current, entry.name));
|
||||
continue;
|
||||
}
|
||||
const absolute = path.join(current, entry.name);
|
||||
const relative = path.relative(repoRoot, absolute).split(path.sep).join("/");
|
||||
if (shouldScanFile(relative)) results.push({ absolute, relative });
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
export function runCheck({ repoRoot, scanRoots = DEFAULT_SCAN_ROOTS, log = console.log, error = console.error } = {}) {
|
||||
const allOffenses = [];
|
||||
|
||||
for (const scanRoot of scanRoots) {
|
||||
const absoluteRoot = path.resolve(repoRoot, scanRoot);
|
||||
const files = collectScannableFiles(absoluteRoot, repoRoot);
|
||||
for (const file of files) {
|
||||
let text;
|
||||
try {
|
||||
text = readFileSync(file.absolute, "utf8");
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const offenses = findGitPushOffenses(text);
|
||||
for (const offense of offenses) {
|
||||
allOffenses.push({ relative: file.relative, ...offense });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (allOffenses.length > 0) {
|
||||
error("ERROR: `git push` (or equivalent remote-mutating git command) found in adapter/runtime code:\n");
|
||||
for (const offense of allOffenses) {
|
||||
error(` ${offense.relative}:${offense.lineNumber}: ${offense.line}`);
|
||||
}
|
||||
error(
|
||||
"\nAdapter and runtime code must not push to a git remote. The local execution-workspace cwd is the only persistence boundary between runs (see packages/adapters/AUTHORING.md and PAPA-432).",
|
||||
);
|
||||
error(
|
||||
`If the operator has explicitly configured a path that must push, add a \`${ALLOW_MARKER}: <reason>\` comment on the matching line or the line immediately above to opt in.`,
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
log(` ✓ No unapproved \`git push\` invocations found in adapter/runtime code.`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
function isMainModule() {
|
||||
return process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
||||
}
|
||||
|
||||
if (isMainModule()) {
|
||||
const repoRoot = process.cwd();
|
||||
process.exit(runCheck({ repoRoot }));
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
ALLOW_MARKER,
|
||||
GIT_PUSH_PATTERN,
|
||||
collectScannableFiles,
|
||||
findGitPushOffenses,
|
||||
runCheck,
|
||||
} from "./check-no-git-push.mjs";
|
||||
|
||||
test("regex matches common git push forms", () => {
|
||||
assert.ok(GIT_PUSH_PATTERN.test("git push"));
|
||||
assert.ok(GIT_PUSH_PATTERN.test("GIT PUSH"));
|
||||
assert.ok(GIT_PUSH_PATTERN.test("git push origin master"));
|
||||
assert.ok(GIT_PUSH_PATTERN.test("git-push"));
|
||||
assert.ok(GIT_PUSH_PATTERN.test("git_push"));
|
||||
});
|
||||
|
||||
test("regex ignores unrelated `push` usages", () => {
|
||||
assert.ok(!GIT_PUSH_PATTERN.test("args.push('git')"));
|
||||
assert.ok(!GIT_PUSH_PATTERN.test("notes.push('git remote')"));
|
||||
assert.ok(!GIT_PUSH_PATTERN.test("pushed"));
|
||||
assert.ok(!GIT_PUSH_PATTERN.test("git fetch"));
|
||||
});
|
||||
|
||||
test("findGitPushOffenses flags a bare invocation in a string", () => {
|
||||
const text = `await exec("git push origin master");\n`;
|
||||
const offenses = findGitPushOffenses(text);
|
||||
assert.equal(offenses.length, 1);
|
||||
assert.equal(offenses[0].lineNumber, 1);
|
||||
});
|
||||
|
||||
test("findGitPushOffenses ignores mentions inside `//` comments", () => {
|
||||
const text = `// sync-back alone — no \`git push\`, no fetch from any origin.\nconst x = 1;\n`;
|
||||
assert.deepEqual(findGitPushOffenses(text), []);
|
||||
});
|
||||
|
||||
test("findGitPushOffenses allows opt-in marker on the same line", () => {
|
||||
const text = `await exec("git push origin master"); // ${ALLOW_MARKER}: operator-configured release mirror\n`;
|
||||
assert.deepEqual(findGitPushOffenses(text), []);
|
||||
});
|
||||
|
||||
test("findGitPushOffenses allows opt-in marker on the line above", () => {
|
||||
const text = `// ${ALLOW_MARKER}: operator-configured release mirror\nawait exec("git push origin master");\n`;
|
||||
assert.deepEqual(findGitPushOffenses(text), []);
|
||||
});
|
||||
|
||||
test("findGitPushOffenses flags string-literal push even when text is split across mixed quotes", () => {
|
||||
const text = "const cmd = `git push --tags`;\n";
|
||||
const offenses = findGitPushOffenses(text);
|
||||
assert.equal(offenses.length, 1);
|
||||
});
|
||||
|
||||
test("findGitPushOffenses flags args-array form passed to spawn/execFile", () => {
|
||||
const cases = [
|
||||
`spawn("git", ["push", "origin", "main"]);\n`,
|
||||
`execFile('git', ['push', '--tags']);\n`,
|
||||
"execFile(`git`, [`push`, `--mirror`]);\n",
|
||||
];
|
||||
for (const text of cases) {
|
||||
const offenses = findGitPushOffenses(text);
|
||||
assert.equal(offenses.length, 1, `expected match for ${text}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("findGitPushOffenses ignores `git push` in a comment after a string ending with a literal backslash", () => {
|
||||
// The closing `"` after `\\` should end the string (even literal count of
|
||||
// backslashes leaves the quote unescaped), so the `// git push` that
|
||||
// follows is comment text and must be stripped.
|
||||
const text = 'const path = "C:\\\\"; // git push origin master\nconst y = 2;\n';
|
||||
assert.deepEqual(findGitPushOffenses(text), []);
|
||||
});
|
||||
|
||||
test("findGitPushOffenses does not flag args-array form when allow marker is present", () => {
|
||||
const text = `// ${ALLOW_MARKER}: release tooling adapter\nspawn("git", ["push", "origin", "main"]);\n`;
|
||||
assert.deepEqual(findGitPushOffenses(text), []);
|
||||
});
|
||||
|
||||
test("runCheck passes when scoped tree has no offenses", () => {
|
||||
const tmpRoot = mkdtempSync(path.join(os.tmpdir(), "no-git-push-pass-"));
|
||||
try {
|
||||
mkdirSync(path.join(tmpRoot, "packages/adapters/sample/src"), { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(tmpRoot, "packages/adapters/sample/src/index.ts"),
|
||||
"export const ok = 1;\n",
|
||||
);
|
||||
const logs = [];
|
||||
const errors = [];
|
||||
const code = runCheck({
|
||||
repoRoot: tmpRoot,
|
||||
scanRoots: ["packages/adapters"],
|
||||
log: (msg) => logs.push(msg),
|
||||
error: (msg) => errors.push(msg),
|
||||
});
|
||||
assert.equal(code, 0);
|
||||
assert.equal(errors.length, 0);
|
||||
} finally {
|
||||
rmSync(tmpRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("runCheck fails when scoped tree contains an unapproved git push", () => {
|
||||
const tmpRoot = mkdtempSync(path.join(os.tmpdir(), "no-git-push-fail-"));
|
||||
try {
|
||||
mkdirSync(path.join(tmpRoot, "packages/adapters/sample/src"), { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(tmpRoot, "packages/adapters/sample/src/index.ts"),
|
||||
"import { execSync } from 'node:child_process';\nexecSync('git push origin main');\n",
|
||||
);
|
||||
const logs = [];
|
||||
const errors = [];
|
||||
const code = runCheck({
|
||||
repoRoot: tmpRoot,
|
||||
scanRoots: ["packages/adapters"],
|
||||
log: (msg) => logs.push(msg),
|
||||
error: (msg) => errors.push(msg),
|
||||
});
|
||||
assert.equal(code, 1);
|
||||
assert.ok(errors.some((line) => line.includes("packages/adapters/sample/src/index.ts:2")));
|
||||
} finally {
|
||||
rmSync(tmpRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("runCheck ignores opt-in marker outside the scoped tree", () => {
|
||||
const tmpRoot = mkdtempSync(path.join(os.tmpdir(), "no-git-push-scope-"));
|
||||
try {
|
||||
mkdirSync(path.join(tmpRoot, "scripts"), { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(tmpRoot, "scripts/release.mjs"),
|
||||
"execSync('git push origin v1.2.3');\n",
|
||||
);
|
||||
const code = runCheck({
|
||||
repoRoot: tmpRoot,
|
||||
scanRoots: ["packages/adapters", "server/src"],
|
||||
log: () => {},
|
||||
error: () => {},
|
||||
});
|
||||
assert.equal(code, 0);
|
||||
} finally {
|
||||
rmSync(tmpRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("collectScannableFiles skips node_modules, dist, and .d.ts", () => {
|
||||
const tmpRoot = mkdtempSync(path.join(os.tmpdir(), "no-git-push-collect-"));
|
||||
try {
|
||||
const adaptersRoot = path.join(tmpRoot, "packages/adapters/sample");
|
||||
mkdirSync(path.join(adaptersRoot, "src"), { recursive: true });
|
||||
mkdirSync(path.join(adaptersRoot, "dist"), { recursive: true });
|
||||
mkdirSync(path.join(adaptersRoot, "node_modules/pkg"), { recursive: true });
|
||||
writeFileSync(path.join(adaptersRoot, "src/index.ts"), "");
|
||||
writeFileSync(path.join(adaptersRoot, "src/types.d.ts"), "");
|
||||
writeFileSync(path.join(adaptersRoot, "dist/index.js"), "");
|
||||
writeFileSync(path.join(adaptersRoot, "node_modules/pkg/index.js"), "");
|
||||
|
||||
const files = collectScannableFiles(
|
||||
path.join(tmpRoot, "packages/adapters"),
|
||||
tmpRoot,
|
||||
);
|
||||
const relatives = files.map((entry) => entry.relative).sort();
|
||||
assert.deepEqual(relatives, ["packages/adapters/sample/src/index.ts"]);
|
||||
} finally {
|
||||
rmSync(tmpRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
import { buildReleasePackagePlan } from "./release-package-map.mjs";
|
||||
|
||||
function normalizePath(filePath) {
|
||||
return filePath.replace(/\\/g, "/");
|
||||
}
|
||||
|
||||
function classifyNpmViewFailure(output) {
|
||||
return /\bE404\b|404 Not Found|could not be found/i.test(output) ? "missing" : "registry_error";
|
||||
}
|
||||
|
||||
function inspectNpmPackage(packageName) {
|
||||
const result = spawnSync("npm", ["view", packageName, "name", "--json"], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
if (result.status === 0) {
|
||||
return { status: "exists" };
|
||||
}
|
||||
|
||||
const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim();
|
||||
const failureType = classifyNpmViewFailure(output);
|
||||
|
||||
if (failureType === "missing") {
|
||||
return { status: "missing" };
|
||||
}
|
||||
|
||||
return {
|
||||
status: "registry_error",
|
||||
detail: output || `npm view exited with status ${result.status ?? "unknown"}`,
|
||||
};
|
||||
}
|
||||
|
||||
function readGitFileAtRevision(revision, filePath) {
|
||||
const result = spawnSync("git", ["show", `${revision}:${normalizePath(filePath)}`], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
if (result.status === 0) {
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim();
|
||||
|
||||
if (
|
||||
/exists on disk, but not in/i.test(output) ||
|
||||
/does not exist in/i.test(output)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw new Error(`failed to read ${filePath} at ${revision}:\n${output || "git show failed"}`);
|
||||
}
|
||||
|
||||
function getBaseReleaseState(
|
||||
revision,
|
||||
releasePackages = buildReleasePackagePlan(),
|
||||
readFileAtRevision = readGitFileAtRevision,
|
||||
) {
|
||||
if (!revision) return null;
|
||||
|
||||
const manifestText = readFileAtRevision(revision, "scripts/release-package-manifest.json");
|
||||
|
||||
if (manifestText) {
|
||||
const manifestEntries = JSON.parse(manifestText);
|
||||
|
||||
if (!Array.isArray(manifestEntries)) {
|
||||
throw new Error(`expected scripts/release-package-manifest.json at ${revision} to contain an array`);
|
||||
}
|
||||
|
||||
return {
|
||||
source: "manifest",
|
||||
byDir: new Map(
|
||||
manifestEntries
|
||||
.filter((entry) => entry?.publishFromCi === true && typeof entry.dir === "string" && typeof entry.name === "string")
|
||||
.map((entry) => [entry.dir, { name: entry.name, publishFromCi: true }]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const byDir = new Map();
|
||||
|
||||
for (const pkg of releasePackages) {
|
||||
const packageJsonText = readFileAtRevision(revision, `${pkg.dir}/package.json`);
|
||||
if (!packageJsonText) continue;
|
||||
|
||||
const basePackage = JSON.parse(packageJsonText);
|
||||
if (basePackage.private) continue;
|
||||
|
||||
byDir.set(pkg.dir, {
|
||||
name: basePackage.name,
|
||||
publishFromCi: true,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
source: "public-packages",
|
||||
byDir,
|
||||
};
|
||||
}
|
||||
|
||||
function collectReleasePackagesForChangedPaths(
|
||||
changedPaths,
|
||||
releasePackages = buildReleasePackagePlan(),
|
||||
baseReleaseState = null,
|
||||
) {
|
||||
const normalizedChangedPaths = changedPaths.map(normalizePath);
|
||||
const manifestFileChanged = normalizedChangedPaths.includes("scripts/release-package-manifest.json");
|
||||
const changedReleasePackages = [];
|
||||
const seen = new Set();
|
||||
|
||||
for (const pkg of releasePackages) {
|
||||
if (!pkg.publishFromCi) continue;
|
||||
const packageJsonPath = `${pkg.dir}/package.json`;
|
||||
const packageJsonChanged = normalizedChangedPaths.includes(packageJsonPath);
|
||||
const basePackage = baseReleaseState?.byDir.get(pkg.dir);
|
||||
const newlyReleaseEnabled =
|
||||
manifestFileChanged &&
|
||||
(!baseReleaseState || !basePackage || basePackage.publishFromCi !== true || basePackage.name !== pkg.name);
|
||||
const isRelevant = packageJsonChanged || newlyReleaseEnabled;
|
||||
|
||||
if (!isRelevant) continue;
|
||||
if (seen.has(pkg.name)) continue;
|
||||
|
||||
changedReleasePackages.push(pkg);
|
||||
seen.add(pkg.name);
|
||||
}
|
||||
|
||||
return changedReleasePackages;
|
||||
}
|
||||
|
||||
function main(changedPaths) {
|
||||
const releasePackages = buildReleasePackagePlan();
|
||||
const baseReleaseState = getBaseReleaseState(process.env.PAPERCLIP_RELEASE_BOOTSTRAP_BASE_SHA, releasePackages);
|
||||
const changedReleasePackages = collectReleasePackagesForChangedPaths(changedPaths, releasePackages, baseReleaseState);
|
||||
|
||||
if (changedReleasePackages.length === 0) {
|
||||
process.stdout.write("No release-enabled package manifests changed in this PR.\n");
|
||||
return;
|
||||
}
|
||||
|
||||
const missingPackages = [];
|
||||
const registryFailures = [];
|
||||
|
||||
for (const pkg of changedReleasePackages) {
|
||||
const npmStatus = inspectNpmPackage(pkg.name);
|
||||
|
||||
if (npmStatus.status === "missing") {
|
||||
missingPackages.push(pkg);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (npmStatus.status === "registry_error") {
|
||||
registryFailures.push({ pkg, detail: npmStatus.detail });
|
||||
}
|
||||
}
|
||||
|
||||
if (missingPackages.length > 0) {
|
||||
const details = missingPackages
|
||||
.map(
|
||||
(pkg) =>
|
||||
`${pkg.name} (${pkg.dir}) is release-enabled but does not exist on npm yet; bootstrap the first publish before merge or keep it out of CI release enrollment`,
|
||||
)
|
||||
.join("\n- ");
|
||||
|
||||
throw new Error(`release package bootstrap check failed:\n- ${details}`);
|
||||
}
|
||||
|
||||
if (registryFailures.length > 0) {
|
||||
const details = registryFailures
|
||||
.map(
|
||||
({ pkg, detail }) =>
|
||||
`${pkg.name} (${pkg.dir}) could not be checked against npm due to a registry error:\n${detail}`,
|
||||
)
|
||||
.join("\n- ");
|
||||
|
||||
throw new Error(`release package bootstrap check could not verify npm state:\n- ${details}`);
|
||||
}
|
||||
|
||||
process.stdout.write(
|
||||
`Release bootstrap OK for changed manifests: ${changedReleasePackages.map((pkg) => pkg.name).join(", ")}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
if (process.argv[1] && normalizePath(process.argv[1]).endsWith("scripts/check-release-package-bootstrap.mjs")) {
|
||||
main(process.argv.slice(2));
|
||||
}
|
||||
|
||||
export {
|
||||
classifyNpmViewFailure,
|
||||
collectReleasePackagesForChangedPaths,
|
||||
getBaseReleaseState,
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
classifyNpmViewFailure,
|
||||
collectReleasePackagesForChangedPaths,
|
||||
getBaseReleaseState,
|
||||
} from "./check-release-package-bootstrap.mjs";
|
||||
|
||||
test("manifest changes without base state validate all release-enabled packages", () => {
|
||||
const releasePackages = [
|
||||
{ dir: "packages/a", name: "@paperclipai/a", publishFromCi: true },
|
||||
{ dir: "packages/b", name: "@paperclipai/b", publishFromCi: true },
|
||||
{ dir: "packages/c", name: "@paperclipai/c", publishFromCi: false },
|
||||
];
|
||||
|
||||
const changedPackages = collectReleasePackagesForChangedPaths(
|
||||
["scripts/release-package-manifest.json"],
|
||||
releasePackages,
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
changedPackages.map((pkg) => pkg.name),
|
||||
["@paperclipai/a", "@paperclipai/b"],
|
||||
);
|
||||
});
|
||||
|
||||
test("manifest changes only validate newly release-enabled packages relative to base state", () => {
|
||||
const releasePackages = [
|
||||
{ dir: "packages/a", name: "@paperclipai/a", publishFromCi: true },
|
||||
{ dir: "packages/b", name: "@paperclipai/b", publishFromCi: true },
|
||||
{ dir: "packages/c", name: "@paperclipai/c", publishFromCi: false },
|
||||
];
|
||||
const baseReleaseState = {
|
||||
source: "manifest",
|
||||
byDir: new Map([["packages/a", { name: "@paperclipai/a", publishFromCi: true }]]),
|
||||
};
|
||||
|
||||
const changedPackages = collectReleasePackagesForChangedPaths(
|
||||
["scripts/release-package-manifest.json"],
|
||||
releasePackages,
|
||||
baseReleaseState,
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
changedPackages.map((pkg) => pkg.name),
|
||||
["@paperclipai/b"],
|
||||
);
|
||||
});
|
||||
|
||||
test("package-specific changes only validate affected release-enabled packages", () => {
|
||||
const releasePackages = [
|
||||
{ dir: "packages/a", name: "@paperclipai/a", publishFromCi: true },
|
||||
{ dir: "packages/b", name: "@paperclipai/b", publishFromCi: true },
|
||||
];
|
||||
|
||||
const changedPackages = collectReleasePackagesForChangedPaths(
|
||||
["packages/b/package.json", "README.md"],
|
||||
releasePackages,
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
changedPackages.map((pkg) => pkg.name),
|
||||
["@paperclipai/b"],
|
||||
);
|
||||
});
|
||||
|
||||
test("npm E404 failures are treated as missing packages", () => {
|
||||
assert.equal(classifyNpmViewFailure("npm error code E404"), "missing");
|
||||
assert.equal(classifyNpmViewFailure("404 Not Found"), "missing");
|
||||
});
|
||||
|
||||
test("non-404 npm failures are treated as registry errors", () => {
|
||||
assert.equal(classifyNpmViewFailure("npm error code EAI_AGAIN"), "registry_error");
|
||||
assert.equal(classifyNpmViewFailure("npm error code E429"), "registry_error");
|
||||
});
|
||||
|
||||
test("base release state falls back to public packages when manifest is absent", () => {
|
||||
const releasePackages = [
|
||||
{ dir: "packages/a", name: "@paperclipai/a", publishFromCi: true },
|
||||
{ dir: "packages/b", name: "@paperclipai/b", publishFromCi: true },
|
||||
];
|
||||
|
||||
const baseReleaseState = getBaseReleaseState("base-sha", releasePackages, (_revision, filePath) => {
|
||||
if (filePath === "scripts/release-package-manifest.json") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (filePath === "packages/a/package.json") {
|
||||
return JSON.stringify({ name: "@paperclipai/a", private: false });
|
||||
}
|
||||
|
||||
if (filePath === "packages/b/package.json") {
|
||||
return JSON.stringify({ name: "@paperclipai/b", private: true });
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
assert.equal(baseReleaseState?.source, "public-packages");
|
||||
assert.deepEqual([...baseReleaseState.byDir.entries()], [
|
||||
["packages/a", { name: "@paperclipai/a", publishFromCi: true }],
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,315 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-token-gates.mjs
|
||||
*
|
||||
* Phase 2 (extraction) DONE-WHEN gate check for the design-token-extraction
|
||||
* run (branch design/token-extraction; see DESIGN.md, GOAL-PROMPT.md,
|
||||
* TOKEN-AUDIT.md). Scans `ui/src/components/**` and `ui/src/pages/**`
|
||||
* (excluding `ui/src/lib|context|plugins`, which are explicitly out of
|
||||
* scope for this run per TOKEN-AUDIT.md's Batch 4 log) for three gates:
|
||||
*
|
||||
* Gate 1 — zero hardcoded COLOR LITERALS: hex colors (#fff, #ffffff,
|
||||
* #ffffffff) and rgb()/rgba()/hsl()/hsla()/oklch() value literals
|
||||
* (i.e. NOT a var() reference, and not merely referencing a CSS
|
||||
* variable inside one of those functions, e.g. hsl(var(--primary)) is
|
||||
* fine — only a literal numeric color argument fails the gate).
|
||||
*
|
||||
* Gate 2 — zero VALUE-BEARING arbitrary Tailwind bracket utilities:
|
||||
* bracket contents (`utility-[...]`) that carry a rendered CSS value
|
||||
* (digits with CSS units, bare numbers, color literals, or CSS value
|
||||
* functions like calc()/min()/max()/clamp()/var()/linear-gradient()/
|
||||
* cubic-bezier()/rgba()/env()). This is checked on the UTILITY
|
||||
* position, i.e. `word-[...]` where `word` is not itself a selector/
|
||||
* variant keyword.
|
||||
*
|
||||
* SELECTOR/VARIANT BRACKETS ARE EXCLUDED BY DEFINITION, not by
|
||||
* omission: `data-[...]`, `group-data-[...]`, `has-[...]`,
|
||||
* `group-has-data-[...]`, `aria-[...]`, `supports-[...]`, and
|
||||
* `max-[...]`/`min-[...]` used as a BREAKPOINT VARIANT PREFIX (i.e.
|
||||
* immediately followed by `:`, such as `max-[480px]:hidden`) are CSS
|
||||
* SELECTOR CONDITIONS or responsive variant prefixes, not visual
|
||||
* values applied to a property — they describe WHEN a rule applies,
|
||||
* not WHAT value it sets. A variant's bracket cannot reference a CSS
|
||||
* custom property (Tailwind resolves variants at build time, before
|
||||
* any `var()` could be evaluated), so there is nothing to tokenize;
|
||||
* tokenizing would require changing Tailwind's own variant syntax,
|
||||
* which is out of scope. These are recognized structurally: a
|
||||
* bracket immediately followed by `:` (not part of a class string's
|
||||
* trailing utility) is a variant, not a utility value.
|
||||
*
|
||||
* True exceptions that DO carry a value but cannot be tokenized are
|
||||
* ALLOWLISTED, not silently excluded (see ALLOWLIST parsing below):
|
||||
* `max-[480px]`/`min-[420px]` breakpoint variants (variant position
|
||||
* cannot reference a var), and `rounded-[inherit]` (a CSS-wide
|
||||
* keyword, not a literal value, cannot come from a custom property).
|
||||
*
|
||||
* Gate 3 — zero raw FONT-SIZE declarations: `text-[Npx]`/`text-[N.Nrem]`
|
||||
* Tailwind arbitrary font-size utilities (a subset of gate 2, checked
|
||||
* explicitly since font-size is its own DESIGN.md-named category) and
|
||||
* `fontSize: "..."` / `font-size:` string-literal declarations in
|
||||
* inline styles or css-in-js.
|
||||
*
|
||||
* The ALLOWLIST is parsed from the machine-readable block in
|
||||
* ui/src/index.css (search for "── ALLOWLIST" below it), one entry per
|
||||
* line in the form:
|
||||
* * allow <repo-relative-path> — <reason>
|
||||
* A violation at a path is suppressed if the path CONTAINS (substring
|
||||
* match) any allowlisted path. This intentionally allowlists the whole
|
||||
* file for simplicity/reviewability, matching how Batches 1-3 allowlisted
|
||||
* entire sites' surrounding functional code rather than individual
|
||||
* characters.
|
||||
*
|
||||
* Exit code: 0 if all three gates are clean (prints a per-gate summary).
|
||||
* Exit code: 1 if any gate has violations (lists them, grouped by gate).
|
||||
*
|
||||
* Usage: node scripts/check-token-gates.mjs
|
||||
*/
|
||||
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { resolve, dirname, join, relative } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = resolve(__dirname, "..");
|
||||
const UI_SRC = resolve(REPO_ROOT, "ui/src");
|
||||
const SCAN_DIRS = ["components", "pages"];
|
||||
const CSS_PATH = resolve(UI_SRC, "index.css");
|
||||
|
||||
// ── Allowlist parsing ────────────────────────────────────────────────────
|
||||
// Reads the machine-readable "* allow <path> — <reason>" lines from the
|
||||
// ALLOWLIST block in ui/src/index.css. Tolerant of either em-dash (—) or
|
||||
// a plain hyphen-minus as the path/reason separator, and of the historical
|
||||
// per-batch prose blocks NOT being in this format (they are not parsed;
|
||||
// only lines starting with "* allow " are).
|
||||
function loadAllowlist(cssPath) {
|
||||
const css = readFileSync(cssPath, "utf8");
|
||||
const entries = [];
|
||||
const lineRe = /^\s*\*\s*allow\s+(\S+)\s+(?:—|-{1,2})\s*(.*)$/;
|
||||
for (const rawLine of css.split("\n")) {
|
||||
const m = rawLine.match(lineRe);
|
||||
if (m) {
|
||||
entries.push({ path: m[1], reason: m[2].trim() });
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function isAllowlisted(relPath, allowlist) {
|
||||
return allowlist.some((entry) => relPath.includes(entry.path));
|
||||
}
|
||||
|
||||
// ── File walking ─────────────────────────────────────────────────────────
|
||||
function walk(dir, out) {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const p = join(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(p, out);
|
||||
else if (/\.(tsx?|jsx?)$/.test(entry.name)) out.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
function listFiles() {
|
||||
const files = [];
|
||||
for (const dir of SCAN_DIRS) walk(resolve(UI_SRC, dir), files);
|
||||
files.sort();
|
||||
return files;
|
||||
}
|
||||
|
||||
// ── Gate 1: color literals ───────────────────────────────────────────────
|
||||
// Hex colors: #abc, #aabbcc, #aabbccdd — word-boundary guarded so it
|
||||
// doesn't match inside identifiers, and NOT preceded by another hex digit
|
||||
// (avoids over-matching truncated substrings of longer non-color tokens,
|
||||
// though `#` itself is a strong enough anchor in practice).
|
||||
// A genuine CSS hex color is never glued directly to an identifier
|
||||
// character (letter/digit/underscore) or `/` immediately before the `#` —
|
||||
// that shape is an issue/PR reference like "acme/web#241" or "acme/web#12"
|
||||
// (Batch 1's codemod header documented this exact false-positive risk for
|
||||
// its own hex-literal sweep; the same guard applies here). A real color
|
||||
// literal is preceded by a delimiter (quote, colon, paren, comma,
|
||||
// whitespace, backtick, template `${`) or sits at the start of the string.
|
||||
const HEX_COLOR_RE = /(?<![a-zA-Z0-9_/])#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})\b/g;
|
||||
|
||||
// rgb()/rgba()/hsl()/hsla()/oklch() with a LITERAL first argument (a digit,
|
||||
// a `.` decimal, or a `%` — i.e. not `var(` or `calc(` immediately inside).
|
||||
// `hsl(var(--x)/0.16)` must NOT match (var() reference); `rgba(0,0,0,0.5)`
|
||||
// MUST match (literal numeric channels).
|
||||
const COLOR_FN_LITERAL_RE = /\b(?:rgb|rgba|hsl|hsla|oklch)\(\s*(?!var\()[0-9.%-]/g;
|
||||
|
||||
function findColorLiteralIssues(content) {
|
||||
const issues = [];
|
||||
for (const m of content.matchAll(HEX_COLOR_RE)) {
|
||||
issues.push({ index: m.index, snippet: m[0] });
|
||||
}
|
||||
for (const m of content.matchAll(COLOR_FN_LITERAL_RE)) {
|
||||
issues.push({ index: m.index, snippet: m[0] });
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
// ── Gate 2: value-bearing arbitrary bracket utilities ───────────────────
|
||||
// Matches `word-[content]` (optionally prefixed by `!`, and optionally
|
||||
// preceded by a Tailwind variant chain like `sm:` / `dark:` / `hover:` /
|
||||
// `data-[state=open]:` etc. — the regex only needs to find the utility's
|
||||
// OWN bracket, not parse the whole variant chain, since VARIANT_KEYWORDS
|
||||
// below excludes variant-shaped words directly at the match site).
|
||||
//
|
||||
// A bracket is a VARIANT (excluded by definition, see header) if:
|
||||
// (a) the word immediately before `-[` is one of the known variant
|
||||
// keywords (data, group-data, has, group-has-data, aria, supports,
|
||||
// group-aria, peer-data, peer-aria, in, not), OR
|
||||
// (b) the bracket is immediately followed by `:` (a breakpoint-style
|
||||
// variant prefix, e.g. `max-[480px]:hidden` — the `:` right after
|
||||
/// `]` is the structural signal that this bracket is a CONDITION,
|
||||
// not a value).
|
||||
const BRACKET_RE = /(!?)([a-zA-Z][a-zA-Z0-9-]*)-\[([^\[\]]*)\]/g;
|
||||
|
||||
const VARIANT_WORD_RE =
|
||||
/(?:^|[\s"'`{])(?:group-|peer-)?(?:data|has|aria|supports|in|not)(?:-[a-zA-Z0-9]+)*$/;
|
||||
|
||||
// A bracket carries a VALUE (not just a keyword/selector fragment) if its
|
||||
// content looks like: a number (optionally with a CSS unit or %), a CSS
|
||||
// color literal (# hex or a color function), OR a known CSS value function
|
||||
// call (calc/min/max/clamp/var/env/linear-gradient/radial-gradient/
|
||||
// conic-gradient/cubic-bezier/rgba/rgb/hsl/hsla/oklch). Pure CSS KEYWORDS
|
||||
// (e.g. `inherit`, `auto`, `pointer`) do NOT match and are not gated here
|
||||
// (they're a separate, allowlisted concern — see `rounded-[inherit]`).
|
||||
const VALUE_UNIT_RE = /^-?[0-9.]+(?:px|rem|em|vh|vw|dvh|dvw|svh|svw|ch|%|deg|s|ms|fr)?$/;
|
||||
const VALUE_FUNC_RE =
|
||||
/^(?:calc|min|max|clamp|var|env|linear-gradient|radial-gradient|conic-gradient|cubic-bezier|rgba?|hsla?|oklch|color-mix)\(/;
|
||||
const HEX_ONLY_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
|
||||
|
||||
function bracketCarriesValue(raw) {
|
||||
const trimmed = raw.trim();
|
||||
if (VALUE_UNIT_RE.test(trimmed)) return true;
|
||||
if (HEX_ONLY_RE.test(trimmed)) return true;
|
||||
if (VALUE_FUNC_RE.test(trimmed)) return true;
|
||||
// A bracket containing an embedded CSS value function anywhere (e.g. a
|
||||
// grid track list `56px_56px_24px_minmax(0,1fr)` that doesn't itself
|
||||
// start with one of the above, or `translate-y-[-50%]`-style negative
|
||||
// percentages already covered by VALUE_UNIT_RE) also counts.
|
||||
if (/[0-9](?:px|rem|em|vh|vw|dvh|dvw|svh|svw|ch|%|deg|fr)\b/.test(trimmed)) return true;
|
||||
if (/\b(?:calc|min|max|clamp|var|env|linear-gradient|radial-gradient|conic-gradient|cubic-bezier|rgba?|hsla?|oklch|color-mix)\(/.test(trimmed)) return true;
|
||||
if (HEX_COLOR_RE.test(trimmed)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function findArbitraryBracketIssues(content) {
|
||||
const issues = [];
|
||||
for (const m of content.matchAll(BRACKET_RE)) {
|
||||
const [full, , word, raw] = m;
|
||||
const matchEnd = m.index + full.length;
|
||||
const followedByColon = content[matchEnd] === ":";
|
||||
if (followedByColon) continue; // breakpoint/arbitrary-variant prefix, not a utility value
|
||||
|
||||
// Reject if `word` itself IS (or ends in) a variant keyword shape, e.g.
|
||||
// a match that accidentally captured "...data" as the utility name for
|
||||
// some malformed/edge case. In practice BRACKET_RE's utility-name
|
||||
// capture group only ever contains real utility names (data-[...] etc.
|
||||
// are matched with `word` = "data", "group-data", "has", etc.).
|
||||
const precedingContext = content.slice(Math.max(0, m.index - 1), m.index + word.length + 1);
|
||||
if (VARIANT_WORD_RE.test(precedingContext)) continue;
|
||||
if (/^(?:data|has|aria|supports|group-data|group-has-data|group-aria|peer-data|peer-aria|group-has-data-slot|in|not)$/.test(word)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!raw.includes("[") && bracketCarriesValue(raw)) {
|
||||
issues.push({ index: m.index, snippet: `${word}-[${raw}]` });
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
// ── Gate 3: raw font-size declarations ──────────────────────────────────
|
||||
const FONT_SIZE_CLASS_RE = /\btext-\[(?:[0-9.]+(?:px|rem|em)|[0-9.]+\/[0-9.]+)\]/g;
|
||||
// A raw literal font-size value: starts with a digit (px/rem/em number) —
|
||||
// EXCLUDES `fontSize: "var(--text-micro)"`-style token references, which start
|
||||
// with `var(` and are the desired post-extraction form, not a violation.
|
||||
const FONT_SIZE_INLINE_RE = /\bfontSize\s*:\s*["'][0-9][^"']*["']/g;
|
||||
const FONT_SIZE_CSS_PROP_RE = /(?<!-)\bfont-size\s*:\s*["'`][0-9][^"'`]*["'`]/g;
|
||||
|
||||
function findFontSizeIssues(content) {
|
||||
const issues = [];
|
||||
for (const m of content.matchAll(FONT_SIZE_CLASS_RE)) {
|
||||
issues.push({ index: m.index, snippet: m[0] });
|
||||
}
|
||||
for (const m of content.matchAll(FONT_SIZE_INLINE_RE)) {
|
||||
issues.push({ index: m.index, snippet: m[0] });
|
||||
}
|
||||
for (const m of content.matchAll(FONT_SIZE_CSS_PROP_RE)) {
|
||||
issues.push({ index: m.index, snippet: m[0] });
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
function lineNumberAt(content, index) {
|
||||
return content.slice(0, index).split("\n").length;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const allowlist = loadAllowlist(CSS_PATH);
|
||||
const files = listFiles();
|
||||
|
||||
const violations = { gate1: [], gate2: [], gate3: [] };
|
||||
let allowlistedSkips = 0;
|
||||
|
||||
for (const filePath of files) {
|
||||
const content = readFileSync(filePath, "utf8");
|
||||
const relPathPosix = relPathToPosix(filePath);
|
||||
|
||||
const allowed = isAllowlisted(relPathPosix, allowlist);
|
||||
|
||||
const g1 = findColorLiteralIssues(content);
|
||||
const g2 = findArbitraryBracketIssues(content);
|
||||
const g3 = findFontSizeIssues(content);
|
||||
|
||||
if (allowed) {
|
||||
allowlistedSkips += g1.length + g2.length + g3.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const issue of g1) {
|
||||
violations.gate1.push({ file: relPathPosix, line: lineNumberAt(content, issue.index), snippet: issue.snippet });
|
||||
}
|
||||
for (const issue of g2) {
|
||||
violations.gate2.push({ file: relPathPosix, line: lineNumberAt(content, issue.index), snippet: issue.snippet });
|
||||
}
|
||||
for (const issue of g3) {
|
||||
violations.gate3.push({ file: relPathPosix, line: lineNumberAt(content, issue.index), snippet: issue.snippet });
|
||||
}
|
||||
}
|
||||
|
||||
const totalViolations = violations.gate1.length + violations.gate2.length + violations.gate3.length;
|
||||
|
||||
console.log("check-token-gates summary");
|
||||
console.log(` Files scanned: ${files.length}`);
|
||||
console.log(` Allowlist entries loaded: ${allowlist.length}`);
|
||||
console.log(` Allowlisted issues skipped: ${allowlistedSkips}`);
|
||||
console.log("");
|
||||
console.log(` Gate 1 (color literals): ${violations.gate1.length === 0 ? "CLEAN" : `${violations.gate1.length} violation(s)`}`);
|
||||
console.log(` Gate 2 (arbitrary bracket vals): ${violations.gate2.length === 0 ? "CLEAN" : `${violations.gate2.length} violation(s)`}`);
|
||||
console.log(` Gate 3 (raw font-size): ${violations.gate3.length === 0 ? "CLEAN" : `${violations.gate3.length} violation(s)`}`);
|
||||
|
||||
if (totalViolations > 0) {
|
||||
console.log("\nViolations:\n");
|
||||
for (const [gateName, list] of Object.entries(violations)) {
|
||||
if (list.length === 0) continue;
|
||||
console.log(`── ${gateName} ──`);
|
||||
for (const v of list) {
|
||||
console.log(` ${v.file}:${v.line} ${v.snippet}`);
|
||||
}
|
||||
console.log("");
|
||||
}
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("\nAll gates clean.");
|
||||
process.exitCode = 0;
|
||||
}
|
||||
|
||||
// Windows path separators never appear in this repo's CI, but keep relative
|
||||
// paths POSIX-style for allowlist substring matching regardless of platform.
|
||||
function relPathToPosix(filePath) {
|
||||
return ("ui/src/" + relative(UI_SRC, filePath)).split("\\").join("/");
|
||||
}
|
||||
|
||||
main();
|
||||
Executable
+14
@@ -0,0 +1,14 @@
|
||||
#!/bin/bash -ex
|
||||
export PC_TEST_ROOT="$(mktemp -d /tmp/paperclip-clean.XXXXXX)"
|
||||
export PC_HOME="$PC_TEST_ROOT/home"
|
||||
export PC_CACHE="$PC_TEST_ROOT/npm-cache"
|
||||
export PC_DATA="$PC_TEST_ROOT/paperclip-data"
|
||||
mkdir -p "$PC_HOME" "$PC_CACHE" "$PC_DATA"
|
||||
echo "PC_TEST_ROOT: $PC_TEST_ROOT"
|
||||
echo "PC_HOME: $PC_HOME"
|
||||
cd $PC_TEST_ROOT
|
||||
git clone https://github.com/paperclipai/paperclip.git repo
|
||||
cd repo
|
||||
pnpm install
|
||||
env HOME="$PC_HOME" npm_config_cache="$PC_CACHE" npm_config_userconfig="$PC_HOME/.npmrc" \
|
||||
pnpm paperclipai onboard --yes --data-dir "$PC_DATA"
|
||||
Executable
+13
@@ -0,0 +1,13 @@
|
||||
#!/bin/bash -ex
|
||||
export PC_TEST_ROOT="$(mktemp -d /tmp/paperclip-clean.XXXXXX)"
|
||||
export PC_HOME="$PC_TEST_ROOT/home"
|
||||
export PC_CACHE="$PC_TEST_ROOT/npm-cache"
|
||||
export PC_DATA="$PC_TEST_ROOT/paperclip-data"
|
||||
mkdir -p "$PC_HOME" "$PC_CACHE" "$PC_DATA"
|
||||
echo "PC_TEST_ROOT: $PC_TEST_ROOT"
|
||||
echo "PC_HOME: $PC_HOME"
|
||||
cd $PC_TEST_ROOT
|
||||
env HOME="$PC_HOME" \
|
||||
npm_config_cache="$PC_CACHE" \
|
||||
npm_config_userconfig="$PC_HOME/.npmrc" \
|
||||
npx --yes paperclipai onboard --yes --data-dir "$PC_DATA"
|
||||
Executable
+86
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
TARGET_REF="${1:-HEAD}"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
./scripts/clean-onboard-ref.sh [git-ref]
|
||||
|
||||
Examples:
|
||||
./scripts/clean-onboard-ref.sh
|
||||
./scripts/clean-onboard-ref.sh HEAD
|
||||
./scripts/clean-onboard-ref.sh v0.2.7
|
||||
|
||||
Environment overrides:
|
||||
KEEP_TEMP=1 Keep the temp directory and detached worktree for debugging
|
||||
PC_TEST_ROOT=/tmp/custom Base temp directory to use
|
||||
PC_DATA=/tmp/data Paperclip data dir to use
|
||||
PAPERCLIP_HOST=127.0.0.1 Host passed to the onboarded server
|
||||
PAPERCLIP_PORT=3232 Port passed to the onboarded server
|
||||
|
||||
Notes:
|
||||
- Defaults to the current committed ref (HEAD), not uncommitted local edits.
|
||||
- Creates an isolated temp HOME, npm cache, data dir, and detached git worktree.
|
||||
EOF
|
||||
}
|
||||
|
||||
if [ $# -gt 1 ]; then
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ $# -eq 1 ] && [[ "$1" =~ ^(-h|--help)$ ]]; then
|
||||
usage
|
||||
exit 0
|
||||
fi
|
||||
|
||||
TARGET_COMMIT="$(git -C "$REPO_ROOT" rev-parse --verify "${TARGET_REF}^{commit}")"
|
||||
|
||||
export KEEP_TEMP="${KEEP_TEMP:-0}"
|
||||
export PC_TEST_ROOT="${PC_TEST_ROOT:-$(mktemp -d /tmp/paperclip-clean-ref.XXXXXX)}"
|
||||
export PC_HOME="${PC_HOME:-$PC_TEST_ROOT/home}"
|
||||
export PC_CACHE="${PC_CACHE:-$PC_TEST_ROOT/npm-cache}"
|
||||
export PC_DATA="${PC_DATA:-$PC_TEST_ROOT/paperclip-data}"
|
||||
export PC_REPO="${PC_REPO:-$PC_TEST_ROOT/repo}"
|
||||
export PAPERCLIP_HOST="${PAPERCLIP_HOST:-127.0.0.1}"
|
||||
export PAPERCLIP_PORT="${PAPERCLIP_PORT:-3100}"
|
||||
export PAPERCLIP_OPEN_ON_LISTEN="${PAPERCLIP_OPEN_ON_LISTEN:-false}"
|
||||
|
||||
cleanup() {
|
||||
if [ "$KEEP_TEMP" = "1" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
git -C "$REPO_ROOT" worktree remove --force "$PC_REPO" >/dev/null 2>&1 || true
|
||||
rm -rf "$PC_TEST_ROOT"
|
||||
}
|
||||
|
||||
trap cleanup EXIT
|
||||
|
||||
mkdir -p "$PC_HOME" "$PC_CACHE" "$PC_DATA"
|
||||
|
||||
echo "TARGET_REF: $TARGET_REF"
|
||||
echo "TARGET_COMMIT: $TARGET_COMMIT"
|
||||
echo "PC_TEST_ROOT: $PC_TEST_ROOT"
|
||||
echo "PC_HOME: $PC_HOME"
|
||||
echo "PC_DATA: $PC_DATA"
|
||||
echo "PC_REPO: $PC_REPO"
|
||||
echo "PAPERCLIP_HOST: $PAPERCLIP_HOST"
|
||||
echo "PAPERCLIP_PORT: $PAPERCLIP_PORT"
|
||||
|
||||
git -C "$REPO_ROOT" worktree add --detach "$PC_REPO" "$TARGET_COMMIT"
|
||||
|
||||
cd "$PC_REPO"
|
||||
pnpm install
|
||||
|
||||
env \
|
||||
HOME="$PC_HOME" \
|
||||
npm_config_cache="$PC_CACHE" \
|
||||
npm_config_userconfig="$PC_HOME/.npmrc" \
|
||||
HOST="$PAPERCLIP_HOST" \
|
||||
PORT="$PAPERCLIP_PORT" \
|
||||
PAPERCLIP_OPEN_ON_LISTEN="$PAPERCLIP_OPEN_ON_LISTEN" \
|
||||
pnpm paperclipai onboard --yes --data-dir "$PC_DATA"
|
||||
@@ -0,0 +1,470 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* codemod-extract-colors.mjs
|
||||
*
|
||||
* Phase 2 (extraction), Batch 1/4 of the design-token audit
|
||||
* (branch design/token-extraction). Replaces hardcoded COLOR literals
|
||||
* (hex / rgb() / rgba() / hsl() / hsla() / oklch()) in Tailwind class
|
||||
* strings and inline style objects, in `ui/src/components/**` and
|
||||
* `ui/src/pages/**` (including their *.test.tsx companions), with
|
||||
* references to CSS custom-property tokens defined in `ui/src/index.css`.
|
||||
*
|
||||
* Scope is deliberately a fixed, manually-audited SITE TABLE rather than a
|
||||
* blind hex-matching regex sweep: a generic `#[0-9a-f]{3,8}` regex produces
|
||||
* false positives on this codebase (issue references like "acme/web#241",
|
||||
* PR/comment numbers like "React #10140", etc.). Every entry below was
|
||||
* verified by hand against TOKEN-AUDIT.md section 1 (see repo root) to
|
||||
* confirm it is (a) a real color value, (b) consumed as a rendered CSS
|
||||
* value (not fed into contrast math, canvas painting, or persisted /
|
||||
* compared JS state), and (c) safe to swap for `var(--token)` without any
|
||||
* visual difference.
|
||||
*
|
||||
* Idempotent: every site's `find` string is the ORIGINAL literal-bearing
|
||||
* form; once rewritten the file no longer contains that string, so
|
||||
* re-running the script is a no-op (each replace() call only fires if the
|
||||
* exact original substring is still present).
|
||||
*
|
||||
* Usage: node scripts/codemod-extract-colors.mjs [--check]
|
||||
* --check Report what WOULD change without writing files (dry run).
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { resolve, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = resolve(__dirname, "..");
|
||||
const UI_SRC = resolve(REPO_ROOT, "ui/src");
|
||||
|
||||
const DRY_RUN = process.argv.includes("--check");
|
||||
|
||||
/**
|
||||
* Token table — every new token minted by this batch, value VERBATIM.
|
||||
* `name` is the CSS custom-property name (without leading --).
|
||||
* `value` is the exact literal value from the source site.
|
||||
* `comment` documents where it came from / why, emitted above the
|
||||
* declaration in ui/src/index.css.
|
||||
*/
|
||||
const NEW_TOKENS = [
|
||||
// --- Tailwind bracket color-class hex sites (section: bracket hex) ---
|
||||
{ name: "hex-959596", value: "#959596", comment: "Muted feed actor/verb/title text (ActivityFeed.tsx, FeedCard.tsx) — PRIOR-ART-flagged gap cluster, no existing token match." },
|
||||
{ name: "hex-1d1d1d", value: "#1d1d1d", comment: "OnboardingWizard.tsx dark decorative panel background, singleton." },
|
||||
|
||||
// --- OrgChart.tsx status dot colors (independent from --status-agent-* hues) ---
|
||||
{ name: "hex-22d3ee", value: "#22d3ee", comment: "OrgChart.tsx agent status dot — 'running' (independent palette from --status-agent-*, see TOKEN-AUDIT.md 1.2)." },
|
||||
{ name: "hex-4ade80", value: "#4ade80", comment: "OrgChart.tsx agent status dot — 'active'." },
|
||||
{ name: "hex-facc15", value: "#facc15", comment: "OrgChart.tsx agent status dot — 'paused' / 'idle' (shared value)." },
|
||||
{ name: "hex-f87171", value: "#f87171", comment: "OrgChart.tsx agent status dot — 'error'." },
|
||||
{ name: "hex-a3a3a3", value: "#a3a3a3", comment: "OrgChart.tsx agent status dot — 'terminated' + defaultDotColor fallback." },
|
||||
|
||||
// --- ActivityCharts.tsx priority + status color maps (independent palette, see TOKEN-AUDIT.md 1.2) ---
|
||||
{ name: "hex-ef4444", value: "#ef4444", comment: "ActivityCharts.tsx — priority 'critical' + status 'blocked' (shared value); also the <0.5 success-rate bar tint." },
|
||||
{ name: "hex-f97316", value: "#f97316", comment: "ActivityCharts.tsx — priority 'high'." },
|
||||
{ name: "hex-eab308", value: "#eab308", comment: "ActivityCharts.tsx — priority 'medium'; also the 0.5-0.8 success-rate bar tint." },
|
||||
{ name: "hex-6b7280", value: "#6b7280", comment: "ActivityCharts.tsx — priority 'low' + status 'cancelled' + statusColors fallback (shared value)." },
|
||||
{ name: "hex-3b82f6", value: "#3b82f6", comment: "ActivityCharts.tsx — status 'todo' (independent from --status-task-todo which is #f59e0b)." },
|
||||
{ name: "hex-8b5cf6", value: "#8b5cf6", comment: "ActivityCharts.tsx — status 'in_progress' (independent from --status-task-in_progress which is #2563eb — flagged inconsistency, TOKEN-AUDIT.md 1.2)." },
|
||||
{ name: "hex-a855f7", value: "#a855f7", comment: "ActivityCharts.tsx — status 'in_review'." },
|
||||
{ name: "hex-10b981", value: "#10b981", comment: "ActivityCharts.tsx — status 'done'; also the >=0.8 success-rate bar tint." },
|
||||
{ name: "hex-64748b", value: "#64748b", comment: "ActivityCharts.tsx status 'backlog'; also the widely-repeated 'no project assigned' muted-slate fallback color (TOKEN-AUDIT.md 1.3) across Routines/MarkdownEditor/RoutineRunVariablesDialog/RoutineList/IssueColumns/editable-sections." },
|
||||
|
||||
// --- Project-color-fallback indigo cluster (TOKEN-AUDIT.md 1.3) ---
|
||||
{ name: "hex-6366f1", value: "#6366f1", comment: "Project-color-fallback indigo seed default (ProjectDetail/PipelineSettings/IssueProperties/NewIssueDialog) — new-project-color-picker-seed family per TOKEN-AUDIT.md 1.3." },
|
||||
|
||||
// --- Gradient tokens (verbatim, one per distinct gradient string; DESIGN.md: mint, don't collapse) ---
|
||||
{ name: "gradient-extract-1", value: "linear-gradient(180deg,rgba(255,80,80,0.12),rgba(255,255,255,0.02))", comment: "Dashboard.tsx budget-alert card gradient." },
|
||||
{ name: "gradient-extract-2", value: "linear-gradient(180deg,rgba(255,255,255,0.05),rgba(255,255,255,0.02))", comment: "Costs.tsx subtle white card gradient." },
|
||||
{ name: "gradient-extract-3", value: "radial-gradient(circle at top left,rgba(244,114,182,0.08),transparent 35%),radial-gradient(circle at bottom right,rgba(56,189,248,0.1),transparent 32%)", comment: "AccountingModelCard.tsx decorative overlay." },
|
||||
{ name: "gradient-extract-4", value: "linear-gradient(180deg,rgba(255,70,70,0.10),rgba(255,255,255,0.02))", comment: "BudgetIncidentCard.tsx incident-card gradient." },
|
||||
{ name: "gradient-extract-5", value: "radial-gradient(circle at top left,rgba(8,145,178,0.08),transparent 36%),radial-gradient(circle at bottom right,rgba(245,158,11,0.10),transparent 28%)", comment: "RunTranscriptUxLab.tsx:78 hero gradient." },
|
||||
{ name: "gradient-extract-6", value: "linear-gradient(135deg,rgba(8,145,178,0.08),transparent 28%),linear-gradient(180deg,rgba(245,158,11,0.08),transparent 40%),var(--background)", comment: "RunTranscriptUxLab.tsx:203 hero-card gradient." },
|
||||
{ name: "gradient-extract-7", value: "radial-gradient(circle at top right,rgba(255,255,255,0.22),transparent 34%),radial-gradient(circle at bottom left,rgba(255,255,255,0.08),transparent 36%)", comment: "ProfileSettings.tsx:162 decorative overlay." },
|
||||
{ name: "gradient-extract-8", value: "radial-gradient(circle at top,rgba(8,145,178,0.18),transparent 48%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,1))", comment: "InviteUxLab.tsx:510 dark hero gradient." },
|
||||
{ name: "gradient-extract-9", value: "linear-gradient(135deg,rgba(8,145,178,0.10),transparent 28%),linear-gradient(180deg,rgba(245,158,11,0.10),transparent 44%),var(--background)", comment: "IssueChatUxLab.tsx:139 + InviteUxLab.tsx:700 hero-card gradient (identical string, 2 sites)." },
|
||||
{ name: "gradient-extract-10", value: "linear-gradient(180deg,rgba(168,85,247,0.06),transparent 28%),var(--background)", comment: "IssueChatUxLab.tsx:203 + InviteUxLab.tsx:909 accent gradient (identical string, 2 sites)." },
|
||||
{ name: "gradient-extract-11", value: "linear-gradient(180deg,rgba(16,185,129,0.06),transparent 28%),var(--background)", comment: "IssueChatUxLab.tsx:226 accent gradient." },
|
||||
{ name: "gradient-extract-12", value: "linear-gradient(180deg,rgba(6,182,212,0.05),transparent 28%),var(--background)", comment: "IssueChatUxLab.tsx:263 accent gradient." },
|
||||
{ name: "gradient-extract-13", value: "linear-gradient(180deg,rgba(59,130,246,0.06),transparent 28%),var(--background)", comment: "IssueChatUxLab.tsx:294 accent gradient." },
|
||||
{ name: "gradient-extract-14", value: "linear-gradient(180deg,rgba(168,85,247,0.05),transparent 26%),var(--background)", comment: "IssueChatUxLab.tsx:315 accent gradient." },
|
||||
{ name: "gradient-extract-15", value: "linear-gradient(180deg,rgba(245,158,11,0.08),transparent 26%),var(--background)", comment: "IssueChatUxLab.tsx:339 accent gradient." },
|
||||
{ name: "gradient-extract-16", value: "linear-gradient(135deg,rgba(245,158,11,0.10),transparent 28%),linear-gradient(180deg,rgba(8,145,178,0.08),transparent 44%),var(--background)", comment: "SystemNoticeUxLab.tsx:140 hero-card gradient." },
|
||||
{ name: "gradient-extract-17", value: "linear-gradient(180deg,rgba(245,158,11,0.05),transparent 28%),var(--background)", comment: "SystemNoticeUxLab.tsx:193 accent gradient." },
|
||||
{ name: "gradient-extract-18", value: "linear-gradient(180deg,rgba(8,145,178,0.05),transparent 28%),var(--background)", comment: "SystemNoticeUxLab.tsx:225 accent gradient." },
|
||||
{ name: "gradient-extract-19", value: "linear-gradient(180deg,rgba(244,63,94,0.05),transparent 28%),var(--background)", comment: "SystemNoticeUxLab.tsx:289 accent gradient." },
|
||||
{ name: "gradient-extract-20", value: "linear-gradient(180deg,rgba(16,185,129,0.05),transparent 28%),var(--background)", comment: "SystemNoticeUxLab.tsx:331 accent gradient." },
|
||||
{ name: "gradient-extract-21", value: "linear-gradient(180deg,rgba(59,130,246,0.05),transparent 30%),var(--background)", comment: "InviteUxLab.tsx:753 accent gradient." },
|
||||
{ name: "gradient-extract-22", value: "linear-gradient(180deg,rgba(234,179,8,0.06),transparent 28%),var(--background)", comment: "InviteUxLab.tsx:807 accent gradient." },
|
||||
{ name: "gradient-extract-23", value: "linear-gradient(180deg,rgba(16,185,129,0.06),transparent 30%),var(--background)", comment: "InviteUxLab.tsx:884 accent gradient." },
|
||||
{ name: "gradient-extract-24", value: "linear-gradient(180deg,rgba(244,114,182,0.06),transparent 28%),var(--background)", comment: "InviteUxLab.tsx:921 accent gradient." },
|
||||
];
|
||||
|
||||
/**
|
||||
* Existing-token reuse map — hardcoded value -> existing index.css token,
|
||||
* used ONLY where the value exact-matches (case-insensitive) a token whose
|
||||
* value is IDENTICAL in :root and .dark (mode-independent brand tier per
|
||||
* DESIGN.md). Both matches found in this batch are status hues with no
|
||||
* `.dark` override in index.css.
|
||||
*/
|
||||
const REUSE = {
|
||||
"#2563EB": "var(--status-task-in_progress)", // == --status-agent-running, both #2563eb, mode-independent
|
||||
"#22c55e": "var(--status-task-done)",
|
||||
};
|
||||
|
||||
/**
|
||||
* SITE TABLE — [relative file path, find, replace]. Order-independent;
|
||||
* every find/replace pair is applied with a single non-global `.replace()`
|
||||
* per occurrence count noted, so duplicate literal strings within one file
|
||||
* (e.g. the three `text-[#959596]` in ActivityFeed.tsx) are handled via
|
||||
* `replaceAll` where explicitly marked.
|
||||
*/
|
||||
const SITES = [
|
||||
// ── Tailwind bracket hex-class sites ──────────────────────────────
|
||||
{
|
||||
file: "components/ActivityFeed.tsx",
|
||||
replaceAll: [
|
||||
['className="font-medium text-[#959596] group-hover:text-white"', 'className="font-medium text-(--hex-959596) group-hover:text-white"'],
|
||||
['className="ml-1 text-[#959596]"', 'className="ml-1 text-(--hex-959596)"'],
|
||||
['className="ml-1 text-[#959596] group-hover:text-white"', 'className="ml-1 text-(--hex-959596) group-hover:text-white"'],
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "components/FeedCard.tsx",
|
||||
replaceAll: [
|
||||
['isMuted ? "text-muted-foreground/70" : "text-[#959596]"', 'isMuted ? "text-muted-foreground/70" : "text-(--hex-959596)"'],
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "components/OnboardingWizard.tsx",
|
||||
replaceAll: [
|
||||
['"hidden md:block overflow-hidden bg-[#1d1d1d] transition-[width,opacity] duration-500 ease-in-out"', '"hidden md:block overflow-hidden bg-(--hex-1d1d1d) transition-[width,opacity] duration-500 ease-in-out"'],
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "components/IssueChatThread.tsx",
|
||||
replaceAll: [
|
||||
['// Liveness blue (#2563EB) for the human\'s own messages (PAP-95 rev 5).', '// Liveness blue (--status-task-in_progress) for the human\'s own messages (PAP-95 rev 5).'],
|
||||
['? "bg-[#2563EB] text-white"', '? "bg-(--status-task-in_progress) text-white"'],
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "components/IssueChatThread.test.tsx",
|
||||
replaceAll: [
|
||||
['expect(bubble?.className).not.toContain("bg-[#2563EB]");', 'expect(bubble?.className).not.toContain("bg-(--status-task-in_progress)");'],
|
||||
],
|
||||
},
|
||||
|
||||
// ── OrgChart.tsx status dot color map (pure style render) ─────────
|
||||
{
|
||||
file: "pages/OrgChart.tsx",
|
||||
replaceAll: [
|
||||
['running: "#22d3ee",', 'running: "var(--hex-22d3ee)",'],
|
||||
['active: "#4ade80",', 'active: "var(--hex-4ade80)",'],
|
||||
['paused: "#facc15",\n idle: "#facc15",', 'paused: "var(--hex-facc15)",\n idle: "var(--hex-facc15)",'],
|
||||
['error: "#f87171",', 'error: "var(--hex-f87171)",'],
|
||||
['terminated: "#a3a3a3",', 'terminated: "var(--hex-a3a3a3)",'],
|
||||
['const defaultDotColor = "#a3a3a3";', 'const defaultDotColor = "var(--hex-a3a3a3)";'],
|
||||
],
|
||||
},
|
||||
|
||||
// ── ActivityCharts.tsx priority + status color maps (pure style render) ──
|
||||
{
|
||||
file: "components/ActivityCharts.tsx",
|
||||
replaceAll: [
|
||||
['critical: "#ef4444",', 'critical: "var(--hex-ef4444)",'],
|
||||
['high: "#f97316",', 'high: "var(--hex-f97316)",'],
|
||||
['medium: "#eab308",', 'medium: "var(--hex-eab308)",'],
|
||||
['low: "#6b7280",', 'low: "var(--hex-6b7280)",'],
|
||||
['todo: "#3b82f6",', 'todo: "var(--hex-3b82f6)",'],
|
||||
['in_progress: "#8b5cf6",', 'in_progress: "var(--hex-8b5cf6)",'],
|
||||
['in_review: "#a855f7",', 'in_review: "var(--hex-a855f7)",'],
|
||||
['done: "#10b981",', 'done: "var(--hex-10b981)",'],
|
||||
['blocked: "#ef4444",', 'blocked: "var(--hex-ef4444)",'],
|
||||
['cancelled: "#6b7280",', 'cancelled: "var(--hex-6b7280)",'],
|
||||
['backlog: "#64748b",', 'backlog: "var(--hex-64748b)",'],
|
||||
['backgroundColor: statusColors[s] ?? "#6b7280"', 'backgroundColor: statusColors[s] ?? "var(--hex-6b7280)"'],
|
||||
['color: statusColors[s] ?? "#6b7280"', 'color: statusColors[s] ?? "var(--hex-6b7280)"'],
|
||||
['rate >= 0.8 ? "#10b981" : rate >= 0.5 ? "#eab308" : "#ef4444"', 'rate >= 0.8 ? "var(--hex-10b981)" : rate >= 0.5 ? "var(--hex-eab308)" : "var(--hex-ef4444)"'],
|
||||
],
|
||||
},
|
||||
|
||||
// ── Project-color-fallback pure-style-render sites (indigo #6366f1) ──
|
||||
{
|
||||
file: "pages/ProjectDetail.tsx",
|
||||
replaceAll: [
|
||||
['backgroundColor: project.color ?? "#6366f1"', 'backgroundColor: project.color ?? "var(--hex-6366f1)"'],
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "pages/PipelineSettings.tsx",
|
||||
replaceAll: [
|
||||
['backgroundColor: selectedAutomationProject.color ?? "#6366f1"', 'backgroundColor: selectedAutomationProject.color ?? "var(--hex-6366f1)"'],
|
||||
['backgroundColor: project?.color ?? "#6366f1"', 'backgroundColor: project?.color ?? "var(--hex-6366f1)"'],
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "components/issue-properties/IssueProperties.tsx",
|
||||
replaceAll: [
|
||||
['backgroundColor: orderedProjects.find((p) => p.id === issue.projectId)?.color ?? "#6366f1"', 'backgroundColor: orderedProjects.find((p) => p.id === issue.projectId)?.color ?? "var(--hex-6366f1)"'],
|
||||
['backgroundColor: option.color ?? "#6366f1"', 'backgroundColor: option.color ?? "var(--hex-6366f1)"'],
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "components/NewIssueDialog.tsx",
|
||||
replaceAll: [
|
||||
['backgroundColor: currentProject.color ?? "#6366f1"', 'backgroundColor: currentProject.color ?? "var(--hex-6366f1)"'],
|
||||
['backgroundColor: project?.color ?? "#6366f1"', 'backgroundColor: project?.color ?? "var(--hex-6366f1)"'],
|
||||
],
|
||||
},
|
||||
|
||||
// ── Project-color-fallback pure-style-render sites (slate #64748b) ──
|
||||
{
|
||||
file: "pages/Routines.tsx",
|
||||
replaceAll: [
|
||||
['backgroundColor: currentProject.color ?? "#64748b"', 'backgroundColor: currentProject.color ?? "var(--hex-64748b)"'],
|
||||
['backgroundColor: project?.color ?? "#64748b"', 'backgroundColor: project?.color ?? "var(--hex-64748b)"'],
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "components/MarkdownEditor.tsx",
|
||||
replaceAll: [
|
||||
['backgroundColor: option.projectColor ?? "#64748b"', 'backgroundColor: option.projectColor ?? "var(--hex-64748b)"'],
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "components/RoutineRunVariablesDialog.tsx",
|
||||
replaceAll: [
|
||||
['backgroundColor: selectedProject.color ?? "#64748b"', 'backgroundColor: selectedProject.color ?? "var(--hex-64748b)"'],
|
||||
['backgroundColor: project?.color ?? "#64748b"', 'backgroundColor: project?.color ?? "var(--hex-64748b)"'],
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "components/RoutineList.tsx",
|
||||
replaceAll: [
|
||||
['backgroundColor: project?.color ?? "#64748b"', 'backgroundColor: project?.color ?? "var(--hex-64748b)"'],
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "components/routine-sections/editable-sections.tsx",
|
||||
replaceAll: [
|
||||
['backgroundColor: currentProject.color ?? "#64748b"', 'backgroundColor: currentProject.color ?? "var(--hex-64748b)"'],
|
||||
['backgroundColor: project?.color ?? "#64748b"', 'backgroundColor: project?.color ?? "var(--hex-64748b)"'],
|
||||
],
|
||||
},
|
||||
|
||||
// ── Gradient sites (verbatim value -> --gradient-extract-N token) ─
|
||||
{
|
||||
file: "pages/Dashboard.tsx",
|
||||
replaceAll: [
|
||||
['bg-[linear-gradient(180deg,rgba(255,80,80,0.12),rgba(255,255,255,0.02))]', 'bg-(image:--gradient-extract-1)'],
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "pages/Costs.tsx",
|
||||
replaceAll: [
|
||||
['bg-[linear-gradient(180deg,rgba(255,255,255,0.05),rgba(255,255,255,0.02))]', 'bg-(image:--gradient-extract-2)'],
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "components/AccountingModelCard.tsx",
|
||||
replaceAll: [
|
||||
['bg-[radial-gradient(circle_at_top_left,rgba(244,114,182,0.08),transparent_35%),radial-gradient(circle_at_bottom_right,rgba(56,189,248,0.1),transparent_32%)]', 'bg-(image:--gradient-extract-3)'],
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "components/BudgetIncidentCard.tsx",
|
||||
replaceAll: [
|
||||
['bg-[linear-gradient(180deg,rgba(255,70,70,0.10),rgba(255,255,255,0.02))]', 'bg-(image:--gradient-extract-4)'],
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "pages/RunTranscriptUxLab.tsx",
|
||||
replaceAll: [
|
||||
['bg-[radial-gradient(circle_at_top_left,rgba(8,145,178,0.08),transparent_36%),radial-gradient(circle_at_bottom_right,rgba(245,158,11,0.10),transparent_28%)]', 'bg-(image:--gradient-extract-5)'],
|
||||
['bg-[linear-gradient(135deg,rgba(8,145,178,0.08),transparent_28%),linear-gradient(180deg,rgba(245,158,11,0.08),transparent_40%),var(--background)]', 'bg-(image:--gradient-extract-6)'],
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "pages/ProfileSettings.tsx",
|
||||
replaceAll: [
|
||||
['bg-[radial-gradient(circle_at_top_right,rgba(255,255,255,0.22),transparent_34%),radial-gradient(circle_at_bottom_left,rgba(255,255,255,0.08),transparent_36%)]', 'bg-(image:--gradient-extract-7)'],
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "pages/InviteUxLab.tsx",
|
||||
replaceAll: [
|
||||
['bg-[radial-gradient(circle_at_top,rgba(8,145,178,0.18),transparent_48%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,1))]', 'bg-(image:--gradient-extract-8)'],
|
||||
['bg-[linear-gradient(135deg,rgba(8,145,178,0.10),transparent_28%),linear-gradient(180deg,rgba(245,158,11,0.10),transparent_44%),var(--background)]', 'bg-(image:--gradient-extract-9)'],
|
||||
['bg-[linear-gradient(180deg,rgba(59,130,246,0.05),transparent_30%),var(--background)]', 'bg-(image:--gradient-extract-21)'],
|
||||
['bg-[linear-gradient(180deg,rgba(234,179,8,0.06),transparent_28%),var(--background)]', 'bg-(image:--gradient-extract-22)'],
|
||||
['bg-[linear-gradient(180deg,rgba(16,185,129,0.06),transparent_30%),var(--background)]', 'bg-(image:--gradient-extract-23)'],
|
||||
['bg-[linear-gradient(180deg,rgba(168,85,247,0.06),transparent_28%),var(--background)]', 'bg-(image:--gradient-extract-10)'],
|
||||
['bg-[linear-gradient(180deg,rgba(244,114,182,0.06),transparent_28%),var(--background)]', 'bg-(image:--gradient-extract-24)'],
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "pages/IssueChatUxLab.tsx",
|
||||
replaceAll: [
|
||||
['bg-[linear-gradient(135deg,rgba(8,145,178,0.10),transparent_28%),linear-gradient(180deg,rgba(245,158,11,0.10),transparent_44%),var(--background)]', 'bg-(image:--gradient-extract-9)'],
|
||||
['bg-[linear-gradient(180deg,rgba(168,85,247,0.06),transparent_28%),var(--background)]', 'bg-(image:--gradient-extract-10)'],
|
||||
['bg-[linear-gradient(180deg,rgba(16,185,129,0.06),transparent_28%),var(--background)]', 'bg-(image:--gradient-extract-11)'],
|
||||
['bg-[linear-gradient(180deg,rgba(6,182,212,0.05),transparent_28%),var(--background)]', 'bg-(image:--gradient-extract-12)'],
|
||||
['bg-[linear-gradient(180deg,rgba(59,130,246,0.06),transparent_28%),var(--background)]', 'bg-(image:--gradient-extract-13)'],
|
||||
['bg-[linear-gradient(180deg,rgba(168,85,247,0.05),transparent_26%),var(--background)]', 'bg-(image:--gradient-extract-14)'],
|
||||
['bg-[linear-gradient(180deg,rgba(245,158,11,0.08),transparent_26%),var(--background)]', 'bg-(image:--gradient-extract-15)'],
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "pages/SystemNoticeUxLab.tsx",
|
||||
replaceAll: [
|
||||
['bg-[linear-gradient(135deg,rgba(245,158,11,0.10),transparent_28%),linear-gradient(180deg,rgba(8,145,178,0.08),transparent_44%),var(--background)]', 'bg-(image:--gradient-extract-16)'],
|
||||
['bg-[linear-gradient(180deg,rgba(245,158,11,0.05),transparent_28%),var(--background)]', 'bg-(image:--gradient-extract-17)'],
|
||||
['bg-[linear-gradient(180deg,rgba(8,145,178,0.05),transparent_28%),var(--background)]', 'bg-(image:--gradient-extract-18)'],
|
||||
['bg-[linear-gradient(180deg,rgba(244,63,94,0.05),transparent_28%),var(--background)]', 'bg-(image:--gradient-extract-19)'],
|
||||
['bg-[linear-gradient(180deg,rgba(16,185,129,0.05),transparent_28%),var(--background)]', 'bg-(image:--gradient-extract-20)'],
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// ── Allowlist — sites intentionally NOT rewritten (functional / third-party) ──
|
||||
// One entry per file; each also gets an inline
|
||||
// `/* token-extraction: allowlisted — ... */` comment injected at the site
|
||||
// (idempotent: only injected if not already present).
|
||||
const ALLOWLIST_COMMENTS = [
|
||||
{
|
||||
file: "pages/CompanyEnvironments.tsx",
|
||||
anchor: 'background: "#0a0a0a",',
|
||||
commentLine: " // token-extraction: allowlisted — xterm.js terminal theme config; functional third-party option object, not a rendered CSS value.",
|
||||
},
|
||||
{
|
||||
file: "pages/CompanySettings.tsx",
|
||||
anchor: ' <input\n type="color"',
|
||||
commentLine: " {/* token-extraction: allowlisted — <input type=\"color\"> value must be a real hex string, not a var() reference. */}",
|
||||
},
|
||||
{
|
||||
file: "components/issue-properties/IssueProperties.tsx",
|
||||
anchor: 'const [newLabelColor, setNewLabelColor] = useState("#6366f1");',
|
||||
commentLine: " // token-extraction: allowlisted — color-picker seed state, persisted into label-create payload; a var() string would break that payload.",
|
||||
},
|
||||
{
|
||||
file: "pages/CompanySkills.tsx",
|
||||
anchor: "const DISCOVERY_ACCENTS = [",
|
||||
commentLine: "// token-extraction: allowlisted — skill.color is persisted/compared JS data (SkillCreateDraft), not just a rendered value; a var() string would corrupt it.",
|
||||
},
|
||||
{
|
||||
file: "components/IssueColumns.tsx",
|
||||
anchor: 'const accentColor = projectColor ?? "#64748b";',
|
||||
commentLine: " // token-extraction: allowlisted — accentColor also feeds pickTextColorForPillBg() contrast math; a var() string can't be parsed as a hex color there.",
|
||||
},
|
||||
{
|
||||
file: "components/CompanyPatternIcon.tsx",
|
||||
anchor: "ctx.fillStyle = `rgb(${offR} ${offG} ${offB})`;",
|
||||
commentLine: " // token-extraction: allowlisted — canvas 2D fillStyle computed at runtime from numeric channel props; not a static literal.",
|
||||
},
|
||||
{
|
||||
file: "components/FileViewerSheet.tsx",
|
||||
anchor: 'isHighlighted && "bg-[var(--paperclip-code-highlight-bg,rgba(250,204,21,0.12))]",',
|
||||
commentLine: " // token-extraction: allowlisted — half-migrated var(--x, fallback) pattern; --paperclip-code-highlight-bg/-border don't exist in index.css yet. Needs human decision (see TOKEN-AUDIT.md 2) before minting, since defining the var changes a fallback-CSS-var-expression, not a plain literal swap.",
|
||||
},
|
||||
{
|
||||
file: "pages/InviteUxLab.tsx",
|
||||
anchor: ' <div className="flex items-start gap-4">\n <CompanyPatternIcon\n companyName="Acme Robotics"\n logoUrl="/api/invites/pcp_invite_test/logo"\n brandColor="#114488"\n className="h-16 w-16 rounded-none border border-zinc-800"',
|
||||
commentLine: " {/* token-extraction: allowlisted — brandColor feeds CompanyPatternIcon's hexToHue() color math via a canvas fill; demo/showcase-only prop, not a rendered CSS value. */}",
|
||||
},
|
||||
{
|
||||
file: "pages/InviteUxLab.tsx",
|
||||
anchor: ' <div className="flex items-center gap-3">\n <CompanyPatternIcon\n companyName="Acme Robotics"\n logoUrl="/api/invites/pcp_invite_test/logo"\n brandColor="#114488"\n className="h-12 w-12 rounded-none border border-zinc-800"',
|
||||
commentLine: " {/* token-extraction: allowlisted — brandColor feeds CompanyPatternIcon's hexToHue() color math via a canvas fill; demo/showcase-only prop, not a rendered CSS value. */}",
|
||||
},
|
||||
];
|
||||
|
||||
function applyReplacements(content, pairs, filePath) {
|
||||
let next = content;
|
||||
let count = 0;
|
||||
for (const [find, replace] of pairs) {
|
||||
if (next.includes(find)) {
|
||||
next = next.split(find).join(replace);
|
||||
count += 1;
|
||||
} else if (!next.includes(replace)) {
|
||||
// Neither the original nor the replacement is present — likely a
|
||||
// stale site table entry; flag loudly rather than silently no-op.
|
||||
console.warn(` ! WARNING: pattern not found (and not already applied) in ${filePath}:\n ${find.slice(0, 100)}`);
|
||||
}
|
||||
}
|
||||
return { next, count };
|
||||
}
|
||||
|
||||
function injectAllowlistComment(content, anchor, commentLine, filePath) {
|
||||
if (content.includes(commentLine.trim())) return { next: content, injected: false }; // idempotent
|
||||
const idx = content.indexOf(anchor);
|
||||
if (idx === -1) {
|
||||
console.warn(` ! WARNING: allowlist anchor not found in ${filePath}:\n ${anchor}`);
|
||||
return { next: content, injected: false };
|
||||
}
|
||||
const lineStart = content.lastIndexOf("\n", idx) + 1;
|
||||
const next = content.slice(0, lineStart) + commentLine + "\n" + content.slice(lineStart);
|
||||
return { next, injected: true };
|
||||
}
|
||||
|
||||
function main() {
|
||||
let totalSitesRewritten = 0;
|
||||
let filesChanged = 0;
|
||||
const changedFiles = [];
|
||||
|
||||
for (const site of SITES) {
|
||||
const filePath = resolve(UI_SRC, site.file);
|
||||
const original = readFileSync(filePath, "utf8");
|
||||
const { next, count } = applyReplacements(original, site.replaceAll, site.file);
|
||||
if (next !== original) {
|
||||
filesChanged += 1;
|
||||
changedFiles.push(site.file);
|
||||
if (!DRY_RUN) writeFileSync(filePath, next, "utf8");
|
||||
}
|
||||
totalSitesRewritten += count;
|
||||
}
|
||||
|
||||
let allowlistInjections = 0;
|
||||
for (const entry of ALLOWLIST_COMMENTS) {
|
||||
const filePath = resolve(UI_SRC, entry.file);
|
||||
const original = readFileSync(filePath, "utf8");
|
||||
const { next, injected } = injectAllowlistComment(original, entry.anchor, entry.commentLine, entry.file);
|
||||
if (injected) {
|
||||
allowlistInjections += 1;
|
||||
if (!DRY_RUN) writeFileSync(filePath, next, "utf8");
|
||||
if (!changedFiles.includes(entry.file)) {
|
||||
filesChanged += 1;
|
||||
changedFiles.push(entry.file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── index.css token block ──────────────────────────────────────────
|
||||
const cssPath = resolve(UI_SRC, "index.css");
|
||||
const cssOriginal = readFileSync(cssPath, "utf8");
|
||||
const marker = "/* ── Extracted verbatim tokens (Phase 2, design/token-extraction) ── */";
|
||||
let cssNext = cssOriginal;
|
||||
let cssChanged = false;
|
||||
|
||||
if (!cssOriginal.includes(marker)) {
|
||||
const tokenLines = NEW_TOKENS.map((t) => ` --${t.name}: ${t.value}; /* ${t.comment} */`).join("\n");
|
||||
const block = `\n${marker}\n/* Batch 1/4: color literals only. Reused-from-existing-token sites (see\n TOKEN-AUDIT.md section 1.1) are NOT duplicated here — they reference\n --status-task-in_progress / --status-task-done directly at the call site.\n\n Allowlist (sites intentionally left as hardcoded / functional literals,\n NOT converted to tokens — each also carries an inline\n \`token-extraction: allowlisted\` comment at the site):\n - pages/CompanyEnvironments.tsx — xterm.js terminal theme config; functional JS values, third-party.\n - pages/CompanySettings.tsx — <input type="color"> value; functional form control, not a rendered value.\n - components/issue-properties/IssueProperties.tsx (newLabelColor) — color-picker seed persisted into label-create payload.\n - pages/CompanySkills.tsx (DISCOVERY_ACCENTS) — persisted/compared skill.color JS data, not just rendered.\n - components/IssueColumns.tsx (accentColor fallback) — also feeds pickTextColorForPillBg() contrast math.\n - components/CompanyPatternIcon.tsx — canvas fillStyle computed at runtime from numeric props, not a static literal.\n - components/FileViewerSheet.tsx — half-migrated var(--paperclip-code-highlight-*, fallback) pattern; needs human decision, see TOKEN-AUDIT.md section 2.\n - pages/InviteUxLab.tsx (brandColor prop, x2) — demo/showcase-only prop feeding CompanyPatternIcon's hexToHue() color math, not a rendered CSS value.\n*/\n:root {\n${tokenLines}\n}\n`;
|
||||
cssNext = cssOriginal + block;
|
||||
cssChanged = true;
|
||||
}
|
||||
|
||||
if (cssChanged && !DRY_RUN) writeFileSync(cssPath, cssNext, "utf8");
|
||||
|
||||
// ── Summary ─────────────────────────────────────────────────────────
|
||||
console.log(`\n${DRY_RUN ? "[DRY RUN] " : ""}codemod-extract-colors summary`);
|
||||
console.log(` Sites rewritten: ${totalSitesRewritten}`);
|
||||
console.log(` Component/page files changed: ${filesChanged}`);
|
||||
console.log(` Allowlist comments injected: ${allowlistInjections}`);
|
||||
console.log(` New tokens minted: ${NEW_TOKENS.length}`);
|
||||
console.log(` Existing tokens reused: ${Object.keys(REUSE).length} (${Object.values(REUSE).join(", ")})`);
|
||||
console.log(` index.css token block: ${cssChanged ? "added" : "already present (idempotent no-op)"}`);
|
||||
if (changedFiles.length) {
|
||||
console.log(`\n Changed files:`);
|
||||
for (const f of changedFiles) console.log(` - ui/src/${f}`);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,599 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* codemod-extract-misc.mjs
|
||||
*
|
||||
* Phase 2 (extraction), Batch 4/4 (final sweep) of the design-token audit
|
||||
* (branch design/token-extraction). Replaces the remaining value-bearing
|
||||
* arbitrary Tailwind bracket utilities in `ui/src/components/**` and
|
||||
* `ui/src/pages/**` (including their *.test.tsx companions) with references
|
||||
* to CSS custom-property tokens defined in `ui/src/index.css`.
|
||||
*
|
||||
* Patterns covered this batch:
|
||||
* grid-cols-[...] / grid-rows-[...] -> --gtc-<n> / --gtr-<n> (track lists,
|
||||
* deduped on exact string match)
|
||||
* transition-[...] -> --tp-<slug> (property lists,
|
||||
* deduped on exact string match)
|
||||
* z-[...] -> --z-<n> (bare z-index scale values)
|
||||
* scale-[...] -> --s-<slug>
|
||||
* ease-[cubic-bezier(...)] -> --e-<slug>
|
||||
* align-[...] -> --va-<slug> (vertical-align)
|
||||
* stroke-[...] -> --sw-<slug> (SVG stroke-WIDTH,
|
||||
* requires the `length:` hint)
|
||||
* blur-[...] -> --blur-<slug>
|
||||
* drop-shadow-[...] -> --drop-shadow-extract-<n> (its own
|
||||
* family, separate from Batch 3's
|
||||
* --shadow-extract-* box-shadow
|
||||
* family — filter vs. box-shadow are
|
||||
* different CSS properties, see
|
||||
* TOKEN-AUDIT.md Batch 3 "Needs human
|
||||
* decision")
|
||||
* bg-[linear|radial|conic-gradient(...)] -> --gradient-extract-<n>,
|
||||
* CONTINUING Batch 1's counter (not
|
||||
* restarting it) - 2 sites that use
|
||||
* hsl(var(...))/color-mix(...)
|
||||
* CSS-native color functions rather
|
||||
* than raw hex/rgb literals, so
|
||||
* Batch 1's hand-audited color table
|
||||
* did not catch them even though
|
||||
* they are the same gradient-bracket
|
||||
* shape.
|
||||
* bg|text|border-[var(--x)] -> bare paren passthrough, no new
|
||||
* (no fallback) token minted (same rule as Batch
|
||||
* 3's var()-only case)
|
||||
* bg|text|border-[var(--x,fallback)] -> mints a --*-resolved wrapper token
|
||||
* (fallback form) whose value is `var(--x, fallback)`
|
||||
* verbatim (paren-with-fallback-comma
|
||||
* does not parse in Tailwind v4 —
|
||||
* confirmed in the Step 0 spike --
|
||||
* so the fallback expression itself
|
||||
* must live in the CSS token, not in
|
||||
* the utility). theme(colors.a.b)
|
||||
* fallbacks are resolved to their
|
||||
* BUILT-CSS equivalent var(...) form
|
||||
* first (inspected from
|
||||
* storybook-static output before
|
||||
* writing this codemod - theme() is
|
||||
* a Tailwind build-time function).
|
||||
*
|
||||
* NOT rewritten (documented, not a bug):
|
||||
* - Selector/variant brackets (data-[...], group-data-[...], has-[...],
|
||||
* aria-[...], supports-[...], etc.) are CSS selector conditions, not
|
||||
* visual values - out of scope for this codemod BY DEFINITION (see
|
||||
* check-token-gates.mjs header for the same distinction, gate 2).
|
||||
* - tw-animate/animate-plugin arbitrary utilities (zoom-in-[0.97],
|
||||
* zoom-out-[0.97], slide-in-from-top-[1%], slide-out-to-top-[1%]) plus
|
||||
* their siblings (animate-in, animate-out, fade-in-0, fade-out-0):
|
||||
* confirmed via the Step 0 spike that NONE of these compile to any CSS
|
||||
* at all in this repo's build (no tw-animate-css plugin is installed,
|
||||
* and no @utility overrides exist in index.css) - grep of the built
|
||||
* storybook-static CSS shows zero occurrences of "animate-in", "zoom",
|
||||
* "slide-in-from-top", etc. These are dead, no-op class names with zero
|
||||
* rendered visual value today, so there is nothing to tokenize; touching
|
||||
* them would not preserve OR change any pixel. Left untouched and
|
||||
* allowlisted (third-party plugin syntax, retained for whenever
|
||||
* tw-animate-css is actually installed) rather than silently deleted,
|
||||
* since deleting dead classes is itself a (no-op but non-mechanical)
|
||||
* edit outside this batch's mandate.
|
||||
* - max-[480px] / min-[420px] breakpoint variants and rounded-[inherit]:
|
||||
* documented allowlist entries, not code the codemod touches (variant
|
||||
* position cannot reference a CSS custom property; `inherit` is a
|
||||
* keyword). See ALLOWLIST block in index.css.
|
||||
*
|
||||
* Idempotent: the FIND regexes only match the ORIGINAL bracket-literal form;
|
||||
* once rewritten the pattern no longer matches, so re-running is a no-op.
|
||||
*
|
||||
* Usage: node scripts/codemod-extract-misc.mjs [--check]
|
||||
* --check Report what WOULD change without writing files (dry run).
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, readdirSync } from "node:fs";
|
||||
import { resolve, dirname, join, relative } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = resolve(__dirname, "..");
|
||||
const UI_SRC = resolve(REPO_ROOT, "ui/src");
|
||||
const SCAN_DIRS = ["components", "pages"];
|
||||
|
||||
const DRY_RUN = process.argv.includes("--check");
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────
|
||||
function walk(dir, out) {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const p = join(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(p, out);
|
||||
else if (/\.(tsx?|jsx?)$/.test(entry.name)) out.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
// Tailwind bracket-escaping uses `_` for literal spaces; reverse it before
|
||||
// writing into a real CSS custom property value (Batch 1's gradient gotcha,
|
||||
// generalized in Batch 3, applies again here for grid track lists / calc
|
||||
// expressions embedded in transition/ease values).
|
||||
function unescapeSpaces(value) {
|
||||
return value.replace(/_/g, " ");
|
||||
}
|
||||
|
||||
// "320" "0.7rem" "-50" "auto_minmax(0,1fr)" etc -> safe token-name suffix.
|
||||
// Dots become underscores, spaces/commas/parens/percent become hyphens,
|
||||
// collapsed and trimmed (matches Batch 1-3's `--fs-0_7rem`-style convention
|
||||
// for numeric suffixes; grid/transition/ease values use a slug instead since
|
||||
// their content isn't a single number).
|
||||
function slugify(value) {
|
||||
return value
|
||||
.trim()
|
||||
.replace(/_/g, " ")
|
||||
.replace(/\./g, "_")
|
||||
.replace(/[^a-zA-Z0-9_]+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-|-$/g, "")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
// ── Token registries ────────────────────────────────────────────────────
|
||||
const gtcTokens = new Map(); // --gtc-<n> grid-template-columns
|
||||
const gtrTokens = new Map(); // --gtr-<n> grid-template-rows
|
||||
const tpTokens = new Map(); // --tp-<slug> transition-property lists
|
||||
const zTokens = new Map(); // --z-<n>
|
||||
const sTokens = new Map(); // --s-<slug> scale
|
||||
const eTokens = new Map(); // --e-<slug> ease / timing-function
|
||||
const vaTokens = new Map(); // --va-<slug> vertical-align
|
||||
const swTokens = new Map(); // --sw-<slug> stroke-width
|
||||
const blurTokens = new Map(); // --blur-<slug>
|
||||
const dropShadowTokens = new Map(); // --drop-shadow-extract-<n>
|
||||
const resolvedVarTokens = new Map(); // --*-resolved wrapper tokens (fallback var() forms)
|
||||
// --gradient-extract-<n> continuing Batch 1's family/counter (these 2 sites
|
||||
// use hsl(var(...))/color-mix(...) CSS-native color functions rather than
|
||||
// raw hex/rgb literals, which is why Batch 1's hand-audited color-literal
|
||||
// table did not catch them - they're still a value-bearing gradient bracket,
|
||||
// in scope for this batch's "remaining bracket utilities" sweep).
|
||||
const gradientTokens = new Map();
|
||||
|
||||
let gtcCounter = 0;
|
||||
let gtrCounter = 0;
|
||||
let dropShadowCounter = 0;
|
||||
// Batch 1 minted --gradient-extract-1 through --gradient-extract-24; this
|
||||
// batch's new gradient sites continue that numbering, not restart it.
|
||||
let gradientCounter = 24;
|
||||
|
||||
const gtcByValue = new Map();
|
||||
function registerGtcToken(unescapedValue, sourceNote) {
|
||||
if (gtcByValue.has(unescapedValue)) return gtcByValue.get(unescapedValue);
|
||||
gtcCounter += 1;
|
||||
const name = `gtc-${gtcCounter}`;
|
||||
gtcTokens.set(name, { value: unescapedValue, comment: sourceNote });
|
||||
gtcByValue.set(unescapedValue, name);
|
||||
return name;
|
||||
}
|
||||
|
||||
const gtrByValue = new Map();
|
||||
function registerGtrToken(unescapedValue, sourceNote) {
|
||||
if (gtrByValue.has(unescapedValue)) return gtrByValue.get(unescapedValue);
|
||||
gtrCounter += 1;
|
||||
const name = `gtr-${gtrCounter}`;
|
||||
gtrTokens.set(name, { value: unescapedValue, comment: sourceNote });
|
||||
gtrByValue.set(unescapedValue, name);
|
||||
return name;
|
||||
}
|
||||
|
||||
function registerTpToken(unescapedValue, sourceNote) {
|
||||
const slug = slugify(unescapedValue.replace(/,/g, " "));
|
||||
const name = `tp-${slug}`;
|
||||
if (!tpTokens.has(name)) tpTokens.set(name, { value: unescapedValue, comment: sourceNote });
|
||||
return name;
|
||||
}
|
||||
|
||||
function registerZToken(rawNum, sourceNote) {
|
||||
const name = `z-${rawNum}`;
|
||||
if (!zTokens.has(name)) zTokens.set(name, { value: rawNum, comment: sourceNote });
|
||||
return name;
|
||||
}
|
||||
|
||||
function registerSToken(unescapedValue, sourceNote) {
|
||||
const slug = slugify(unescapedValue);
|
||||
const name = `s-${slug}`;
|
||||
if (!sTokens.has(name)) sTokens.set(name, { value: unescapedValue, comment: sourceNote });
|
||||
return name;
|
||||
}
|
||||
|
||||
function registerEToken(unescapedValue, sourceNote) {
|
||||
const slug = slugify(unescapedValue);
|
||||
const name = `e-${slug}`;
|
||||
if (!eTokens.has(name)) eTokens.set(name, { value: unescapedValue, comment: sourceNote });
|
||||
return name;
|
||||
}
|
||||
|
||||
function registerVaToken(unescapedValue, sourceNote) {
|
||||
const slug = slugify(unescapedValue);
|
||||
const name = `va-${slug}`;
|
||||
if (!vaTokens.has(name)) vaTokens.set(name, { value: unescapedValue, comment: sourceNote });
|
||||
return name;
|
||||
}
|
||||
|
||||
function registerSwToken(unescapedValue, sourceNote) {
|
||||
const slug = slugify(unescapedValue);
|
||||
const name = `sw-${slug}`;
|
||||
if (!swTokens.has(name)) swTokens.set(name, { value: unescapedValue, comment: sourceNote });
|
||||
return name;
|
||||
}
|
||||
|
||||
function registerBlurToken(unescapedValue, sourceNote) {
|
||||
const slug = slugify(unescapedValue);
|
||||
const name = `blur-${slug}`;
|
||||
if (!blurTokens.has(name)) blurTokens.set(name, { value: unescapedValue, comment: sourceNote });
|
||||
return name;
|
||||
}
|
||||
|
||||
const gradientByValue = new Map();
|
||||
function registerGradientToken(unescapedValue, sourceNote) {
|
||||
if (gradientByValue.has(unescapedValue)) return gradientByValue.get(unescapedValue);
|
||||
gradientCounter += 1;
|
||||
const name = `gradient-extract-${gradientCounter}`;
|
||||
gradientTokens.set(name, { value: unescapedValue, comment: sourceNote });
|
||||
gradientByValue.set(unescapedValue, name);
|
||||
return name;
|
||||
}
|
||||
|
||||
const dropShadowByValue = new Map();
|
||||
function registerDropShadowToken(unescapedValue, sourceNote) {
|
||||
if (dropShadowByValue.has(unescapedValue)) return dropShadowByValue.get(unescapedValue);
|
||||
dropShadowCounter += 1;
|
||||
const name = `drop-shadow-extract-${dropShadowCounter}`;
|
||||
dropShadowTokens.set(name, { value: unescapedValue, comment: sourceNote });
|
||||
dropShadowByValue.set(unescapedValue, name);
|
||||
return name;
|
||||
}
|
||||
|
||||
// Wrapper tokens for the var(--x, fallback) forms that can't use the
|
||||
// paren-with-fallback-comma shorthand (confirmed unsupported in the Step 0
|
||||
// spike). theme(colors.a.b) fallbacks are resolved to the equivalent
|
||||
// var(--token) form the Tailwind build already resolves them to today
|
||||
// (inspected in ui/storybook-static/assets/*.css before writing this
|
||||
// codemod - theme() is a Tailwind build-time function and cannot appear
|
||||
// inside a runtime custom property).
|
||||
const THEME_COLOR_MAP = {
|
||||
"theme(colors.muted.DEFAULT)": "var(--muted)",
|
||||
"theme(colors.muted.foreground)": "var(--muted-foreground)",
|
||||
};
|
||||
|
||||
function resolveThemeColor(raw) {
|
||||
let out = raw;
|
||||
for (const [from, to] of Object.entries(THEME_COLOR_MAP)) {
|
||||
out = out.split(from).join(to);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function registerResolvedVarToken(varName, fallbackRaw, sourceNote) {
|
||||
// varName like "--paperclip-code-highlight-bg" -> token name
|
||||
// "code-highlight-bg-resolved" (strip the leading "--paperclip-" prefix
|
||||
// for readability, matching the mission's suggested name for the first
|
||||
// site; other vars in the same family follow the same convention).
|
||||
const bare = varName.replace(/^--paperclip-/, "").replace(/^--/, "");
|
||||
const name = `${bare}-resolved`;
|
||||
const fallback = resolveThemeColor(fallbackRaw);
|
||||
const value = `var(${varName}, ${fallback})`;
|
||||
if (!resolvedVarTokens.has(name)) resolvedVarTokens.set(name, { value, comment: sourceNote });
|
||||
return name;
|
||||
}
|
||||
|
||||
// ── Regexes ──────────────────────────────────────────────────────────────
|
||||
// Every regex requires the utility to start at a genuine class-token
|
||||
// boundary (preceded by whitespace, a quote/backtick, template-literal `${`,
|
||||
// or start-of-string) - see Batch 3's BOUNDARY GOTCHA. `:` is included for
|
||||
// variant prefixes (`data-[state=open]:`, `sm:`, etc.).
|
||||
const BOUNDARY = String.raw`(?<=^|[\s"'\`{:])`;
|
||||
|
||||
const GRID_COLS_RE = new RegExp(`${BOUNDARY}(!?)grid-cols-\\[([^\\]]+)\\]`, "g");
|
||||
const GRID_ROWS_RE = new RegExp(`${BOUNDARY}(!?)grid-rows-\\[([^\\]]+)\\]`, "g");
|
||||
const TRANSITION_RE = new RegExp(`${BOUNDARY}(!?)transition-\\[([^\\]]+)\\]`, "g");
|
||||
const Z_RE = new RegExp(`${BOUNDARY}(!?)z-\\[([0-9]+)\\]`, "g");
|
||||
const SCALE_RE = new RegExp(`${BOUNDARY}(!?)scale-\\[([^\\]]+)\\]`, "g");
|
||||
const EASE_RE = new RegExp(`${BOUNDARY}(!?)ease-\\[([^\\]]+)\\]`, "g");
|
||||
const ALIGN_RE = new RegExp(`${BOUNDARY}(!?)align-\\[([^\\]]+)\\]`, "g");
|
||||
const STROKE_RE = new RegExp(`${BOUNDARY}(!?)stroke-\\[([^\\]]+)\\]`, "g");
|
||||
// NOTE: negative lookbehind isn't reliably portable across regex engines at
|
||||
// the boundary position used elsewhere, so drop-shadow is matched with its
|
||||
// own explicit prefix (drop-shadow-) which never collides with bare blur-.
|
||||
// Matches both bare `blur-[...]` and `backdrop-blur-[...]` (the latter's
|
||||
// `backdrop-` prefix means `blur` doesn't start at a class-token BOUNDARY,
|
||||
// so it needs its own alternative rather than relying on the shared
|
||||
// boundary-anchored pattern).
|
||||
const BLUR_RE = new RegExp(`${BOUNDARY}(!?)(backdrop-blur|blur)-\\[([^\\]]+)\\]`, "g");
|
||||
const DROP_SHADOW_RE = new RegExp(`${BOUNDARY}(!?)drop-shadow-\\[([^\\]]+)\\]`, "g");
|
||||
|
||||
// bg-[linear-gradient(...)] / bg-[radial-gradient(...)] / bg-[conic-gradient(...)]
|
||||
// - continuing Batch 1's --gradient-extract-* family (see registerGradientToken).
|
||||
// Matched separately from the generic VAR_* regexes below since these are not
|
||||
// var() passthrough; they need the `image:` paren hint (Batch 1 convention).
|
||||
const GRADIENT_RE = new RegExp(
|
||||
`${BOUNDARY}(!?)bg-\\[((?:linear|radial|conic)-gradient\\([^\\]]*\\))\\]`,
|
||||
"g",
|
||||
);
|
||||
|
||||
// bg|text|border-[var(--x)] (no fallback) and bg|text|border-[var(--x,fallback)]
|
||||
// (with fallback). The fallback variant's raw capture includes everything up
|
||||
// to the matching `)]` - since none of these fallback values contain nested
|
||||
// brackets, a simple `[^\]]+` capture is safe (verified by inspection of all
|
||||
// matching sites in this batch).
|
||||
const VAR_NOFALLBACK_RE = new RegExp(
|
||||
`${BOUNDARY}(!?)(bg|text|border)-\\[var\\((--[a-zA-Z0-9-]+)\\)\\]`,
|
||||
"g",
|
||||
);
|
||||
const VAR_FALLBACK_RE = new RegExp(
|
||||
`${BOUNDARY}(!?)(bg|text|border)-\\[var\\((--[a-zA-Z0-9-]+),([^\\]]+)\\)\\]`,
|
||||
"g",
|
||||
);
|
||||
|
||||
function rewriteFile(filePath, relPath) {
|
||||
const original = readFileSync(filePath, "utf8");
|
||||
let content = original;
|
||||
let siteCount = 0;
|
||||
|
||||
content = content.replace(GRID_COLS_RE, (match, bang, raw) => {
|
||||
const unescaped = unescapeSpaces(raw);
|
||||
const sourceNote = `Extracted from ${relPath} (grid-cols-[${raw}]).`;
|
||||
const name = registerGtcToken(unescaped, sourceNote);
|
||||
siteCount++;
|
||||
return `${bang}grid-cols-(--${name})`;
|
||||
});
|
||||
|
||||
content = content.replace(GRID_ROWS_RE, (match, bang, raw) => {
|
||||
const unescaped = unescapeSpaces(raw);
|
||||
const sourceNote = `Extracted from ${relPath} (grid-rows-[${raw}]).`;
|
||||
const name = registerGtrToken(unescaped, sourceNote);
|
||||
siteCount++;
|
||||
return `${bang}grid-rows-(--${name})`;
|
||||
});
|
||||
|
||||
content = content.replace(TRANSITION_RE, (match, bang, raw) => {
|
||||
const unescaped = unescapeSpaces(raw);
|
||||
const sourceNote = `Extracted from ${relPath} (transition-[${raw}]).`;
|
||||
const name = registerTpToken(unescaped, sourceNote);
|
||||
siteCount++;
|
||||
return `${bang}transition-(--${name})`;
|
||||
});
|
||||
|
||||
content = content.replace(Z_RE, (match, bang, raw) => {
|
||||
const sourceNote = `Extracted from ${relPath} (z-[${raw}]).`;
|
||||
const name = registerZToken(raw, sourceNote);
|
||||
siteCount++;
|
||||
return `${bang}z-(--${name})`;
|
||||
});
|
||||
|
||||
content = content.replace(SCALE_RE, (match, bang, raw) => {
|
||||
const unescaped = unescapeSpaces(raw);
|
||||
const sourceNote = `Extracted from ${relPath} (scale-[${raw}]).`;
|
||||
const name = registerSToken(unescaped, sourceNote);
|
||||
siteCount++;
|
||||
return `${bang}scale-(--${name})`;
|
||||
});
|
||||
|
||||
content = content.replace(EASE_RE, (match, bang, raw) => {
|
||||
const unescaped = unescapeSpaces(raw);
|
||||
const sourceNote = `Extracted from ${relPath} (ease-[${raw}]).`;
|
||||
const name = registerEToken(unescaped, sourceNote);
|
||||
siteCount++;
|
||||
return `${bang}ease-(--${name})`;
|
||||
});
|
||||
|
||||
content = content.replace(ALIGN_RE, (match, bang, raw) => {
|
||||
const unescaped = unescapeSpaces(raw);
|
||||
const sourceNote = `Extracted from ${relPath} (align-[${raw}]).`;
|
||||
const name = registerVaToken(unescaped, sourceNote);
|
||||
siteCount++;
|
||||
return `${bang}align-(--${name})`;
|
||||
});
|
||||
|
||||
content = content.replace(STROKE_RE, (match, bang, raw) => {
|
||||
const unescaped = unescapeSpaces(raw);
|
||||
const sourceNote = `Extracted from ${relPath} (stroke-[${raw}]).`;
|
||||
const name = registerSwToken(unescaped, sourceNote);
|
||||
siteCount++;
|
||||
// stroke-WIDTH requires the `length:` hint (bare stroke-(--x) is
|
||||
// ambiguous with the stroke-COLOR utility) - confirmed in Step 0 spike.
|
||||
return `${bang}stroke-(length:--${name})`;
|
||||
});
|
||||
|
||||
content = content.replace(DROP_SHADOW_RE, (match, bang, raw) => {
|
||||
const unescaped = unescapeSpaces(raw);
|
||||
const sourceNote = `Extracted from ${relPath} (drop-shadow-[${raw}]).`;
|
||||
const name = registerDropShadowToken(unescaped, sourceNote);
|
||||
siteCount++;
|
||||
return `${bang}drop-shadow-(--${name})`;
|
||||
});
|
||||
|
||||
// Gradient bg-[...] brackets must be rewritten BEFORE the generic var()
|
||||
// passthrough regexes below (a gradient value can itself contain
|
||||
// `var(--x)` sub-expressions, e.g. hsl(var(--primary)), which the generic
|
||||
// VAR_NOFALLBACK_RE must not also try to match against the outer bg-[...]).
|
||||
content = content.replace(GRADIENT_RE, (match, bang, raw) => {
|
||||
const unescaped = unescapeSpaces(raw);
|
||||
const sourceNote = `Extracted from ${relPath} (bg-[${raw}]).`;
|
||||
const name = registerGradientToken(unescaped, sourceNote);
|
||||
siteCount++;
|
||||
return `${bang}bg-(image:--${name})`;
|
||||
});
|
||||
|
||||
content = content.replace(BLUR_RE, (match, bang, util, raw) => {
|
||||
const unescaped = unescapeSpaces(raw);
|
||||
const sourceNote = `Extracted from ${relPath} (${util}-[${raw}]).`;
|
||||
const name = registerBlurToken(unescaped, sourceNote);
|
||||
siteCount++;
|
||||
return `${bang}${util}-(--${name})`;
|
||||
});
|
||||
|
||||
// var(--x, fallback) forms MUST be rewritten before the no-fallback form
|
||||
// (which would otherwise partially match the `var(--x` prefix of a
|
||||
// fallback expression and corrupt it - the fallback regex requires a
|
||||
// literal comma so there's no real ambiguity, but ordering fallback-first
|
||||
// keeps the intent explicit and avoids relying on regex engine match order).
|
||||
content = content.replace(VAR_FALLBACK_RE, (match, bang, util, varName, fallbackRaw) => {
|
||||
const sourceNote = `Extracted from ${relPath} (${util}-[var(${varName},${fallbackRaw})]).`;
|
||||
const name = registerResolvedVarToken(varName, fallbackRaw, sourceNote);
|
||||
siteCount++;
|
||||
return `${bang}${util}-(--${name})`;
|
||||
});
|
||||
|
||||
content = content.replace(VAR_NOFALLBACK_RE, (match, bang, util, varName) => {
|
||||
siteCount++;
|
||||
// Bare var() passthrough - no new token minted, per DESIGN.md/Batch 3's
|
||||
// special case for runtime library/component variables. Here the vars
|
||||
// already exist as first-class design tokens in index.css (--chip-match-*),
|
||||
// so this is a pure syntax modernization, not a token mint.
|
||||
return `${bang}${util}-(${varName})`;
|
||||
});
|
||||
|
||||
if (content !== original && !DRY_RUN) {
|
||||
writeFileSync(filePath, content, "utf8");
|
||||
}
|
||||
return { changed: content !== original, siteCount };
|
||||
}
|
||||
|
||||
function main() {
|
||||
const files = [];
|
||||
for (const dir of SCAN_DIRS) walk(resolve(UI_SRC, dir), files);
|
||||
files.sort();
|
||||
|
||||
let totalSites = 0;
|
||||
let filesChanged = 0;
|
||||
const changedFiles = [];
|
||||
|
||||
for (const filePath of files) {
|
||||
const relPath = "ui/src/" + relative(UI_SRC, filePath);
|
||||
const { changed, siteCount } = rewriteFile(filePath, relPath);
|
||||
if (changed) {
|
||||
filesChanged++;
|
||||
changedFiles.push(relPath);
|
||||
}
|
||||
totalSites += siteCount;
|
||||
}
|
||||
|
||||
// ── index.css token block ──────────────────────────────────────────
|
||||
const cssPath = resolve(UI_SRC, "index.css");
|
||||
const cssOriginal = readFileSync(cssPath, "utf8");
|
||||
const marker = "/* ── Extracted verbatim MISC tokens (Phase 2 Batch 4, design/token-extraction) ── */";
|
||||
let cssNext = cssOriginal;
|
||||
let cssChanged = false;
|
||||
|
||||
const anyTokens =
|
||||
gtcTokens.size ||
|
||||
gtrTokens.size ||
|
||||
tpTokens.size ||
|
||||
zTokens.size ||
|
||||
sTokens.size ||
|
||||
eTokens.size ||
|
||||
vaTokens.size ||
|
||||
swTokens.size ||
|
||||
blurTokens.size ||
|
||||
dropShadowTokens.size ||
|
||||
resolvedVarTokens.size ||
|
||||
gradientTokens.size;
|
||||
|
||||
if (!cssOriginal.includes(marker) && anyTokens) {
|
||||
const lines = [];
|
||||
lines.push(marker);
|
||||
lines.push("/* Batch 4/4 (final sweep): grid track lists, transition-property");
|
||||
lines.push(" lists, z-index, scale, easing, vertical-align, stroke-width, blur,");
|
||||
lines.push(" drop-shadow, and half-migrated var(x, fallback) color forms,");
|
||||
lines.push(" verbatim (no normalizing - the human scale-collapse decision comes");
|
||||
lines.push(" later per DESIGN.md/TOKEN-AUDIT.md). --gtc-* and --gtr-* and --tp-* are");
|
||||
lines.push(" sequentially numbered / slugged since their content (track lists,");
|
||||
lines.push(" property lists) is not safely nameable by value alone.");
|
||||
lines.push("");
|
||||
lines.push(" drop-shadow (a CSS filter function) gets its own");
|
||||
lines.push(" --drop-shadow-extract-* family, kept separate from Batch 3's");
|
||||
lines.push(" --shadow-extract-* box-shadow family per that batch's logged");
|
||||
lines.push(" human-decision note.");
|
||||
lines.push("");
|
||||
lines.push(" *-resolved wrapper tokens hold a verbatim var(--x, fallback)");
|
||||
lines.push(" expression for sites where Tailwind v4's paren-with-fallback-comma");
|
||||
lines.push(" shorthand does not parse (confirmed unsupported in this batch's");
|
||||
lines.push(" Step 0 syntax spike). theme(colors.a.b) fallbacks were resolved to");
|
||||
lines.push(" the equivalent var(--token) form the Tailwind build already");
|
||||
lines.push(" compiles them to today (inspected byte-for-byte from the built");
|
||||
lines.push(" storybook-static CSS before this codemod ran: theme(colors.muted.DEFAULT)");
|
||||
lines.push(" -> var(--muted), theme(colors.muted.foreground) -> var(--muted-foreground)).");
|
||||
lines.push("");
|
||||
lines.push(" --gradient-extract-* here CONTINUES Batch 1's counter (Batch 1 minted");
|
||||
lines.push(" 1 through 24) rather than restarting it - these 2 sites use");
|
||||
lines.push(" hsl(var(...))/color-mix(...) CSS-native color functions rather than raw");
|
||||
lines.push(" hex/rgb literals, which is why Batch 1's hand-audited color-literal");
|
||||
lines.push(" table did not catch them, but they are still value-bearing gradient");
|
||||
lines.push(" brackets in scope for this batch's final sweep.");
|
||||
lines.push("");
|
||||
lines.push(" Allowlist (sites intentionally left as-is - see ALLOWLIST doc-comment");
|
||||
lines.push(" at the end of this file for the machine-readable list consumed by");
|
||||
lines.push(" scripts/check-token-gates.mjs):");
|
||||
lines.push(" allow ui/src/components/ui/dialog.tsx — tw-animate-css plugin utilities (zoom-in-[0.97] etc.) are dead/no-op classes today (plugin not installed, verified via built-CSS grep); nothing to tokenize without visually changing a currently-inert class");
|
||||
lines.push(" allow ui/src/components/ui/alert-dialog.tsx — same tw-animate-css dead-class situation as dialog.tsx");
|
||||
lines.push("*/");
|
||||
lines.push(":root {");
|
||||
for (const [name, { value, comment }] of gtcTokens) {
|
||||
lines.push(` --${name}: ${value}; /* ${comment} */`);
|
||||
}
|
||||
for (const [name, { value, comment }] of gtrTokens) {
|
||||
lines.push(` --${name}: ${value}; /* ${comment} */`);
|
||||
}
|
||||
for (const [name, { value, comment }] of tpTokens) {
|
||||
lines.push(` --${name}: ${value}; /* ${comment} */`);
|
||||
}
|
||||
for (const [name, { value, comment }] of zTokens) {
|
||||
lines.push(` --${name}: ${value}; /* ${comment} */`);
|
||||
}
|
||||
for (const [name, { value, comment }] of sTokens) {
|
||||
lines.push(` --${name}: ${value}; /* ${comment} */`);
|
||||
}
|
||||
for (const [name, { value, comment }] of eTokens) {
|
||||
lines.push(` --${name}: ${value}; /* ${comment} */`);
|
||||
}
|
||||
for (const [name, { value, comment }] of vaTokens) {
|
||||
lines.push(` --${name}: ${value}; /* ${comment} */`);
|
||||
}
|
||||
for (const [name, { value, comment }] of swTokens) {
|
||||
lines.push(` --${name}: ${value}; /* ${comment} */`);
|
||||
}
|
||||
for (const [name, { value, comment }] of blurTokens) {
|
||||
lines.push(` --${name}: ${value}; /* ${comment} */`);
|
||||
}
|
||||
for (const [name, { value, comment }] of dropShadowTokens) {
|
||||
lines.push(` --${name}: ${value}; /* ${comment} */`);
|
||||
}
|
||||
for (const [name, { value, comment }] of resolvedVarTokens) {
|
||||
lines.push(` --${name}: ${value}; /* ${comment} */`);
|
||||
}
|
||||
for (const [name, { value, comment }] of gradientTokens) {
|
||||
lines.push(` --${name}: ${value}; /* ${comment} */`);
|
||||
}
|
||||
lines.push("}");
|
||||
const block = "\n" + lines.join("\n") + "\n";
|
||||
cssNext = cssOriginal + block;
|
||||
cssChanged = true;
|
||||
}
|
||||
|
||||
if (cssChanged && !DRY_RUN) writeFileSync(cssPath, cssNext, "utf8");
|
||||
|
||||
// ── Summary ─────────────────────────────────────────────────────────
|
||||
console.log(`\n${DRY_RUN ? "[DRY RUN] " : ""}codemod-extract-misc summary`);
|
||||
console.log(` Sites rewritten: ${totalSites}`);
|
||||
console.log(` Files changed: ${filesChanged}`);
|
||||
console.log(` New --gtc-*: ${gtcTokens.size}`);
|
||||
console.log(` New --gtr-*: ${gtrTokens.size}`);
|
||||
console.log(` New --tp-*: ${tpTokens.size}`);
|
||||
console.log(` New --z-*: ${zTokens.size}`);
|
||||
console.log(` New --s-*: ${sTokens.size}`);
|
||||
console.log(` New --e-*: ${eTokens.size}`);
|
||||
console.log(` New --va-*: ${vaTokens.size}`);
|
||||
console.log(` New --sw-*: ${swTokens.size}`);
|
||||
console.log(` New --blur-*: ${blurTokens.size}`);
|
||||
console.log(` New --drop-shadow-extract-*: ${dropShadowTokens.size}`);
|
||||
console.log(` New *-resolved wrapper: ${resolvedVarTokens.size}`);
|
||||
console.log(` New --gradient-extract-* (continuing Batch 1): ${gradientTokens.size}`);
|
||||
console.log(` index.css token block: ${cssChanged ? "added" : "already present or nothing to add (idempotent no-op)"}`);
|
||||
if (changedFiles.length) {
|
||||
console.log(`\n Changed files:`);
|
||||
for (const f of changedFiles) console.log(` - ${f}`);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,517 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* codemod-extract-sizes.mjs
|
||||
*
|
||||
* Phase 2 (extraction), Batch 3/4 of the design-token audit
|
||||
* (branch design/token-extraction). Replaces hardcoded SIZE / SPACING /
|
||||
* RADIUS / SHADOW arbitrary Tailwind bracket values in
|
||||
* `ui/src/components/**` and `ui/src/pages/**` (including their
|
||||
* *.test.tsx companions) with references to CSS custom-property tokens
|
||||
* defined in `ui/src/index.css`.
|
||||
*
|
||||
* Patterns covered (all unambiguous bracket-literal Tailwind utilities —
|
||||
* like Batch 2's font-size/tracking sweep, a blanket regex is safe here
|
||||
* because none of these utility prefixes have a non-dimensional meaning):
|
||||
* w-[...] h-[...] size-[...] min-w-[...] max-w-[...] min-h-[...] max-h-[...]
|
||||
* p-[...] pt/pb/pl/pr/px/py-[...] m-[...] mt/mb/ml/mr/mx/my-[...]
|
||||
* gap-[...] gap-x-[...] gap-y-[...]
|
||||
* inset-[...] inset-x/y-[...] top/left/right/bottom-[...]
|
||||
* translate-x-[...] translate-y-[...]
|
||||
* rounded-[...] and directional rounded-t/r/b/l/tl/tr/br/bl-[...]
|
||||
* shadow-[...] ring-[...] outline-[...]
|
||||
* `!`-important-prefixed utilities (`!max-w-[90%]`) and variant/arbitrary-
|
||||
* variant prefixes (`sm:`, `dark:`, `[&>x]:`, etc.) are preserved verbatim —
|
||||
* the regex only rewrites the bracket portion itself.
|
||||
*
|
||||
* BOUNDARY GOTCHA (found + fixed during this batch's dry run): a naive
|
||||
* `\b(top|left|right|bottom)-\[...\]` regex false-positives on Tailwind's
|
||||
* OWN compound animation utilities `slide-out-to-top-[1%]` /
|
||||
* `slide-in-from-top-[1%]` (`components/ui/dialog.tsx`,
|
||||
* `components/ui/alert-dialog.tsx`) — `\b` matches at the `-to-top`
|
||||
* boundary because `-` is not a word character, so the regex would rewrite
|
||||
* only the `top-[1%]` tail of a longer utility name and silently corrupt
|
||||
* `slide-out-to-top-[1%]` into `slide-out-to-top-(--pct-1)` (which Tailwind
|
||||
* would not recognize as the `slide-out-to-top-*` animation-direction
|
||||
* utility at all — a real visual regression, not just a naming nit). Fixed
|
||||
* by requiring the utility name to start at a genuine class-token boundary
|
||||
* (preceded by whitespace, a quote character, backtick, template-literal
|
||||
* `${`, or the start of the string) rather than a bare `\b`.
|
||||
*
|
||||
* Token naming (verbatim value, no normalizing/rounding/unit-conversion):
|
||||
* --sz-<value><unit> width/height/spacing lengths — ONE shared family so
|
||||
* identical values dedupe across w/h/p/m/gap/inset/etc.
|
||||
* e.g. --sz-320: 320px; --sz-0_7rem: 0.7rem;
|
||||
* --sz-85vh: 85vh; --sz-24ch: 24ch;
|
||||
* --sz-calc-<n> calc(...)/min()/max()/clamp() forms, sequentially
|
||||
* numbered (values are not safely nameable by content
|
||||
* without ambiguity), each with an inline comment.
|
||||
* --sz-safe-<edge> bare env(safe-area-inset-<edge>) forms, verbatim.
|
||||
* --pct-<N> bare percentage values, e.g. --pct-50: 50%;
|
||||
* --pct-neg-<N> negative percentage values as they literally
|
||||
* appear in the bracket (e.g. translate-x-[-50%]),
|
||||
* e.g. --pct-neg-50: -50%; (kept as a DISTINCT token
|
||||
* from --pct-50 rather than negated at the utility
|
||||
* level, since the source bracket already carries the
|
||||
* minus sign inside the value, not as a utility
|
||||
* prefix — see Step 0 spike notes in TOKEN-AUDIT.md).
|
||||
* --rad-<value> radius/ring/outline width values (unitless numeric
|
||||
* suffix, unit implied px unless the source used a
|
||||
* different unit, in which case the unit is appended
|
||||
* to the name for disambiguation), e.g. --rad-8: 8px;
|
||||
* --shadow-extract-<n> shadow values verbatim (multi-stop shadows are not
|
||||
* nameable by content), each with an inline comment.
|
||||
* Underscore-to-space reversal applied (Tailwind's
|
||||
* own bracket-escaping convention; see Batch 1's
|
||||
* gradient gotcha, generalized here to shadows).
|
||||
*
|
||||
* CALC-SPACING GOTCHA (Batch 1's underscore lesson, generalized): Tailwind's
|
||||
* bracket escaping uses `_` for literal spaces (`calc(-50%_-_2px)`), but
|
||||
* `calc(100%-2rem)` (WITHOUT underscores, i.e. no space around the operator
|
||||
* AT ALL in the original bracket) is *also* present in this codebase and is
|
||||
* INVALID CSS once lifted verbatim into a custom property — verified via a
|
||||
* headless-browser Step 0 spike: `calc(100%-2rem)` silently drops (falls
|
||||
* back to the containing block's width) while `calc(100% - 2rem)` (spaces
|
||||
* added) computes correctly. Every calc() lifted into a token by this
|
||||
* codemod therefore has spaces normalized around its top-level `+`/`-`
|
||||
* operators (percent/length arithmetic), in addition to underscore reversal.
|
||||
*
|
||||
* Tailwind v4 paren-shorthand rewrite forms used (confirmed via mandatory
|
||||
* Step-0 syntax spike — scratch story + `pnpm build-storybook` + grep of
|
||||
* emitted CSS, then deleted before this codemod ran):
|
||||
* w-[320px] -> w-(--sz-320) width:var(--sz-320)
|
||||
* max-h-[85vh] -> max-h-(--sz-85vh) max-height:var(...)
|
||||
* p-[18px] -> p-(--sz-18) padding:var(...)
|
||||
* gap-[3px] -> gap-(--sz-3) gap:var(...)
|
||||
* rounded-[8px] -> rounded-(--rad-8) border-radius
|
||||
* rounded-br-[4px] -> rounded-br-(--rad-4) border-bottom-right-radius
|
||||
* shadow-[...] -> shadow-(--shadow-extract-N) box-shadow (bare
|
||||
* form works — no `shadow:` hint needed; verified
|
||||
* identical emitted rule to the hinted form).
|
||||
* top-[50%] -> top-(--pct-50) top:var(...)
|
||||
* translate-x-[-50%] -> translate-x-(--pct-neg-50) --tw-translate-x:var(...)
|
||||
* pb-[env(safe-area-inset-bottom)] -> pb-(--sz-safe-bottom) padding-bottom
|
||||
* w-[var(--radix-popover-trigger-width)] -> w-(--radix-popover-trigger-width)
|
||||
* (brackets that only wrap a var() reference a runtime library variable
|
||||
* directly — no new token minted, per DESIGN.md special-case guidance).
|
||||
*
|
||||
* ring-[Npx] / outline-[Npx] note: `ring`/`outline` are box-shadow/outline-
|
||||
* width utilities whose bracket form sets a WIDTH, not a generic length or
|
||||
* shadow value — and `ring`/`outline` ALSO have color-bracket forms
|
||||
* (`ring-[#hex]`). The correct paren-shorthand hint is `ring-(length:--x)` /
|
||||
* `outline-(length:--x)` (spiked and confirmed correct — a bare `ring-(--x)`
|
||||
* would be ambiguous the same way `text-(--x)` is). No ring/outline color-
|
||||
* bracket sites exist in this codebase (verified during Step 0 inventory),
|
||||
* so only the width form is handled.
|
||||
*
|
||||
* `calc(theme(spacing.N)±Mpx)` forms (components/IssueRow.tsx): theme() is a
|
||||
* Tailwind BUILD-TIME function and does not work inside a runtime CSS custom
|
||||
* property. This codemod resolves theme(spacing.N) using Tailwind v4's
|
||||
* default `--spacing: 0.25rem` base (confirmed via Step 0: no `--spacing`
|
||||
* override exists in index.css, and the ACTUAL BUILT CSS for these exact
|
||||
* classes was inspected before this codemod ran and shows
|
||||
* `padding-left:calc(.5rem - 2px)` / `calc(.25rem - 2px)` for
|
||||
* theme(spacing.2)/theme(spacing.1) respectively, and a plain
|
||||
* `margin-left:1.25rem` for the fully-constant
|
||||
* theme(spacing.3)+theme(spacing.2) expression, i.e. Tailwind pre-resolves
|
||||
* it when both operands are compile-time constants). The resolved-equivalent
|
||||
* calc() is minted as the token value (byte-equivalent computed output
|
||||
* verified via headless-browser spike, see TOKEN-AUDIT.md Batch 3 log).
|
||||
*
|
||||
* Idempotent: the FIND regex only matches the ORIGINAL bracket-literal form;
|
||||
* once rewritten the pattern no longer matches, so re-running is a no-op.
|
||||
*
|
||||
* Usage: node scripts/codemod-extract-sizes.mjs [--check]
|
||||
* --check Report what WOULD change without writing files (dry run).
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, readdirSync } from "node:fs";
|
||||
import { resolve, dirname, join, relative } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = resolve(__dirname, "..");
|
||||
const UI_SRC = resolve(REPO_ROOT, "ui/src");
|
||||
const SCAN_DIRS = ["components", "pages"];
|
||||
|
||||
const DRY_RUN = process.argv.includes("--check");
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────
|
||||
function walk(dir, out) {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const p = join(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(p, out);
|
||||
else if (/\.(tsx?|jsx?)$/.test(entry.name)) out.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
// Tailwind bracket-escaping uses `_` for literal spaces; reverse it before
|
||||
// writing into a real CSS custom property value. No site in this batch uses
|
||||
// `\_` (escaped underscore meaning a LITERAL underscore) — verified by
|
||||
// inspection of every match — so a plain global replace is safe.
|
||||
function unescapeSpaces(value) {
|
||||
return value.replace(/_/g, " ");
|
||||
}
|
||||
|
||||
// Ensures whitespace around top-level +/- operators inside calc()/min()/
|
||||
// max()/clamp() so the expression is valid once it's the value of a runtime
|
||||
// custom property (see CALC-SPACING GOTCHA in the header comment).
|
||||
//
|
||||
// DELIBERATELY NARROW: only rewrites a +/- whose LEFT side is a number,
|
||||
// number+unit, or a closing paren `)`, and whose RIGHT side is a number or
|
||||
// the start of a known CSS value-function call (`env(`, `var(`, `calc(`,
|
||||
// `min(`, `max(`, `clamp(`). This excludes any hyphen that sits between two
|
||||
// bare identifier characters — critical because a naive "any letter-hyphen-
|
||||
// letter" rule corrupts `env(safe-area-inset-bottom)` into
|
||||
// `env(safe - area - inset - bottom)` (caught during this batch's dry run;
|
||||
// see CALC-SPACING GOTCHA). A leading unary minus right after `(`/`,`/start
|
||||
// (e.g. `min(-40px, ...)`) is also left untouched since nothing precedes it
|
||||
// on the left to match.
|
||||
const CALC_UNIT = "(?:px|rem|em|vh|vw|dvh|dvw|svh|svw|ch|%)";
|
||||
const CALC_NUM = "[0-9]*\\.?[0-9]+";
|
||||
const CALC_FUNC = "(?:env|var|calc|min|max|clamp)\\(";
|
||||
const CALC_SPACING_RE = new RegExp(
|
||||
`(${CALC_NUM}${CALC_UNIT}|${CALC_NUM}|\\))\\s*([+-])\\s*(?=${CALC_NUM}|${CALC_FUNC})`,
|
||||
"g",
|
||||
);
|
||||
function normalizeCalcSpacing(value) {
|
||||
return value.replace(CALC_SPACING_RE, (m, left, op) => `${left} ${op} `);
|
||||
}
|
||||
|
||||
// "320" "0.7rem" "-50" etc -> safe token-name suffix. Dots become
|
||||
// underscores (matches Batch 2's `--fs-0_7rem` convention); a leading minus
|
||||
// is spelled out as `neg-` for readability.
|
||||
function safeSuffix(value) {
|
||||
let v = value;
|
||||
const negative = v.startsWith("-");
|
||||
if (negative) v = v.slice(1);
|
||||
v = v.replace(/\./g, "_");
|
||||
return negative ? `neg-${v}` : v;
|
||||
}
|
||||
|
||||
// ── Token registries ────────────────────────────────────────────────────
|
||||
const szTokens = new Map(); // --sz-* (width/height/spacing/gap/inset lengths, shared family)
|
||||
const radTokens = new Map(); // --rad-* (radius/ring-width/outline-width, shared family)
|
||||
const pctTokens = new Map(); // --pct-* / --pct-neg-*
|
||||
const shadowTokens = new Map(); // --shadow-extract-*
|
||||
let calcCounter = 0;
|
||||
let shadowCounter = 0;
|
||||
|
||||
function registerSzToken(rawValue, unit, sourceNote) {
|
||||
const value = `${rawValue}${unit}`;
|
||||
const name = `sz-${safeSuffix(rawValue)}${unit}`;
|
||||
if (!szTokens.has(name)) szTokens.set(name, { value, comment: sourceNote });
|
||||
return name;
|
||||
}
|
||||
|
||||
// Percentages: --pct-50 for "50", --pct-neg-50 for "-50" (kept distinct per
|
||||
// DESIGN.md Step-0 spike guidance — the minus sign is IN the bracket value).
|
||||
function registerPctToken(rawNum, sourceNote) {
|
||||
const name = `pct-${safeSuffix(rawNum)}`;
|
||||
const value = `${rawNum}%`;
|
||||
if (!pctTokens.has(name)) pctTokens.set(name, { value, comment: sourceNote });
|
||||
return name;
|
||||
}
|
||||
|
||||
// Radius / ring-width / outline-width family, shared. Unit is only appended
|
||||
// to the name when it's not a bare px number (px is the overwhelming
|
||||
// majority and matches Batch 1/2's bare-number convention, e.g. --fs-11).
|
||||
function registerRadToken(rawValue, unit, sourceNote) {
|
||||
const value = `${rawValue}${unit}`;
|
||||
const name = unit === "px" ? `rad-${safeSuffix(rawValue)}` : `rad-${safeSuffix(rawValue)}${unit}`;
|
||||
if (!radTokens.has(name)) radTokens.set(name, { value, comment: sourceNote });
|
||||
return name;
|
||||
}
|
||||
|
||||
// calc()/env()/min()/max()/clamp() forms and any other compound expression:
|
||||
// minted 1:1 per distinct (already-unescaped, space-normalized) string into
|
||||
// the --sz-* family, named sequentially (content isn't safely nameable) with
|
||||
// a comment noting origin. Reused across sites if the exact string recurs.
|
||||
const calcByValue = new Map(); // normalized value -> token name
|
||||
function registerCalcToken(normalizedValue, sourceNote) {
|
||||
if (calcByValue.has(normalizedValue)) return calcByValue.get(normalizedValue);
|
||||
calcCounter += 1;
|
||||
const name = `sz-calc-${calcCounter}`;
|
||||
szTokens.set(name, { value: normalizedValue, comment: sourceNote });
|
||||
calcByValue.set(normalizedValue, name);
|
||||
return name;
|
||||
}
|
||||
|
||||
function registerSafeAreaToken(edge, unescapedValue, sourceNote) {
|
||||
const name = `sz-safe-${edge}`;
|
||||
if (!szTokens.has(name)) szTokens.set(name, { value: unescapedValue, comment: sourceNote });
|
||||
return name;
|
||||
}
|
||||
|
||||
const shadowByValue = new Map(); // unescaped shadow value -> token name
|
||||
function registerShadowToken(unescapedValue, sourceNote) {
|
||||
if (shadowByValue.has(unescapedValue)) return shadowByValue.get(unescapedValue);
|
||||
shadowCounter += 1;
|
||||
const name = `shadow-extract-${shadowCounter}`;
|
||||
shadowTokens.set(name, { value: unescapedValue, comment: sourceNote });
|
||||
shadowByValue.set(unescapedValue, name);
|
||||
return name;
|
||||
}
|
||||
|
||||
// ── Classification of a raw bracket value ───────────────────────────────
|
||||
// Returns { tokenRef, isVarOnly } where tokenRef is the FULL replacement
|
||||
// content to place inside `(...)`, e.g. "--sz-320" or
|
||||
// "--radix-popover-trigger-width" (var-only passthrough, no new token).
|
||||
const SIMPLE_LENGTH_RE = /^(-?[0-9.]+)(px|rem|em|vh|vw|dvh|dvw|svh|svw|ch|%)$/;
|
||||
const VAR_ONLY_RE = /^var\((--[a-zA-Z0-9-]+)\)$/;
|
||||
const ENV_ONLY_RE = /^env\((safe-area-inset-[a-z]+)\)$/;
|
||||
const THEME_SPACING_RE = /theme\(spacing\.([0-9.]+)\)/g;
|
||||
|
||||
// Tailwind v4 default spacing scale: --spacing: 0.25rem (confirmed via Step 0:
|
||||
// no --spacing override exists in ui/src/index.css).
|
||||
const TAILWIND_SPACING_BASE_REM = 0.25;
|
||||
|
||||
function resolveThemeSpacing(raw) {
|
||||
// Resolves theme(spacing.N) tokens to their rem equivalents so the
|
||||
// expression can live inside a runtime CSS custom property (theme() is a
|
||||
// Tailwind build-time function and does not work at runtime).
|
||||
return raw.replace(THEME_SPACING_RE, (_m, n) => {
|
||||
const rem = Number(n) * TAILWIND_SPACING_BASE_REM;
|
||||
// Keep a leading zero (0.5rem, not .5rem) for readability; verified
|
||||
// byte-equivalent computed output vs Tailwind's own `.5rem` form.
|
||||
return `${rem}rem`;
|
||||
});
|
||||
}
|
||||
|
||||
function classifyBracketValue(raw, { kind, sourceNote }) {
|
||||
// 1) Bare var() passthrough — no new token, per DESIGN.md special case.
|
||||
const varOnly = raw.match(VAR_ONLY_RE);
|
||||
if (varOnly) return { tokenRef: varOnly[1], isVarOnly: true };
|
||||
|
||||
// 2) Bare env(safe-area-inset-*) passthrough — mint a --sz-safe-<edge> token.
|
||||
const envOnly = raw.match(ENV_ONLY_RE);
|
||||
if (envOnly) {
|
||||
const edge = envOnly[1].replace("safe-area-inset-", "");
|
||||
const name = registerSafeAreaToken(edge, `env(${envOnly[1]})`, sourceNote);
|
||||
return { tokenRef: `--${name}`, isVarOnly: false };
|
||||
}
|
||||
|
||||
// 3) Simple numeric length / percentage.
|
||||
const simple = raw.match(SIMPLE_LENGTH_RE);
|
||||
if (simple) {
|
||||
const [, num, unit] = simple;
|
||||
if (unit === "%") {
|
||||
const name = registerPctToken(num, sourceNote);
|
||||
return { tokenRef: `--${name}`, isVarOnly: false };
|
||||
}
|
||||
if (kind === "radius") {
|
||||
const name = registerRadToken(num, unit, sourceNote);
|
||||
return { tokenRef: `--${name}`, isVarOnly: false };
|
||||
}
|
||||
const name = registerSzToken(num, unit, sourceNote);
|
||||
return { tokenRef: `--${name}`, isVarOnly: false };
|
||||
}
|
||||
|
||||
// 4) Everything else: calc()/min()/max()/clamp()/env()-mixed compound
|
||||
// expressions. Reverse Tailwind's underscore-space escaping, normalize
|
||||
// calc operator spacing, resolve any theme(spacing.N) build-time calls,
|
||||
// and mint a sequential --sz-calc-N.
|
||||
let normalized = unescapeSpaces(raw);
|
||||
normalized = resolveThemeSpacing(normalized);
|
||||
normalized = normalizeCalcSpacing(normalized);
|
||||
const name = registerCalcToken(normalized, sourceNote);
|
||||
return { tokenRef: `--${name}`, isVarOnly: false };
|
||||
}
|
||||
|
||||
// ── Regexes for each utility family ──────────────────────────────────────
|
||||
// Every regex requires the utility to start at a genuine class-token
|
||||
// boundary — preceded by whitespace, a quote/backtick, template-literal
|
||||
// `${`, or the start of the string — NOT a bare `\b`, which would
|
||||
// false-positive inside compound utility names like
|
||||
// `slide-out-to-top-[1%]` (see BOUNDARY GOTCHA in the header comment).
|
||||
// Each captures: (1) optional `!important` prefix, (2) the utility name
|
||||
// (with any directional/axis suffix), (3) the raw bracket contents.
|
||||
// `:` is included because Tailwind variant prefixes (`sm:`, `dark:`,
|
||||
// `focus-visible:`, `data-[state=open]:`, etc.) always precede the utility
|
||||
// name with a colon — a safe class-token boundary, never part of a longer
|
||||
// utility's own name.
|
||||
const BOUNDARY = String.raw`(?<=^|[\s"'\`{:])`;
|
||||
|
||||
const UTILITIES = [
|
||||
// width/height/size family
|
||||
{ re: new RegExp(`${BOUNDARY}(!?)(w|h|size|min-w|max-w|min-h|max-h)-\\[([^\\]]+)\\]`, "g"), kind: "length" },
|
||||
// padding/margin family
|
||||
{ re: new RegExp(`${BOUNDARY}(!?)(p|pt|pb|pl|pr|px|py|m|mt|mb|ml|mr|mx|my)-\\[([^\\]]+)\\]`, "g"), kind: "length" },
|
||||
// gap family
|
||||
{ re: new RegExp(`${BOUNDARY}(!?)(gap|gap-x|gap-y)-\\[([^\\]]+)\\]`, "g"), kind: "length" },
|
||||
// inset / top / left / right / bottom family
|
||||
{ re: new RegExp(`${BOUNDARY}(!?)(inset-x|inset-y|inset|top|left|right|bottom)-\\[([^\\]]+)\\]`, "g"), kind: "length" },
|
||||
// translate family
|
||||
{ re: new RegExp(`${BOUNDARY}(!?)(translate-x|translate-y)-\\[([^\\]]+)\\]`, "g"), kind: "length" },
|
||||
];
|
||||
|
||||
// Radius: bare `rounded-[...]` and directional `rounded-t/r/b/l/tl/tr/bl/br-[...]`.
|
||||
// `rounded-[inherit]` is a KEYWORD (not a numeric literal) — per the batch
|
||||
// mandate, skip it (documented in the extraction log / allowlist below).
|
||||
const ROUNDED_RE = new RegExp(`${BOUNDARY}(!?)rounded(-(?:tl|tr|bl|br|t|r|b|l))?-\\[([^\\]]+)\\]`, "g");
|
||||
|
||||
// Shadow: bare `shadow-[...]`.
|
||||
const SHADOW_RE = new RegExp(`${BOUNDARY}(!?)shadow-\\[([^\\]]+)\\]`, "g");
|
||||
|
||||
// Ring width: bare `ring-[Npx]` (color-bracket forms like `ring-[#hex]` are
|
||||
// NOT touched here — this batch is size/spacing/radius/shadow only, and no
|
||||
// ring color-bracket sites exist in this codebase; verified during Step 0
|
||||
// inventory). Requires the `length:` hint per the Step-0 spike (see header).
|
||||
const RING_RE = new RegExp(`${BOUNDARY}(!?)ring-\\[([^\\]]+)\\]`, "g");
|
||||
|
||||
// Outline width (numeric only; no outline-[...] sites exist in this
|
||||
// codebase per Step 0 inventory, kept for forward-compatibility/completeness).
|
||||
const OUTLINE_RE = new RegExp(`${BOUNDARY}(!?)outline-\\[([^\\]]+)\\]`, "g");
|
||||
|
||||
function rewriteFile(filePath, relPath) {
|
||||
const original = readFileSync(filePath, "utf8");
|
||||
let content = original;
|
||||
let siteCount = 0;
|
||||
|
||||
for (const { re, kind } of UTILITIES) {
|
||||
content = content.replace(re, (match, bang, util, raw) => {
|
||||
const sourceNote = `Extracted from ${relPath} (${util}-[${raw}]).`;
|
||||
const { tokenRef } = classifyBracketValue(raw, { kind, sourceNote });
|
||||
siteCount++;
|
||||
return `${bang}${util}-(${tokenRef})`;
|
||||
});
|
||||
}
|
||||
|
||||
// Radius (needs its own hint-free paren form + inherit skip).
|
||||
content = content.replace(ROUNDED_RE, (match, bang, dir, raw) => {
|
||||
if (raw === "inherit") return match; // keyword, not a literal — skip (see log)
|
||||
const util = `rounded${dir || ""}`;
|
||||
const sourceNote = `Extracted from ${relPath} (${util}-[${raw}]).`;
|
||||
const { tokenRef } = classifyBracketValue(raw, { kind: "radius", sourceNote });
|
||||
siteCount++;
|
||||
return `${bang}${util}-(${tokenRef})`;
|
||||
});
|
||||
|
||||
// Shadow (always unescape underscores + normalize calc spacing inside any
|
||||
// embedded rgba()/hsl() var() args; bare paren form confirmed correct via
|
||||
// Step 0 spike — no `shadow:` hint needed).
|
||||
content = content.replace(SHADOW_RE, (match, bang, raw) => {
|
||||
const unescaped = unescapeSpaces(raw);
|
||||
const sourceNote = `Extracted from ${relPath} (shadow-[${raw}]).`;
|
||||
const name = registerShadowToken(unescaped, sourceNote);
|
||||
siteCount++;
|
||||
return `${bang}shadow-(--${name})`;
|
||||
});
|
||||
|
||||
// Ring width (length hint required — bare ring-(--x) is ambiguous with
|
||||
// the color-bracket form per the Step-0 spike).
|
||||
content = content.replace(RING_RE, (match, bang, raw) => {
|
||||
const sourceNote = `Extracted from ${relPath} (ring-[${raw}]).`;
|
||||
const { tokenRef, isVarOnly } = classifyBracketValue(raw, { kind: "radius", sourceNote });
|
||||
siteCount++;
|
||||
const hint = isVarOnly ? "" : "length:";
|
||||
return `${bang}ring-(${hint}${tokenRef})`;
|
||||
});
|
||||
|
||||
// Outline width (same length-hint treatment; 0 sites exist today).
|
||||
content = content.replace(OUTLINE_RE, (match, bang, raw) => {
|
||||
const sourceNote = `Extracted from ${relPath} (outline-[${raw}]).`;
|
||||
const { tokenRef, isVarOnly } = classifyBracketValue(raw, { kind: "radius", sourceNote });
|
||||
siteCount++;
|
||||
const hint = isVarOnly ? "" : "length:";
|
||||
return `${bang}outline-(${hint}${tokenRef})`;
|
||||
});
|
||||
|
||||
if (content !== original && !DRY_RUN) {
|
||||
writeFileSync(filePath, content, "utf8");
|
||||
}
|
||||
return { changed: content !== original, siteCount };
|
||||
}
|
||||
|
||||
function main() {
|
||||
const files = [];
|
||||
for (const dir of SCAN_DIRS) walk(resolve(UI_SRC, dir), files);
|
||||
files.sort();
|
||||
|
||||
let totalSites = 0;
|
||||
let filesChanged = 0;
|
||||
const changedFiles = [];
|
||||
|
||||
for (const filePath of files) {
|
||||
const relPath = "ui/src/" + relative(UI_SRC, filePath);
|
||||
const { changed, siteCount } = rewriteFile(filePath, relPath);
|
||||
if (changed) {
|
||||
filesChanged++;
|
||||
changedFiles.push(relPath);
|
||||
}
|
||||
totalSites += siteCount;
|
||||
}
|
||||
|
||||
// ── index.css token block ──────────────────────────────────────────
|
||||
const cssPath = resolve(UI_SRC, "index.css");
|
||||
const cssOriginal = readFileSync(cssPath, "utf8");
|
||||
const marker = "/* ── Extracted verbatim SIZE/SPACING/RADIUS/SHADOW tokens (Phase 2 Batch 3, design/token-extraction) ── */";
|
||||
let cssNext = cssOriginal;
|
||||
let cssChanged = false;
|
||||
|
||||
const anyTokens = szTokens.size || radTokens.size || pctTokens.size || shadowTokens.size;
|
||||
if (!cssOriginal.includes(marker) && anyTokens) {
|
||||
const lines = [];
|
||||
lines.push(marker);
|
||||
lines.push("/* Batch 3/4: width/height/min/max, padding/margin/gap/inset/translate,");
|
||||
lines.push(" radius, ring/outline-width, and shadow literals, verbatim (no");
|
||||
lines.push(" normalizing — the human scale-collapse decision comes later per");
|
||||
lines.push(" DESIGN.md/TOKEN-AUDIT.md). --sz-* is ONE shared family across");
|
||||
lines.push(" w/h/p/m/gap/inset/translate so identical values dedupe regardless of");
|
||||
lines.push(" which property used them. --rad-* is likewise shared across");
|
||||
// NOTE: never write a literal "*/" sequence in this prose (e.g. from a
|
||||
// "rounded-*" + "/ring" join) — it prematurely closes this CSS block
|
||||
// comment and silently corrupts everything after it (caught during this
|
||||
// batch's verification: the entire --sz-*/--rad-*/--pct-*/--shadow-*
|
||||
// :root block was being dropped from the built CSS because of exactly
|
||||
// this). Always phrase such utility-family lists with "and" instead of
|
||||
// a bare slash-adjacent asterisk.
|
||||
lines.push(" rounded, ring, and outline widths.");
|
||||
lines.push("");
|
||||
lines.push(" Allowlist (sites intentionally left as hardcoded / functional literals");
|
||||
lines.push(" or var()-only passthrough with no new token minted):");
|
||||
lines.push(" - components/ui/scroll-area.tsx (rounded-[inherit]) — CSS keyword, not a");
|
||||
lines.push(" literal value; nothing to extract.");
|
||||
lines.push(" - Bracket values that only wrap var(--radix-*-trigger-width/height) or");
|
||||
lines.push(" var(--new-issue-dialog-height) etc. are rewritten to the bare paren");
|
||||
lines.push(" form directly (e.g. w-(--radix-popover-trigger-width)) — these are");
|
||||
lines.push(" runtime library/component variables, not design values, so no new");
|
||||
lines.push(" --sz-* token is minted for them (see TOKEN-AUDIT.md extraction log");
|
||||
lines.push(" for the full site list).");
|
||||
lines.push("*/");
|
||||
lines.push(":root {");
|
||||
for (const [name, { value, comment }] of szTokens) {
|
||||
lines.push(` --${name}: ${value}; /* ${comment} */`);
|
||||
}
|
||||
for (const [name, { value, comment }] of radTokens) {
|
||||
lines.push(` --${name}: ${value}; /* ${comment} */`);
|
||||
}
|
||||
for (const [name, { value, comment }] of pctTokens) {
|
||||
lines.push(` --${name}: ${value}; /* ${comment} */`);
|
||||
}
|
||||
for (const [name, { value, comment }] of shadowTokens) {
|
||||
lines.push(` --${name}: ${value}; /* ${comment} */`);
|
||||
}
|
||||
lines.push("}");
|
||||
const block = "\n" + lines.join("\n") + "\n";
|
||||
cssNext = cssOriginal + block;
|
||||
cssChanged = true;
|
||||
}
|
||||
|
||||
if (cssChanged && !DRY_RUN) writeFileSync(cssPath, cssNext, "utf8");
|
||||
|
||||
// ── Summary ─────────────────────────────────────────────────────────
|
||||
console.log(`\n${DRY_RUN ? "[DRY RUN] " : ""}codemod-extract-sizes summary`);
|
||||
console.log(` Sites rewritten: ${totalSites}`);
|
||||
console.log(` Files changed: ${filesChanged}`);
|
||||
console.log(` New --sz-* tokens: ${szTokens.size}`);
|
||||
console.log(` New --rad-* tokens: ${radTokens.size}`);
|
||||
console.log(` New --pct-* tokens: ${pctTokens.size}`);
|
||||
console.log(` New --shadow-extract-*: ${shadowTokens.size}`);
|
||||
console.log(` index.css token block: ${cssChanged ? "added" : "already present or nothing to add (idempotent no-op)"}`);
|
||||
if (changedFiles.length) {
|
||||
console.log(`\n Changed files:`);
|
||||
for (const f of changedFiles) console.log(` - ${f}`);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,239 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* codemod-extract-type.mjs
|
||||
*
|
||||
* Phase 2 (extraction), Batch 2/4 of the design-token audit
|
||||
* (branch design/token-extraction). Replaces hardcoded TYPE values —
|
||||
* arbitrary Tailwind font-size (`text-[11px]`), letter-spacing
|
||||
* (`tracking-[0.18em]`), line-height (`leading-[...]`), and raw inline
|
||||
* `fontSize` style literals — in `ui/src/components/**` and
|
||||
* `ui/src/pages/**` (including their *.test.tsx companions) with
|
||||
* references to CSS custom-property tokens defined in `ui/src/index.css`.
|
||||
*
|
||||
* Unlike Batch 1's color codemod (which used a hand-audited site table to
|
||||
* avoid false-positiving on non-color hex-like strings such as issue
|
||||
* references), this batch's patterns are unambiguous: `text-[Npx]`,
|
||||
* `text-[N.Nrem]`, `tracking-[N em]`, and `leading-[...]` inside Tailwind
|
||||
* class strings, and `fontSize: "Npx"` / `fontSize: "N.Nrem"` inline-style
|
||||
* string literals, cannot mean anything other than a type-size/spacing
|
||||
* value. A blanket regex sweep is therefore safe and is used here, scoped
|
||||
* to `ui/src/components/**` and `ui/src/pages/**` only. Numeric or
|
||||
* computed `fontSize` forms (e.g. `fontSize: 12`, `fontSize: Math.round(...)`)
|
||||
* are functional (third-party config objects / runtime-computed values)
|
||||
* and are left untouched — see ALLOWLIST_NOTES below.
|
||||
*
|
||||
* Token naming (verbatim value, no normalizing):
|
||||
* --fs-<N> font-size, px values, e.g. --fs-11: 11px;
|
||||
* --fs-0_<N>rem font-size, rem values, e.g. --fs-0_7rem: 0.7rem;
|
||||
* --ls-0_<N> letter-spacing, em values, e.g. --ls-0_18: 0.18em;
|
||||
* --lh-<N> line-height (px or unitless — none found this batch)
|
||||
*
|
||||
* Tailwind v4 paren-shorthand rewrite forms used:
|
||||
* text-[Npx] -> text-(length:--fs-N) (length hint REQUIRED —
|
||||
* bare text-(--x) means color)
|
||||
* tracking-[N em] -> tracking-(--ls-0_N) (unambiguous, no hint)
|
||||
* leading-[...] -> leading-(--lh-N) (unambiguous, no hint)
|
||||
* All variant/modifier prefixes (`sm:`, `dark:`, `group-hover:`,
|
||||
* `[&>x]:`, trailing `!important` marker, etc.) are preserved verbatim —
|
||||
* the regex only rewrites the bracket portion itself.
|
||||
*
|
||||
* Idempotent: the FIND regex only matches the ORIGINAL bracket-literal
|
||||
* form (`text-[11px]` etc.); once rewritten to `text-(length:--fs-11)` the
|
||||
* pattern no longer matches, so re-running is a no-op. The inline-style
|
||||
* FIND is likewise the literal `fontSize: "11px"` string form.
|
||||
*
|
||||
* Usage: node scripts/codemod-extract-type.mjs [--check]
|
||||
* --check Report what WOULD change without writing files (dry run).
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { resolve, dirname, join, relative } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = resolve(__dirname, "..");
|
||||
const UI_SRC = resolve(REPO_ROOT, "ui/src");
|
||||
const SCAN_DIRS = ["components", "pages"];
|
||||
|
||||
const DRY_RUN = process.argv.includes("--check");
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────
|
||||
function walk(dir, out) {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const p = join(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(p, out);
|
||||
else if (/\.(tsx?|jsx?)$/.test(entry.name)) out.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
function tokenSuffixForPx(value) {
|
||||
// "11" -> "11", "0.65" -> "0_65"
|
||||
return value.replace(".", "_");
|
||||
}
|
||||
|
||||
function tokenSuffixForEm(value) {
|
||||
// "0.18" -> "0_18"
|
||||
return value.replace(".", "_");
|
||||
}
|
||||
|
||||
// ── Token registries (populated as sites are discovered) ───────────────
|
||||
// Map of token name (without --) -> { value, comment, kind }
|
||||
const fsTokens = new Map(); // font-size
|
||||
const lsTokens = new Map(); // letter-spacing
|
||||
const lhTokens = new Map(); // line-height
|
||||
|
||||
function registerFsToken(rawValue, unit, sourceNote) {
|
||||
const name = unit === "px" ? `fs-${tokenSuffixForPx(rawValue)}` : `fs-${tokenSuffixForPx(rawValue)}rem`;
|
||||
if (!fsTokens.has(name)) {
|
||||
fsTokens.set(name, { value: `${rawValue}${unit}`, comment: sourceNote });
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
function registerLsToken(rawValue, sourceNote) {
|
||||
const name = `ls-${tokenSuffixForEm(rawValue)}`;
|
||||
if (!lsTokens.has(name)) {
|
||||
lsTokens.set(name, { value: `${rawValue}em`, comment: sourceNote });
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
function registerLhToken(rawValue, sourceNote) {
|
||||
// rawValue includes unit already stripped by caller; store as given
|
||||
const safeName = rawValue.replace(/[^a-zA-Z0-9]/g, "_");
|
||||
const name = `lh-${safeName}`;
|
||||
if (!lhTokens.has(name)) {
|
||||
lhTokens.set(name, { value: rawValue, comment: sourceNote });
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
// ── Regexes ──────────────────────────────────────────────────────────
|
||||
// text-[11px], text-[0.65rem], with optional /[Npx] line-height suffix
|
||||
// (none found in this codebase, but handled for completeness/future-proofing).
|
||||
const FS_RE = /text-\[([0-9.]+)(px|rem)\](?:\/\[([0-9.]+)(px|rem)\])?/g;
|
||||
const LS_RE = /tracking-\[([0-9.]+)em\]/g;
|
||||
const LEADING_RE = /leading-\[([^\]]+)\]/g;
|
||||
const FONTSIZE_STYLE_RE = /fontSize:\s*"([0-9.]+)(px|rem)"/g;
|
||||
|
||||
function rewriteFile(filePath, relPath) {
|
||||
const original = readFileSync(filePath, "utf8");
|
||||
let content = original;
|
||||
let siteCount = 0;
|
||||
|
||||
// -- font-size Tailwind class utilities --
|
||||
content = content.replace(FS_RE, (match, num, unit, lhNum, lhUnit) => {
|
||||
const fsName = registerFsToken(num, unit, `Extracted from ${relPath} (text-[${num}${unit}]).`);
|
||||
let replacement = `text-(length:--${fsName})`;
|
||||
if (lhNum) {
|
||||
const lhName = registerLhToken(`${lhNum}${lhUnit}`, `Extracted from ${relPath} (text-[...]/[${lhNum}${lhUnit}] line-height suffix).`);
|
||||
replacement += `/(--${lhName})`;
|
||||
}
|
||||
siteCount++;
|
||||
return replacement;
|
||||
});
|
||||
|
||||
// -- letter-spacing Tailwind class utilities --
|
||||
content = content.replace(LS_RE, (match, num) => {
|
||||
const lsName = registerLsToken(num, `Extracted from ${relPath} (tracking-[${num}em]).`);
|
||||
siteCount++;
|
||||
return `tracking-(--${lsName})`;
|
||||
});
|
||||
|
||||
// -- line-height Tailwind class utilities (standalone leading-[...]) --
|
||||
content = content.replace(LEADING_RE, (match, raw) => {
|
||||
// Only rewrite numeric/unit literals (px, rem, unitless number). Skip
|
||||
// keyword forms like leading-[inherit] or var()-based (already tokenized).
|
||||
if (!/^[0-9.]+(px|rem)?$/.test(raw)) return match;
|
||||
const lhName = registerLhToken(raw, `Extracted from ${relPath} (leading-[${raw}]).`);
|
||||
siteCount++;
|
||||
return `leading-(--${lhName})`;
|
||||
});
|
||||
|
||||
// -- inline style fontSize string literals --
|
||||
content = content.replace(FONTSIZE_STYLE_RE, (match, num, unit) => {
|
||||
const fsName = registerFsToken(num, unit, `Extracted from ${relPath} (inline style fontSize: "${num}${unit}").`);
|
||||
siteCount++;
|
||||
return `fontSize: "var(--${fsName})"`;
|
||||
});
|
||||
|
||||
if (content !== original && !DRY_RUN) {
|
||||
writeFileSync(filePath, content, "utf8");
|
||||
}
|
||||
return { changed: content !== original, siteCount };
|
||||
}
|
||||
|
||||
function main() {
|
||||
const files = [];
|
||||
for (const dir of SCAN_DIRS) walk(resolve(UI_SRC, dir), files);
|
||||
|
||||
let totalSites = 0;
|
||||
let filesChanged = 0;
|
||||
const changedFiles = [];
|
||||
|
||||
for (const filePath of files) {
|
||||
const relPath = "ui/src/" + relative(UI_SRC, filePath);
|
||||
const { changed, siteCount } = rewriteFile(filePath, relPath);
|
||||
if (changed) {
|
||||
filesChanged++;
|
||||
changedFiles.push(relPath);
|
||||
}
|
||||
totalSites += siteCount;
|
||||
}
|
||||
|
||||
// ── index.css token block ──────────────────────────────────────────
|
||||
const cssPath = resolve(UI_SRC, "index.css");
|
||||
const cssOriginal = readFileSync(cssPath, "utf8");
|
||||
const marker = "/* ── Extracted verbatim TYPE tokens (Phase 2 Batch 2, design/token-extraction) ── */";
|
||||
let cssNext = cssOriginal;
|
||||
let cssChanged = false;
|
||||
|
||||
if (!cssOriginal.includes(marker) && (fsTokens.size || lsTokens.size || lhTokens.size)) {
|
||||
const lines = [];
|
||||
lines.push(marker);
|
||||
lines.push("/* Batch 2/4: font-size + letter-spacing + line-height literals, verbatim");
|
||||
lines.push(" (no normalizing — 9/10/11/12/13/14/15px and 0.08-0.24em all stay distinct;");
|
||||
lines.push(" the human scale-collapse decision comes later per DESIGN.md/TOKEN-AUDIT.md).");
|
||||
lines.push("");
|
||||
lines.push(" Allowlist (sites intentionally left as hardcoded / functional literals,");
|
||||
lines.push(" NOT converted to tokens — each also carries an inline");
|
||||
lines.push(" `token-extraction: allowlisted` comment at the site):");
|
||||
lines.push(" - pages/CompanyEnvironments.tsx (fontSize: 12) — xterm.js terminal theme");
|
||||
lines.push(" config; functional third-party numeric option, not a rendered CSS value.");
|
||||
lines.push(" Same allowlisted object as Batch 1's color entry for this file.");
|
||||
lines.push(" - pages/CompanySkills.tsx (fontSize: Math.round(size * 0.42)) — computed at");
|
||||
lines.push(" runtime from a prop; not a static literal, nothing to extract.");
|
||||
lines.push("*/");
|
||||
lines.push(":root {");
|
||||
for (const [name, { value, comment }] of fsTokens) {
|
||||
lines.push(` --${name}: ${value}; /* ${comment} */`);
|
||||
}
|
||||
for (const [name, { value, comment }] of lsTokens) {
|
||||
lines.push(` --${name}: ${value}; /* ${comment} */`);
|
||||
}
|
||||
for (const [name, { value, comment }] of lhTokens) {
|
||||
lines.push(` --${name}: ${value}; /* ${comment} */`);
|
||||
}
|
||||
lines.push("}");
|
||||
const block = "\n" + lines.join("\n") + "\n";
|
||||
cssNext = cssOriginal + block;
|
||||
cssChanged = true;
|
||||
}
|
||||
|
||||
if (cssChanged && !DRY_RUN) writeFileSync(cssPath, cssNext, "utf8");
|
||||
|
||||
// ── Summary ─────────────────────────────────────────────────────────
|
||||
console.log(`\n${DRY_RUN ? "[DRY RUN] " : ""}codemod-extract-type summary`);
|
||||
console.log(` Sites rewritten: ${totalSites}`);
|
||||
console.log(` Files changed: ${filesChanged}`);
|
||||
console.log(` New --fs-* tokens: ${fsTokens.size}`);
|
||||
console.log(` New --ls-* tokens: ${lsTokens.size}`);
|
||||
console.log(` New --lh-* tokens: ${lhTokens.size}`);
|
||||
console.log(` index.css token block: ${cssChanged ? "added" : "already present or nothing to add (idempotent no-op)"}`);
|
||||
if (changedFiles.length) {
|
||||
console.log(`\n Changed files:`);
|
||||
for (const f of changedFiles) console.log(` - ${f}`);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,197 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* codemod-type-ladder.mjs
|
||||
*
|
||||
* DECISION-SHEET.md B3 (user-locked, preset-tune session): collapse the
|
||||
* Batch 2 verbatim type tokens into the named ladder.
|
||||
*
|
||||
* FONT SIZES (8 --fs-* tokens -> 3 named tokens + 2 Tailwind scale classes):
|
||||
* --fs-9, --fs-10 -> --text-nano: 10px (9 -> 10 bump)
|
||||
* --fs-11, --fs-0_7rem -> --text-micro: 11px (0.7rem = 11.2px -> 11px)
|
||||
* --fs-12 -> Tailwind `text-xs` (12px exact match;
|
||||
* scale class preferred over token per DESIGN.md)
|
||||
* --fs-13 -> --text-compact: 13px
|
||||
* --fs-14, --fs-15 -> Tailwind `text-sm` (14px; 15 -> 14)
|
||||
*
|
||||
* LETTER-SPACING (9 --ls-* tokens -> 3 named steps, nearest-step mapping):
|
||||
* 0.08em, 0.1em -> --tracking-label: 0.08em
|
||||
* 0.12em, 0.14em, 0.16em -> --tracking-eyebrow: 0.14em
|
||||
* 0.18em, 0.2em, 0.22em, 0.24em -> --tracking-caps: 0.2em
|
||||
*
|
||||
* The codemod rewrites every site under ui/src (components, pages, lib,
|
||||
* context, plugins — all .ts/.tsx/.js/.jsx), replaces the --fs-* / --ls-*
|
||||
* definitions in ui/src/index.css with the named-ladder block, and fails
|
||||
* loudly if any --fs-* / --ls-* reference survives (e.g. a var(--fs-12) site,
|
||||
* which has no token replacement because that bucket maps to a Tailwind
|
||||
* scale class and would need a manual decision).
|
||||
*
|
||||
* Replacements are exact strings INCLUDING the closing paren, so prefix
|
||||
* collisions (--ls-0_1 vs --ls-0_12, --fs-1* families) cannot mis-match.
|
||||
*
|
||||
* IDEMPOTENT: a second run finds no old references and the ladder marker
|
||||
* already present in index.css, and changes nothing.
|
||||
*
|
||||
* Usage: node scripts/codemod-type-ladder.mjs
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, readdirSync } from "node:fs";
|
||||
import { resolve, dirname, join, relative } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = resolve(__dirname, "..");
|
||||
const UI_SRC = resolve(REPO_ROOT, "ui/src");
|
||||
const CSS_PATH = resolve(UI_SRC, "index.css");
|
||||
|
||||
// ── Site replacement map (exact strings, closing paren included) ─────────
|
||||
const SITE_MAP = new Map([
|
||||
// font sizes — Tailwind utility form (Batch 2 syntax)
|
||||
["text-(length:--fs-9)", "text-(length:--text-nano)"],
|
||||
["text-(length:--fs-10)", "text-(length:--text-nano)"],
|
||||
["text-(length:--fs-11)", "text-(length:--text-micro)"],
|
||||
["text-(length:--fs-0_7rem)", "text-(length:--text-micro)"],
|
||||
["text-(length:--fs-12)", "text-xs"],
|
||||
["text-(length:--fs-13)", "text-(length:--text-compact)"],
|
||||
["text-(length:--fs-14)", "text-sm"],
|
||||
["text-(length:--fs-15)", "text-sm"],
|
||||
// font sizes — inline-style var() form
|
||||
["var(--fs-9)", "var(--text-nano)"],
|
||||
["var(--fs-10)", "var(--text-nano)"],
|
||||
["var(--fs-11)", "var(--text-micro)"],
|
||||
["var(--fs-0_7rem)", "var(--text-micro)"],
|
||||
["var(--fs-13)", "var(--text-compact)"],
|
||||
// (var(--fs-12/14/15) intentionally absent: those buckets map to Tailwind
|
||||
// scale classes; any such site trips the leftover guard for manual review)
|
||||
// letter-spacing — Tailwind utility form
|
||||
["tracking-(--ls-0_08)", "tracking-(--tracking-label)"],
|
||||
["tracking-(--ls-0_1)", "tracking-(--tracking-label)"],
|
||||
["tracking-(--ls-0_12)", "tracking-(--tracking-eyebrow)"],
|
||||
["tracking-(--ls-0_14)", "tracking-(--tracking-eyebrow)"],
|
||||
["tracking-(--ls-0_16)", "tracking-(--tracking-eyebrow)"],
|
||||
["tracking-(--ls-0_18)", "tracking-(--tracking-caps)"],
|
||||
["tracking-(--ls-0_2)", "tracking-(--tracking-caps)"],
|
||||
["tracking-(--ls-0_22)", "tracking-(--tracking-caps)"],
|
||||
["tracking-(--ls-0_24)", "tracking-(--tracking-caps)"],
|
||||
// letter-spacing — inline-style var() form
|
||||
["var(--ls-0_08)", "var(--tracking-label)"],
|
||||
["var(--ls-0_1)", "var(--tracking-label)"],
|
||||
["var(--ls-0_12)", "var(--tracking-eyebrow)"],
|
||||
["var(--ls-0_14)", "var(--tracking-eyebrow)"],
|
||||
["var(--ls-0_16)", "var(--tracking-eyebrow)"],
|
||||
["var(--ls-0_18)", "var(--tracking-caps)"],
|
||||
["var(--ls-0_2)", "var(--tracking-caps)"],
|
||||
["var(--ls-0_22)", "var(--tracking-caps)"],
|
||||
["var(--ls-0_24)", "var(--tracking-caps)"],
|
||||
]);
|
||||
|
||||
const LADDER_MARKER = "Named type ladder (DECISION-SHEET.md B3";
|
||||
const LADDER_BLOCK = ` /* ── Named type ladder (DECISION-SHEET.md B3, preset-tune session) ──
|
||||
Collapses the 8 verbatim --fs-* font-size tokens and 9 --ls-*
|
||||
letter-spacing tokens (extracted in Batch 2 above) into named steps.
|
||||
Codemod: scripts/codemod-type-ladder.mjs. Mapping:
|
||||
9px + 10px -> --text-nano (9 -> 10 bump per locked ladder)
|
||||
11px + 0.7rem -> --text-micro (0.7rem = 11.2px -> 11px)
|
||||
12px -> Tailwind \`text-xs\` class (12px exact match; scale
|
||||
class preferred over a redundant token per DESIGN.md)
|
||||
13px -> --text-compact (PRIOR-ART named this tier "sm 13",
|
||||
but that collides with Tailwind text-sm = 14px,
|
||||
hence "compact")
|
||||
14px + 15px -> Tailwind \`text-sm\` class (14px; 15 -> 14)
|
||||
Letter-spacing, nearest-step mapping:
|
||||
0.08em, 0.1em -> --tracking-label
|
||||
0.12em, 0.14em, 0.16em -> --tracking-eyebrow
|
||||
0.18em, 0.2em, 0.22em, 0.24em -> --tracking-caps
|
||||
NOTE: sites moved to text-xs/text-sm also pick up the Tailwind scale
|
||||
line-height (text-(length:--x) set font-size only) — intentional,
|
||||
reviewed on the post-preset contact sheet. */
|
||||
--text-nano: 10px;
|
||||
--text-micro: 11px;
|
||||
--text-compact: 13px;
|
||||
--tracking-label: 0.08em;
|
||||
--tracking-eyebrow: 0.14em;
|
||||
--tracking-caps: 0.2em;`;
|
||||
|
||||
// ── Walk ui/src ───────────────────────────────────────────────────────────
|
||||
function walk(dir, out) {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const p = join(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(p, out);
|
||||
else if (/\.(tsx?|jsx?)$/.test(entry.name)) out.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
const files = [];
|
||||
walk(UI_SRC, files);
|
||||
files.sort();
|
||||
|
||||
// ── Pass 1: rewrite sites ─────────────────────────────────────────────────
|
||||
const bucketCounts = new Map();
|
||||
let filesTouched = 0;
|
||||
for (const f of files) {
|
||||
const before = readFileSync(f, "utf8");
|
||||
let after = before;
|
||||
for (const [oldStr, newStr] of SITE_MAP) {
|
||||
if (!after.includes(oldStr)) continue;
|
||||
const n = after.split(oldStr).length - 1;
|
||||
after = after.split(oldStr).join(newStr);
|
||||
bucketCounts.set(oldStr, (bucketCounts.get(oldStr) ?? 0) + n);
|
||||
}
|
||||
if (after !== before) {
|
||||
writeFileSync(f, after);
|
||||
filesTouched++;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pass 2: index.css — swap definitions for the ladder block ────────────
|
||||
let css = readFileSync(CSS_PATH, "utf8");
|
||||
if (!css.includes(LADDER_MARKER)) {
|
||||
const lines = css.split("\n");
|
||||
const DEF_RE = /^\s*--(?:fs-[0-9a-z_]+|ls-[0-9_]+):/;
|
||||
const firstDef = lines.findIndex((l) => DEF_RE.test(l));
|
||||
if (firstDef === -1) {
|
||||
console.error("ERROR: no --fs-* / --ls-* definitions found and ladder marker absent — index.css in unexpected state.");
|
||||
process.exit(1);
|
||||
}
|
||||
const kept = lines.filter((l) => !DEF_RE.test(l));
|
||||
// Insert the ladder block at the position of the first removed definition:
|
||||
// count how many kept lines precede the first definition line.
|
||||
let precede = 0;
|
||||
for (let i = 0; i < firstDef; i++) if (!DEF_RE.test(lines[i])) precede++;
|
||||
kept.splice(precede, 0, LADDER_BLOCK);
|
||||
css = kept.join("\n");
|
||||
writeFileSync(CSS_PATH, css);
|
||||
console.log("index.css: --fs-* / --ls-* definitions replaced with named ladder block");
|
||||
} else {
|
||||
console.log("index.css: ladder marker already present — skipped (idempotent)");
|
||||
}
|
||||
|
||||
// ── Guard: no survivors anywhere in ui/src (incl. index.css) ─────────────
|
||||
const SURVIVOR_RE = /--(?:fs-[0-9a-z_]+|ls-[0-9_]+)/;
|
||||
const survivors = [];
|
||||
for (const f of [...files, CSS_PATH]) {
|
||||
const content = readFileSync(f, "utf8");
|
||||
const lines = content.split("\n");
|
||||
lines.forEach((l, i) => {
|
||||
if (SURVIVOR_RE.test(l)) survivors.push(`${relative(REPO_ROOT, f)}:${i + 1}: ${l.trim()}`);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Report ────────────────────────────────────────────────────────────────
|
||||
const bucketTotals = {};
|
||||
for (const [oldStr, n] of bucketCounts) {
|
||||
const target = SITE_MAP.get(oldStr);
|
||||
bucketTotals[target] = (bucketTotals[target] ?? 0) + n;
|
||||
}
|
||||
console.log("Sites rewritten per source form:");
|
||||
for (const [oldStr, n] of [...bucketCounts.entries()].sort()) {
|
||||
console.log(` ${oldStr} -> ${SITE_MAP.get(oldStr)} (${n})`);
|
||||
}
|
||||
console.log("Totals per target bucket:", JSON.stringify(bucketTotals, null, 2));
|
||||
console.log(`Files touched: ${filesTouched}`);
|
||||
|
||||
if (survivors.length > 0) {
|
||||
console.error(`\nERROR: ${survivors.length} leftover --fs-* / --ls-* reference(s) need manual review:`);
|
||||
for (const s of survivors) console.error(" " + s);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("No leftover --fs-* / --ls-* references. Done.");
|
||||
Executable
+99
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
# shellcheck source=./release-lib.sh
|
||||
. "$REPO_ROOT/scripts/release-lib.sh"
|
||||
|
||||
dry_run=false
|
||||
version=""
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
./scripts/create-github-release.sh <version> [--dry-run]
|
||||
|
||||
Examples:
|
||||
./scripts/create-github-release.sh 2026.318.0
|
||||
./scripts/create-github-release.sh 2026.318.0 --dry-run
|
||||
|
||||
Notes:
|
||||
- Run this after pushing the stable tag.
|
||||
- Resolves the git remote automatically.
|
||||
- In GitHub Actions, origin is used explicitly.
|
||||
- If the release already exists, this script updates its title and notes.
|
||||
EOF
|
||||
}
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--dry-run) dry_run=true ;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
if [ -n "$version" ]; then
|
||||
echo "Error: only one version may be provided." >&2
|
||||
exit 1
|
||||
fi
|
||||
version="$1"
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
if [ -z "$version" ]; then
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "Error: version must be a stable calendar version like 2026.318.0." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
tag="v$version"
|
||||
notes_file="$REPO_ROOT/releases/${tag}.md"
|
||||
if [ "${GITHUB_ACTIONS:-}" = "true" ] && [ -z "${PUBLISH_REMOTE:-}" ] && git_remote_exists origin; then
|
||||
PUBLISH_REMOTE=origin
|
||||
fi
|
||||
PUBLISH_REMOTE="$(resolve_release_remote)"
|
||||
if ! command -v gh >/dev/null 2>&1; then
|
||||
echo "Error: gh CLI is required to create GitHub releases." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
GITHUB_REPO="$(github_repo_from_remote "$PUBLISH_REMOTE" || true)"
|
||||
if [ -z "$GITHUB_REPO" ]; then
|
||||
echo "Error: could not determine GitHub repository from remote $PUBLISH_REMOTE." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$notes_file" ]; then
|
||||
echo "Error: release notes file not found at $notes_file." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! git -C "$REPO_ROOT" rev-parse "$tag" >/dev/null 2>&1; then
|
||||
echo "Error: local git tag $tag does not exist." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$dry_run" = true ]; then
|
||||
echo "[dry-run] gh release create $tag -R $GITHUB_REPO --title $tag --notes-file $notes_file"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! git -C "$REPO_ROOT" ls-remote --exit-code --tags "$PUBLISH_REMOTE" "refs/tags/$tag" >/dev/null 2>&1; then
|
||||
echo "Error: remote tag $tag was not found on $PUBLISH_REMOTE. Push the release commit and tag first." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if gh release view "$tag" -R "$GITHUB_REPO" >/dev/null 2>&1; then
|
||||
gh release edit "$tag" -R "$GITHUB_REPO" --title "$tag" --notes-file "$notes_file"
|
||||
echo "Updated GitHub Release $tag"
|
||||
else
|
||||
gh release create "$tag" -R "$GITHUB_REPO" --title "$tag" --notes-file "$notes_file"
|
||||
echo "Created GitHub Release $tag"
|
||||
fi
|
||||
@@ -0,0 +1,93 @@
|
||||
const DEFAULT_CAPTURED_OUTPUT_BYTES = 256 * 1024;
|
||||
const DEFAULT_JSON_RESPONSE_BYTES = 64 * 1024;
|
||||
|
||||
function normalizeByteLimit(maxBytes) {
|
||||
return Math.max(1, Math.trunc(maxBytes));
|
||||
}
|
||||
|
||||
export function createCapturedOutputBuffer(maxBytes = DEFAULT_CAPTURED_OUTPUT_BYTES) {
|
||||
const limit = normalizeByteLimit(maxBytes);
|
||||
const chunks = [];
|
||||
let bufferedBytes = 0;
|
||||
let totalBytes = 0;
|
||||
let truncated = false;
|
||||
|
||||
return {
|
||||
append(chunk) {
|
||||
if (chunk === null || chunk === undefined) return;
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
if (buffer.length === 0) return;
|
||||
|
||||
chunks.push(buffer);
|
||||
bufferedBytes += buffer.length;
|
||||
totalBytes += buffer.length;
|
||||
|
||||
while (bufferedBytes > limit && chunks.length > 0) {
|
||||
const overflow = bufferedBytes - limit;
|
||||
const head = chunks[0];
|
||||
if (head.length <= overflow) {
|
||||
chunks.shift();
|
||||
bufferedBytes -= head.length;
|
||||
truncated = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
chunks[0] = head.subarray(overflow);
|
||||
bufferedBytes -= overflow;
|
||||
truncated = true;
|
||||
}
|
||||
},
|
||||
|
||||
finish() {
|
||||
const body = Buffer.concat(chunks).toString("utf8");
|
||||
if (!truncated) {
|
||||
return {
|
||||
text: body,
|
||||
truncated,
|
||||
totalBytes,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
text: `[output truncated to last ${limit} bytes; total ${totalBytes} bytes]\n${body}`,
|
||||
truncated,
|
||||
totalBytes,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function parseJsonResponseWithLimit(response, maxBytes = DEFAULT_JSON_RESPONSE_BYTES) {
|
||||
const limit = normalizeByteLimit(maxBytes);
|
||||
const contentLength = Number.parseInt(response.headers.get("content-length") ?? "", 10);
|
||||
if (Number.isFinite(contentLength) && contentLength > limit) {
|
||||
throw new Error(`Response exceeds ${limit} bytes`);
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
return JSON.parse("");
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let text = "";
|
||||
let totalBytes = 0;
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
totalBytes += value.byteLength;
|
||||
if (totalBytes > limit) {
|
||||
await reader.cancel("response too large");
|
||||
throw new Error(`Response exceeds ${limit} bytes`);
|
||||
}
|
||||
text += decoder.decode(value, { stream: true });
|
||||
}
|
||||
text += decoder.decode();
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
return JSON.parse(text);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
const DEFAULT_CAPTURED_OUTPUT_BYTES = 256 * 1024;
|
||||
const DEFAULT_JSON_RESPONSE_BYTES = 64 * 1024;
|
||||
|
||||
export type CapturedOutput = {
|
||||
text: string;
|
||||
truncated: boolean;
|
||||
totalBytes: number;
|
||||
};
|
||||
|
||||
function normalizeByteLimit(maxBytes: number) {
|
||||
return Math.max(1, Math.trunc(maxBytes));
|
||||
}
|
||||
|
||||
export function createCapturedOutputBuffer(maxBytes = DEFAULT_CAPTURED_OUTPUT_BYTES) {
|
||||
const limit = normalizeByteLimit(maxBytes);
|
||||
const chunks: Buffer[] = [];
|
||||
let bufferedBytes = 0;
|
||||
let totalBytes = 0;
|
||||
let truncated = false;
|
||||
|
||||
return {
|
||||
append(chunk: Buffer | string | null | undefined) {
|
||||
if (chunk === null || chunk === undefined) return;
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
if (buffer.length === 0) return;
|
||||
|
||||
chunks.push(buffer);
|
||||
bufferedBytes += buffer.length;
|
||||
totalBytes += buffer.length;
|
||||
|
||||
while (bufferedBytes > limit && chunks.length > 0) {
|
||||
const overflow = bufferedBytes - limit;
|
||||
const head = chunks[0]!;
|
||||
if (head.length <= overflow) {
|
||||
chunks.shift();
|
||||
bufferedBytes -= head.length;
|
||||
truncated = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
chunks[0] = head.subarray(overflow);
|
||||
bufferedBytes -= overflow;
|
||||
truncated = true;
|
||||
}
|
||||
},
|
||||
|
||||
finish(): CapturedOutput {
|
||||
const body = Buffer.concat(chunks).toString("utf8");
|
||||
if (!truncated) {
|
||||
return {
|
||||
text: body,
|
||||
truncated,
|
||||
totalBytes,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
text: `[output truncated to last ${limit} bytes; total ${totalBytes} bytes]\n${body}`,
|
||||
truncated,
|
||||
totalBytes,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function parseJsonResponseWithLimit<T>(
|
||||
response: Response,
|
||||
maxBytes = DEFAULT_JSON_RESPONSE_BYTES,
|
||||
): Promise<T> {
|
||||
const limit = normalizeByteLimit(maxBytes);
|
||||
const contentLength = Number.parseInt(response.headers.get("content-length") ?? "", 10);
|
||||
if (Number.isFinite(contentLength) && contentLength > limit) {
|
||||
throw new Error(`Response exceeds ${limit} bytes`);
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error("Response has no body");
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let text = "";
|
||||
let totalBytes = 0;
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
totalBytes += value.byteLength;
|
||||
if (totalBytes > limit) {
|
||||
await reader.cancel("response too large");
|
||||
throw new Error(`Response exceeds ${limit} bytes`);
|
||||
}
|
||||
text += decoder.decode(value, { stream: true });
|
||||
}
|
||||
text += decoder.decode();
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
return JSON.parse(text) as T;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
const testDirectoryNames = new Set([
|
||||
"__tests__",
|
||||
"_tests",
|
||||
"test",
|
||||
"tests",
|
||||
]);
|
||||
|
||||
const ignoredTestConfigBasenames = new Set([
|
||||
"jest.config.cjs",
|
||||
"jest.config.js",
|
||||
"jest.config.mjs",
|
||||
"jest.config.ts",
|
||||
"playwright.config.ts",
|
||||
"vitest.config.ts",
|
||||
]);
|
||||
|
||||
const nodeDiagnosticReportPattern = /^report\.\d{8}\.\d{6}\.\d+\.\d+\.\d+\.json$/i;
|
||||
|
||||
export function shouldTrackDevServerPath(relativePath) {
|
||||
const normalizedPath = String(relativePath).replaceAll("\\", "/").replace(/^\.\/+/, "");
|
||||
if (normalizedPath.length === 0) return false;
|
||||
|
||||
const segments = normalizedPath.split("/");
|
||||
const basename = segments.at(-1) ?? normalizedPath;
|
||||
|
||||
if (nodeDiagnosticReportPattern.test(basename)) {
|
||||
return false;
|
||||
}
|
||||
if (segments.includes(".paperclip")) {
|
||||
return false;
|
||||
}
|
||||
if (ignoredTestConfigBasenames.has(basename)) {
|
||||
return false;
|
||||
}
|
||||
if (segments.some((segment) => testDirectoryNames.has(segment))) {
|
||||
return false;
|
||||
}
|
||||
if (/\.(test|spec)\.[^/]+$/i.test(basename)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { existsSync, readdirSync, statSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { shouldTrackDevServerPath } from "./dev-runner-paths.mjs";
|
||||
|
||||
const defaultFileSystem = {
|
||||
existsSync,
|
||||
readdirSync,
|
||||
statSync,
|
||||
};
|
||||
|
||||
export function isMissingPathError(error) {
|
||||
return (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"code" in error &&
|
||||
(error.code === "ENOENT" || error.code === "ENOTDIR")
|
||||
);
|
||||
}
|
||||
|
||||
function toRelativePath(repoRoot, absolutePath) {
|
||||
return path.relative(repoRoot, absolutePath).split(path.sep).join("/");
|
||||
}
|
||||
|
||||
export function readSignature(absolutePath, fileSystem = defaultFileSystem) {
|
||||
try {
|
||||
const stats = fileSystem.statSync(absolutePath);
|
||||
return `${Math.trunc(stats.mtimeMs)}:${stats.size}`;
|
||||
} catch (error) {
|
||||
if (isMissingPathError(error)) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function addFileToSnapshot(snapshot, absolutePath, options) {
|
||||
const relativePath = toRelativePath(options.repoRoot, absolutePath);
|
||||
if (options.ignoredRelativePaths?.has(relativePath)) return;
|
||||
if (!shouldTrackDevServerPath(relativePath)) return;
|
||||
|
||||
const signature = readSignature(absolutePath, options.fileSystem ?? defaultFileSystem);
|
||||
if (signature === null) return;
|
||||
snapshot.set(relativePath, signature);
|
||||
}
|
||||
|
||||
export function walkDirectory(snapshot, absoluteDirectory, options) {
|
||||
const fileSystem = options.fileSystem ?? defaultFileSystem;
|
||||
if (!fileSystem.existsSync(absoluteDirectory)) return;
|
||||
|
||||
let entries;
|
||||
try {
|
||||
entries = fileSystem.readdirSync(absoluteDirectory, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (isMissingPathError(error)) return;
|
||||
throw error;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (options.ignoredDirectoryNames?.has(entry.name)) continue;
|
||||
|
||||
const absolutePath = path.join(absoluteDirectory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
walkDirectory(snapshot, absolutePath, options);
|
||||
continue;
|
||||
}
|
||||
if (entry.isFile() || entry.isSymbolicLink()) {
|
||||
addFileToSnapshot(snapshot, absolutePath, options);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function collectWatchedSnapshot(options) {
|
||||
const fileSystem = options.fileSystem ?? defaultFileSystem;
|
||||
const snapshot = new Map();
|
||||
|
||||
for (const absoluteDirectory of options.watchedDirectories) {
|
||||
walkDirectory(snapshot, absoluteDirectory, options);
|
||||
}
|
||||
for (const absoluteFile of options.watchedFiles) {
|
||||
if (!fileSystem.existsSync(absoluteFile)) continue;
|
||||
addFileToSnapshot(snapshot, absoluteFile, options);
|
||||
}
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export function diffSnapshots(previous, next) {
|
||||
const changed = new Set();
|
||||
|
||||
for (const [relativePath, signature] of next) {
|
||||
if (previous.get(relativePath) !== signature) {
|
||||
changed.add(relativePath);
|
||||
}
|
||||
}
|
||||
for (const relativePath of previous.keys()) {
|
||||
if (!next.has(relativePath)) {
|
||||
changed.add(relativePath);
|
||||
}
|
||||
}
|
||||
|
||||
return [...changed].sort();
|
||||
}
|
||||
@@ -0,0 +1,603 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawn } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import { stdin, stdout } from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createCapturedOutputBuffer, parseJsonResponseWithLimit } from "./dev-runner-output.mjs";
|
||||
import { shouldTrackDevServerPath } from "./dev-runner-paths.mjs";
|
||||
|
||||
const mode = process.argv[2] === "watch" ? "watch" : "dev";
|
||||
const cliArgs = process.argv.slice(3);
|
||||
const scanIntervalMs = 1500;
|
||||
const autoRestartPollIntervalMs = 2500;
|
||||
const gracefulShutdownTimeoutMs = 10_000;
|
||||
const changedPathSampleLimit = 5;
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const devServerStatusFilePath = path.join(repoRoot, ".paperclip", "dev-server-status.json");
|
||||
const devServerStatusToken = mode === "dev" ? randomUUID() : null;
|
||||
const devServerStatusTokenHeader = "x-paperclip-dev-server-status-token";
|
||||
|
||||
const watchedDirectories = [
|
||||
"cli",
|
||||
"scripts",
|
||||
"server",
|
||||
"packages/adapter-utils",
|
||||
"packages/adapters",
|
||||
"packages/db",
|
||||
"packages/skills-catalog",
|
||||
"packages/plugins/sdk",
|
||||
"packages/shared",
|
||||
].map((relativePath) => path.join(repoRoot, relativePath));
|
||||
|
||||
const watchedFiles = [
|
||||
".env",
|
||||
"package.json",
|
||||
"pnpm-workspace.yaml",
|
||||
"tsconfig.base.json",
|
||||
"tsconfig.json",
|
||||
"vitest.config.ts",
|
||||
].map((relativePath) => path.join(repoRoot, relativePath));
|
||||
|
||||
const ignoredDirectoryNames = new Set([
|
||||
".git",
|
||||
".turbo",
|
||||
".vite",
|
||||
"coverage",
|
||||
"dist",
|
||||
"node_modules",
|
||||
"ui-dist",
|
||||
]);
|
||||
|
||||
const ignoredRelativePaths = new Set([
|
||||
".paperclip/dev-server-status.json",
|
||||
]);
|
||||
|
||||
const tailscaleAuthFlagNames = new Set([
|
||||
"--tailscale-auth",
|
||||
"--authenticated-private",
|
||||
]);
|
||||
|
||||
let tailscaleAuth = false;
|
||||
const forwardedArgs = [];
|
||||
|
||||
for (const arg of cliArgs) {
|
||||
if (tailscaleAuthFlagNames.has(arg)) {
|
||||
tailscaleAuth = true;
|
||||
continue;
|
||||
}
|
||||
forwardedArgs.push(arg);
|
||||
}
|
||||
|
||||
if (process.env.npm_config_tailscale_auth === "true") {
|
||||
tailscaleAuth = true;
|
||||
}
|
||||
if (process.env.npm_config_authenticated_private === "true") {
|
||||
tailscaleAuth = true;
|
||||
}
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
PAPERCLIP_UI_DEV_MIDDLEWARE: "true",
|
||||
};
|
||||
|
||||
if (mode === "dev") {
|
||||
env.PAPERCLIP_DEV_SERVER_STATUS_FILE = devServerStatusFilePath;
|
||||
env.PAPERCLIP_DEV_SERVER_STATUS_TOKEN = devServerStatusToken ?? "";
|
||||
}
|
||||
|
||||
if (mode === "watch") {
|
||||
delete env.PAPERCLIP_DEV_SERVER_STATUS_TOKEN;
|
||||
env.PAPERCLIP_MIGRATION_PROMPT ??= "never";
|
||||
env.PAPERCLIP_MIGRATION_AUTO_APPLY ??= "true";
|
||||
}
|
||||
|
||||
if (tailscaleAuth) {
|
||||
env.PAPERCLIP_DEPLOYMENT_MODE = "authenticated";
|
||||
env.PAPERCLIP_DEPLOYMENT_EXPOSURE = "private";
|
||||
env.PAPERCLIP_AUTH_BASE_URL_MODE = "auto";
|
||||
env.HOST = "0.0.0.0";
|
||||
console.log("[paperclip] dev mode: authenticated/private (tailscale-friendly) on 0.0.0.0");
|
||||
} else {
|
||||
console.log("[paperclip] dev mode: local_trusted (default)");
|
||||
}
|
||||
|
||||
const pnpmBin = process.platform === "win32" ? "pnpm.cmd" : "pnpm";
|
||||
let previousSnapshot = collectWatchedSnapshot();
|
||||
let dirtyPaths = new Set();
|
||||
let pendingMigrations = [];
|
||||
let lastChangedAt = null;
|
||||
let lastRestartAt = null;
|
||||
let scanInFlight = false;
|
||||
let restartInFlight = false;
|
||||
let shuttingDown = false;
|
||||
let childExitWasExpected = false;
|
||||
let child = null;
|
||||
let childExitPromise = null;
|
||||
let scanTimer = null;
|
||||
let autoRestartTimer = null;
|
||||
|
||||
function toError(error, context = "Dev runner command failed") {
|
||||
if (error instanceof Error) return error;
|
||||
if (error === undefined) return new Error(context);
|
||||
if (typeof error === "string") return new Error(`${context}: ${error}`);
|
||||
|
||||
try {
|
||||
return new Error(`${context}: ${JSON.stringify(error)}`);
|
||||
} catch {
|
||||
return new Error(`${context}: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
process.on("uncaughtException", (error) => {
|
||||
const err = toError(error, "Uncaught exception in dev runner");
|
||||
process.stderr.write(`${err.stack ?? err.message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on("unhandledRejection", (reason) => {
|
||||
const err = toError(reason, "Unhandled promise rejection in dev runner");
|
||||
process.stderr.write(`${err.stack ?? err.message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
function formatPendingMigrationSummary(migrations) {
|
||||
if (migrations.length === 0) return "none";
|
||||
return migrations.length > 3
|
||||
? `${migrations.slice(0, 3).join(", ")} (+${migrations.length - 3} more)`
|
||||
: migrations.join(", ");
|
||||
}
|
||||
|
||||
function exitForSignal(signal) {
|
||||
if (signal === "SIGINT") {
|
||||
process.exit(130);
|
||||
}
|
||||
if (signal === "SIGTERM") {
|
||||
process.exit(143);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function toRelativePath(absolutePath) {
|
||||
return path.relative(repoRoot, absolutePath).split(path.sep).join("/");
|
||||
}
|
||||
|
||||
function readSignature(absolutePath) {
|
||||
const stats = statSync(absolutePath);
|
||||
return `${Math.trunc(stats.mtimeMs)}:${stats.size}`;
|
||||
}
|
||||
|
||||
function addFileToSnapshot(snapshot, absolutePath) {
|
||||
const relativePath = toRelativePath(absolutePath);
|
||||
if (ignoredRelativePaths.has(relativePath)) return;
|
||||
if (!shouldTrackDevServerPath(relativePath)) return;
|
||||
snapshot.set(relativePath, readSignature(absolutePath));
|
||||
}
|
||||
|
||||
function walkDirectory(snapshot, absoluteDirectory) {
|
||||
if (!existsSync(absoluteDirectory)) return;
|
||||
|
||||
for (const entry of readdirSync(absoluteDirectory, { withFileTypes: true })) {
|
||||
if (ignoredDirectoryNames.has(entry.name)) continue;
|
||||
|
||||
const absolutePath = path.join(absoluteDirectory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
walkDirectory(snapshot, absolutePath);
|
||||
continue;
|
||||
}
|
||||
if (entry.isFile() || entry.isSymbolicLink()) {
|
||||
addFileToSnapshot(snapshot, absolutePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function collectWatchedSnapshot() {
|
||||
const snapshot = new Map();
|
||||
|
||||
for (const absoluteDirectory of watchedDirectories) {
|
||||
walkDirectory(snapshot, absoluteDirectory);
|
||||
}
|
||||
for (const absoluteFile of watchedFiles) {
|
||||
if (!existsSync(absoluteFile)) continue;
|
||||
addFileToSnapshot(snapshot, absoluteFile);
|
||||
}
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function diffSnapshots(previous, next) {
|
||||
const changed = new Set();
|
||||
|
||||
for (const [relativePath, signature] of next) {
|
||||
if (previous.get(relativePath) !== signature) {
|
||||
changed.add(relativePath);
|
||||
}
|
||||
}
|
||||
for (const relativePath of previous.keys()) {
|
||||
if (!next.has(relativePath)) {
|
||||
changed.add(relativePath);
|
||||
}
|
||||
}
|
||||
|
||||
return [...changed].sort();
|
||||
}
|
||||
|
||||
function ensureDevStatusDirectory() {
|
||||
mkdirSync(path.dirname(devServerStatusFilePath), { recursive: true });
|
||||
}
|
||||
|
||||
function writeDevServerStatus() {
|
||||
if (mode !== "dev") return;
|
||||
|
||||
ensureDevStatusDirectory();
|
||||
const changedPaths = [...dirtyPaths].sort();
|
||||
writeFileSync(
|
||||
devServerStatusFilePath,
|
||||
`${JSON.stringify({
|
||||
dirty: changedPaths.length > 0 || pendingMigrations.length > 0,
|
||||
lastChangedAt,
|
||||
changedPathCount: changedPaths.length,
|
||||
changedPathsSample: changedPaths.slice(0, changedPathSampleLimit),
|
||||
pendingMigrations,
|
||||
lastRestartAt,
|
||||
}, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
function clearDevServerStatus() {
|
||||
if (mode !== "dev") return;
|
||||
rmSync(devServerStatusFilePath, { force: true });
|
||||
}
|
||||
|
||||
async function runPnpm(args, options = {}) {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const spawned = spawn(pnpmBin, args, {
|
||||
stdio: options.stdio ?? ["ignore", "pipe", "pipe"],
|
||||
env: options.env ?? process.env,
|
||||
cwd: options.cwd,
|
||||
shell: process.platform === "win32",
|
||||
});
|
||||
|
||||
const stdoutBuffer = createCapturedOutputBuffer();
|
||||
const stderrBuffer = createCapturedOutputBuffer();
|
||||
|
||||
if (spawned.stdout) {
|
||||
spawned.stdout.on("data", (chunk) => {
|
||||
stdoutBuffer.append(chunk);
|
||||
});
|
||||
}
|
||||
if (spawned.stderr) {
|
||||
spawned.stderr.on("data", (chunk) => {
|
||||
stderrBuffer.append(chunk);
|
||||
});
|
||||
}
|
||||
|
||||
spawned.on("error", reject);
|
||||
spawned.on("exit", (code, signal) => {
|
||||
const stdout = stdoutBuffer.finish();
|
||||
const stderr = stderrBuffer.finish();
|
||||
resolve({
|
||||
code: code ?? 0,
|
||||
signal,
|
||||
stdout: stdout.text,
|
||||
stderr: stderr.text,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function getMigrationStatusPayload() {
|
||||
const status = await runPnpm(
|
||||
["--filter", "@paperclipai/db", "exec", "tsx", "src/migration-status.ts", "--json"],
|
||||
{ env },
|
||||
);
|
||||
if (status.code !== 0) {
|
||||
process.stderr.write(
|
||||
status.stderr ||
|
||||
status.stdout ||
|
||||
`[paperclip] Command failed with code ${status.code}: pnpm --filter @paperclipai/db exec tsx src/migration-status.ts --json\n`,
|
||||
);
|
||||
process.exit(status.code);
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(status.stdout.trim());
|
||||
} catch (error) {
|
||||
process.stderr.write(
|
||||
status.stderr ||
|
||||
status.stdout ||
|
||||
"[paperclip] migration-status returned invalid JSON payload\n",
|
||||
);
|
||||
throw toError(error, "Unable to parse migration-status JSON output");
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshPendingMigrations() {
|
||||
const payload = await getMigrationStatusPayload();
|
||||
pendingMigrations =
|
||||
payload.status === "needsMigrations" && Array.isArray(payload.pendingMigrations)
|
||||
? payload.pendingMigrations.filter((entry) => typeof entry === "string" && entry.trim().length > 0)
|
||||
: [];
|
||||
writeDevServerStatus();
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function maybePreflightMigrations(options = {}) {
|
||||
const interactive = options.interactive ?? mode === "watch";
|
||||
const autoApply = options.autoApply ?? env.PAPERCLIP_MIGRATION_AUTO_APPLY === "true";
|
||||
const exitOnDecline = options.exitOnDecline ?? mode === "watch";
|
||||
|
||||
const payload = await refreshPendingMigrations();
|
||||
if (payload.status !== "needsMigrations" || pendingMigrations.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let shouldApply = autoApply;
|
||||
|
||||
if (!autoApply && interactive) {
|
||||
if (!stdin.isTTY || !stdout.isTTY) {
|
||||
shouldApply = true;
|
||||
} else {
|
||||
const prompt = createInterface({ input: stdin, output: stdout });
|
||||
try {
|
||||
const answer = (
|
||||
await prompt.question(
|
||||
`Apply pending migrations (${formatPendingMigrationSummary(pendingMigrations)}) now? (y/N): `,
|
||||
)
|
||||
)
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
shouldApply = answer === "y" || answer === "yes";
|
||||
} finally {
|
||||
prompt.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!shouldApply) {
|
||||
if (exitOnDecline) {
|
||||
process.stderr.write(
|
||||
`[paperclip] Pending migrations detected (${formatPendingMigrationSummary(pendingMigrations)}). ` +
|
||||
"Refusing to start watch mode against a stale schema.\n",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const migrate = spawn(pnpmBin, ["db:migrate"], {
|
||||
stdio: "inherit",
|
||||
env,
|
||||
shell: process.platform === "win32",
|
||||
});
|
||||
const exit = await new Promise((resolve) => {
|
||||
migrate.on("exit", (code, signal) => resolve({ code: code ?? 0, signal }));
|
||||
});
|
||||
if (exit.signal) {
|
||||
exitForSignal(exit.signal);
|
||||
return;
|
||||
}
|
||||
if (exit.code !== 0) {
|
||||
process.exit(exit.code);
|
||||
}
|
||||
|
||||
await refreshPendingMigrations();
|
||||
}
|
||||
|
||||
async function buildPluginSdk() {
|
||||
console.log("[paperclip] building plugin sdk...");
|
||||
const result = await runPnpm(
|
||||
["--filter", "@paperclipai/plugin-sdk", "build"],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
if (result.signal) {
|
||||
exitForSignal(result.signal);
|
||||
return;
|
||||
}
|
||||
if (result.code !== 0) {
|
||||
console.error("[paperclip] plugin sdk build failed");
|
||||
process.exit(result.code);
|
||||
}
|
||||
}
|
||||
|
||||
async function markChildAsCurrent() {
|
||||
previousSnapshot = collectWatchedSnapshot();
|
||||
dirtyPaths = new Set();
|
||||
lastChangedAt = null;
|
||||
lastRestartAt = new Date().toISOString();
|
||||
await refreshPendingMigrations();
|
||||
}
|
||||
|
||||
async function scanForBackendChanges() {
|
||||
if (mode !== "dev" || scanInFlight || restartInFlight) return;
|
||||
scanInFlight = true;
|
||||
try {
|
||||
const nextSnapshot = collectWatchedSnapshot();
|
||||
const changed = diffSnapshots(previousSnapshot, nextSnapshot);
|
||||
previousSnapshot = nextSnapshot;
|
||||
if (changed.length === 0) return;
|
||||
|
||||
for (const relativePath of changed) {
|
||||
dirtyPaths.add(relativePath);
|
||||
}
|
||||
lastChangedAt = new Date().toISOString();
|
||||
await refreshPendingMigrations();
|
||||
} finally {
|
||||
scanInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function getDevHealthPayload() {
|
||||
const serverPort = env.PORT ?? process.env.PORT ?? "3100";
|
||||
const response = await fetch(`http://127.0.0.1:${serverPort}/api/health`, {
|
||||
headers: devServerStatusToken ? { [devServerStatusTokenHeader]: devServerStatusToken } : undefined,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Health request failed (${response.status})`);
|
||||
}
|
||||
return await parseJsonResponseWithLimit(response);
|
||||
}
|
||||
|
||||
async function waitForChildExit() {
|
||||
if (!childExitPromise) {
|
||||
return { code: 0, signal: null };
|
||||
}
|
||||
return await childExitPromise;
|
||||
}
|
||||
|
||||
async function stopChildForRestart() {
|
||||
if (!child) return { code: 0, signal: null };
|
||||
childExitWasExpected = true;
|
||||
child.kill("SIGTERM");
|
||||
const killTimer = setTimeout(() => {
|
||||
if (child) {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
}, gracefulShutdownTimeoutMs);
|
||||
try {
|
||||
return await waitForChildExit();
|
||||
} finally {
|
||||
clearTimeout(killTimer);
|
||||
}
|
||||
}
|
||||
|
||||
async function startServerChild() {
|
||||
await buildPluginSdk();
|
||||
|
||||
const serverScript = mode === "watch" ? "dev:watch" : "dev";
|
||||
child = spawn(
|
||||
pnpmBin,
|
||||
["--filter", "@paperclipai/server", serverScript, ...forwardedArgs],
|
||||
{ stdio: "inherit", env, shell: process.platform === "win32" },
|
||||
);
|
||||
|
||||
childExitPromise = new Promise((resolve, reject) => {
|
||||
child.on("error", reject);
|
||||
child.on("exit", (code, signal) => {
|
||||
const expected = childExitWasExpected;
|
||||
childExitWasExpected = false;
|
||||
child = null;
|
||||
childExitPromise = null;
|
||||
resolve({ code: code ?? 0, signal });
|
||||
|
||||
if (restartInFlight || expected || shuttingDown) {
|
||||
return;
|
||||
}
|
||||
if (signal) {
|
||||
exitForSignal(signal);
|
||||
return;
|
||||
}
|
||||
process.exit(code ?? 0);
|
||||
});
|
||||
});
|
||||
|
||||
await markChildAsCurrent();
|
||||
}
|
||||
|
||||
async function maybeAutoRestartChild() {
|
||||
if (mode !== "dev" || restartInFlight || !child) return;
|
||||
if (dirtyPaths.size === 0 && pendingMigrations.length === 0) return;
|
||||
|
||||
restartInFlight = true;
|
||||
let health;
|
||||
try {
|
||||
health = await getDevHealthPayload();
|
||||
} catch {
|
||||
restartInFlight = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const devServer = health?.devServer;
|
||||
if (!devServer?.enabled || devServer.autoRestartEnabled !== true) {
|
||||
restartInFlight = false;
|
||||
return;
|
||||
}
|
||||
if ((devServer.activeRunCount ?? 0) > 0) {
|
||||
restartInFlight = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await maybePreflightMigrations({
|
||||
autoApply: true,
|
||||
interactive: false,
|
||||
exitOnDecline: false,
|
||||
});
|
||||
await stopChildForRestart();
|
||||
await startServerChild();
|
||||
} catch (error) {
|
||||
const err = toError(error, "Auto-restart failed");
|
||||
process.stderr.write(`${err.stack ?? err.message}\n`);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
restartInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
function installDevIntervals() {
|
||||
if (mode !== "dev") return;
|
||||
|
||||
scanTimer = setInterval(() => {
|
||||
void scanForBackendChanges();
|
||||
}, scanIntervalMs);
|
||||
autoRestartTimer = setInterval(() => {
|
||||
void maybeAutoRestartChild();
|
||||
}, autoRestartPollIntervalMs);
|
||||
}
|
||||
|
||||
function clearDevIntervals() {
|
||||
if (scanTimer) {
|
||||
clearInterval(scanTimer);
|
||||
scanTimer = null;
|
||||
}
|
||||
if (autoRestartTimer) {
|
||||
clearInterval(autoRestartTimer);
|
||||
autoRestartTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function shutdown(signal) {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
clearDevIntervals();
|
||||
clearDevServerStatus();
|
||||
|
||||
if (!child) {
|
||||
if (signal) {
|
||||
exitForSignal(signal);
|
||||
return;
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
childExitWasExpected = true;
|
||||
child.kill(signal);
|
||||
const exit = await waitForChildExit();
|
||||
if (exit.signal) {
|
||||
exitForSignal(exit.signal);
|
||||
return;
|
||||
}
|
||||
process.exit(exit.code ?? 0);
|
||||
}
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
void shutdown("SIGINT");
|
||||
});
|
||||
process.on("SIGTERM", () => {
|
||||
void shutdown("SIGTERM");
|
||||
});
|
||||
|
||||
await maybePreflightMigrations();
|
||||
await startServerChild();
|
||||
installDevIntervals();
|
||||
|
||||
if (mode === "watch") {
|
||||
const exit = await waitForChildExit();
|
||||
if (exit.signal) {
|
||||
exitForSignal(exit.signal);
|
||||
}
|
||||
process.exit(exit.code ?? 0);
|
||||
}
|
||||
@@ -0,0 +1,697 @@
|
||||
#!/usr/bin/env -S node --import tsx
|
||||
import { spawn } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import { stdin, stdout } from "node:process";
|
||||
import { createCapturedOutputBuffer, parseJsonResponseWithLimit } from "./dev-runner-output.ts";
|
||||
import { collectWatchedSnapshot as collectDevServerWatchedSnapshot, diffSnapshots } from "./dev-runner-snapshot.mjs";
|
||||
import { createDevServiceIdentity, repoRoot } from "./dev-service-profile.ts";
|
||||
import { bootstrapDevRunnerWorktreeEnv } from "../server/src/dev-runner-worktree.ts";
|
||||
import {
|
||||
findAdoptableLocalService,
|
||||
removeLocalServiceRegistryRecord,
|
||||
touchLocalServiceRegistryRecord,
|
||||
writeLocalServiceRegistryRecord,
|
||||
} from "../server/src/services/local-service-supervisor.ts";
|
||||
|
||||
// Keep these values local so the dev runner can boot from the server package's
|
||||
// tsx context without requiring workspace package resolution first.
|
||||
const BIND_MODES = ["loopback", "lan", "tailnet", "custom"] as const;
|
||||
type BindMode = (typeof BIND_MODES)[number];
|
||||
|
||||
const worktreeEnvBootstrap = bootstrapDevRunnerWorktreeEnv(repoRoot, process.env);
|
||||
if (worktreeEnvBootstrap.missingEnv) {
|
||||
console.error(
|
||||
`[paperclip] linked git worktree at ${repoRoot} is missing ${path.relative(repoRoot, worktreeEnvBootstrap.envPath)}. Run \`paperclipai worktree init\` in this worktree before \`pnpm dev\`.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const mode = process.argv[2] === "watch" ? "watch" : "dev";
|
||||
const cliArgs = process.argv.slice(3);
|
||||
const scanIntervalMs = 1500;
|
||||
const autoRestartPollIntervalMs = 2500;
|
||||
const gracefulShutdownTimeoutMs = 10_000;
|
||||
const changedPathSampleLimit = 5;
|
||||
const devServerStatusFilePath = path.join(repoRoot, ".paperclip", "dev-server-status.json");
|
||||
const devServerRestartRequestFilePath = path.join(repoRoot, ".paperclip", "dev-server-restart-request.json");
|
||||
const devServerStatusToken = mode === "dev" ? randomUUID() : null;
|
||||
const devServerStatusTokenHeader = "x-paperclip-dev-server-status-token";
|
||||
|
||||
const watchedDirectories = [
|
||||
"cli",
|
||||
"scripts",
|
||||
"server",
|
||||
"packages/adapter-utils",
|
||||
"packages/adapters",
|
||||
"packages/db",
|
||||
"packages/skills-catalog",
|
||||
"packages/plugins/sdk",
|
||||
"packages/shared",
|
||||
].map((relativePath) => path.join(repoRoot, relativePath));
|
||||
|
||||
const watchedFiles = [
|
||||
".env",
|
||||
"package.json",
|
||||
"pnpm-workspace.yaml",
|
||||
"tsconfig.base.json",
|
||||
"tsconfig.json",
|
||||
"vitest.config.ts",
|
||||
].map((relativePath) => path.join(repoRoot, relativePath));
|
||||
|
||||
const ignoredDirectoryNames = new Set([
|
||||
".git",
|
||||
".turbo",
|
||||
".vite",
|
||||
"coverage",
|
||||
"dist",
|
||||
"node_modules",
|
||||
"ui-dist",
|
||||
]);
|
||||
|
||||
const ignoredRelativePaths = new Set([
|
||||
".paperclip/dev-server-restart-request.json",
|
||||
".paperclip/dev-server-status.json",
|
||||
]);
|
||||
|
||||
const tailscaleAuthFlagNames = new Set([
|
||||
"--tailscale-auth",
|
||||
"--authenticated-private",
|
||||
]);
|
||||
|
||||
let tailscaleAuth = false;
|
||||
let bindMode: BindMode | null = null;
|
||||
let bindHost: string | null = null;
|
||||
const forwardedArgs: string[] = [];
|
||||
|
||||
for (let index = 0; index < cliArgs.length; index += 1) {
|
||||
const arg = cliArgs[index];
|
||||
if (tailscaleAuthFlagNames.has(arg)) {
|
||||
tailscaleAuth = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--bind") {
|
||||
const value = cliArgs[index + 1];
|
||||
if (!value || value.startsWith("--") || !BIND_MODES.includes(value as BindMode)) {
|
||||
console.error(`[paperclip] invalid --bind value. Use one of: ${BIND_MODES.join(", ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
bindMode = value as BindMode;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--bind-host") {
|
||||
const value = cliArgs[index + 1];
|
||||
if (!value || value.startsWith("--")) {
|
||||
console.error("[paperclip] --bind-host requires a value");
|
||||
process.exit(1);
|
||||
}
|
||||
bindHost = value;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
forwardedArgs.push(arg);
|
||||
}
|
||||
|
||||
if (process.env.npm_config_tailscale_auth === "true") {
|
||||
tailscaleAuth = true;
|
||||
}
|
||||
if (process.env.npm_config_authenticated_private === "true") {
|
||||
tailscaleAuth = true;
|
||||
}
|
||||
if (!bindMode && process.env.npm_config_bind && BIND_MODES.includes(process.env.npm_config_bind as BindMode)) {
|
||||
bindMode = process.env.npm_config_bind as BindMode;
|
||||
}
|
||||
if (!bindHost && process.env.npm_config_bind_host) {
|
||||
bindHost = process.env.npm_config_bind_host;
|
||||
}
|
||||
if (bindMode === "custom" && !bindHost) {
|
||||
console.error("[paperclip] --bind custom requires --bind-host <host>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
PAPERCLIP_UI_DEV_MIDDLEWARE: "true",
|
||||
};
|
||||
|
||||
if (mode === "dev") {
|
||||
env.PAPERCLIP_DEV_SERVER_STATUS_FILE = devServerStatusFilePath;
|
||||
env.PAPERCLIP_DEV_SERVER_STATUS_TOKEN = devServerStatusToken ?? "";
|
||||
env.PAPERCLIP_MIGRATION_AUTO_APPLY ??= "true";
|
||||
}
|
||||
|
||||
if (mode === "watch") {
|
||||
delete env.PAPERCLIP_DEV_SERVER_STATUS_TOKEN;
|
||||
env.PAPERCLIP_MIGRATION_PROMPT ??= "never";
|
||||
env.PAPERCLIP_MIGRATION_AUTO_APPLY ??= "true";
|
||||
}
|
||||
|
||||
if (tailscaleAuth || bindMode) {
|
||||
const effectiveBind = bindMode ?? "lan";
|
||||
if (tailscaleAuth) {
|
||||
console.log("[paperclip] note: --tailscale-auth/--authenticated-private are legacy aliases for --bind lan");
|
||||
}
|
||||
env.PAPERCLIP_BIND = effectiveBind;
|
||||
if (bindHost) {
|
||||
env.PAPERCLIP_BIND_HOST = bindHost;
|
||||
} else {
|
||||
delete env.PAPERCLIP_BIND_HOST;
|
||||
}
|
||||
if (effectiveBind === "loopback" && !tailscaleAuth) {
|
||||
delete env.PAPERCLIP_DEPLOYMENT_MODE;
|
||||
delete env.PAPERCLIP_DEPLOYMENT_EXPOSURE;
|
||||
delete env.PAPERCLIP_AUTH_BASE_URL_MODE;
|
||||
console.log("[paperclip] dev mode: local_trusted (bind=loopback)");
|
||||
} else {
|
||||
env.PAPERCLIP_DEPLOYMENT_MODE = "authenticated";
|
||||
env.PAPERCLIP_DEPLOYMENT_EXPOSURE = "private";
|
||||
env.PAPERCLIP_AUTH_BASE_URL_MODE = "auto";
|
||||
console.log(
|
||||
`[paperclip] dev mode: authenticated/private (bind=${effectiveBind}${bindHost ? `:${bindHost}` : ""})`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
delete env.PAPERCLIP_BIND;
|
||||
delete env.PAPERCLIP_BIND_HOST;
|
||||
delete env.PAPERCLIP_DEPLOYMENT_MODE;
|
||||
delete env.PAPERCLIP_DEPLOYMENT_EXPOSURE;
|
||||
delete env.PAPERCLIP_AUTH_BASE_URL_MODE;
|
||||
console.log("[paperclip] dev mode: local_trusted (default)");
|
||||
}
|
||||
|
||||
const serverPort = Number.parseInt(env.PORT ?? process.env.PORT ?? "3100", 10) || 3100;
|
||||
const devService = createDevServiceIdentity({
|
||||
mode,
|
||||
forwardedArgs,
|
||||
networkProfile: tailscaleAuth ? `legacy:${bindMode ?? "lan"}` : (bindMode ?? "default"),
|
||||
port: serverPort,
|
||||
});
|
||||
|
||||
const existingRunner = await findAdoptableLocalService({
|
||||
serviceKey: devService.serviceKey,
|
||||
cwd: repoRoot,
|
||||
envFingerprint: devService.envFingerprint,
|
||||
port: serverPort,
|
||||
});
|
||||
if (existingRunner) {
|
||||
console.log(
|
||||
`[paperclip] ${devService.serviceName} already running (pid ${existingRunner.pid}${typeof existingRunner.metadata?.childPid === "number" ? `, child ${existingRunner.metadata.childPid}` : ""})`,
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const pnpmBin = process.platform === "win32" ? "pnpm.cmd" : "pnpm";
|
||||
let previousSnapshot = collectWatchedSnapshot();
|
||||
let dirtyPaths = new Set<string>();
|
||||
let pendingMigrations: string[] = [];
|
||||
let lastChangedAt: string | null = null;
|
||||
let lastRestartAt: string | null = null;
|
||||
let scanInFlight = false;
|
||||
let restartInFlight = false;
|
||||
let shuttingDown = false;
|
||||
let childExitWasExpected = false;
|
||||
let child: ReturnType<typeof spawn> | null = null;
|
||||
let childExitPromise: Promise<{ code: number; signal: NodeJS.Signals | null }> | null = null;
|
||||
let scanTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let autoRestartTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
function toError(error: unknown, context = "Dev runner command failed") {
|
||||
if (error instanceof Error) return error;
|
||||
if (error === undefined) return new Error(context);
|
||||
if (typeof error === "string") return new Error(`${context}: ${error}`);
|
||||
|
||||
try {
|
||||
return new Error(`${context}: ${JSON.stringify(error)}`);
|
||||
} catch {
|
||||
return new Error(`${context}: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
process.on("uncaughtException", async (error) => {
|
||||
await removeLocalServiceRegistryRecord(devService.serviceKey);
|
||||
const err = toError(error, "Uncaught exception in dev runner");
|
||||
process.stderr.write(`${err.stack ?? err.message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on("unhandledRejection", async (reason) => {
|
||||
await removeLocalServiceRegistryRecord(devService.serviceKey);
|
||||
const err = toError(reason, "Unhandled promise rejection in dev runner");
|
||||
process.stderr.write(`${err.stack ?? err.message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
function formatPendingMigrationSummary(migrations: string[]) {
|
||||
if (migrations.length === 0) return "none";
|
||||
return migrations.length > 3
|
||||
? `${migrations.slice(0, 3).join(", ")} (+${migrations.length - 3} more)`
|
||||
: migrations.join(", ");
|
||||
}
|
||||
|
||||
function exitForSignal(signal: NodeJS.Signals) {
|
||||
if (signal === "SIGINT") {
|
||||
process.exit(130);
|
||||
}
|
||||
if (signal === "SIGTERM") {
|
||||
process.exit(143);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function collectWatchedSnapshot() {
|
||||
return collectDevServerWatchedSnapshot({
|
||||
repoRoot,
|
||||
watchedDirectories,
|
||||
watchedFiles,
|
||||
ignoredDirectoryNames,
|
||||
ignoredRelativePaths,
|
||||
}) as Map<string, string>;
|
||||
}
|
||||
|
||||
function ensureDevStatusDirectory() {
|
||||
mkdirSync(path.dirname(devServerStatusFilePath), { recursive: true });
|
||||
}
|
||||
|
||||
function writeDevServerStatus() {
|
||||
if (mode !== "dev") return;
|
||||
|
||||
ensureDevStatusDirectory();
|
||||
const changedPaths = [...dirtyPaths].sort();
|
||||
writeFileSync(
|
||||
devServerStatusFilePath,
|
||||
`${JSON.stringify({
|
||||
dirty: changedPaths.length > 0 || pendingMigrations.length > 0,
|
||||
lastChangedAt,
|
||||
changedPathCount: changedPaths.length,
|
||||
changedPathsSample: changedPaths.slice(0, changedPathSampleLimit),
|
||||
pendingMigrations,
|
||||
lastRestartAt,
|
||||
}, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
function clearDevServerStatus() {
|
||||
if (mode !== "dev") return;
|
||||
rmSync(devServerStatusFilePath, { force: true });
|
||||
rmSync(devServerRestartRequestFilePath, { force: true });
|
||||
}
|
||||
|
||||
function consumeDevServerRestartRequest() {
|
||||
if (mode !== "dev" || !existsSync(devServerRestartRequestFilePath)) return false;
|
||||
rmSync(devServerRestartRequestFilePath, { force: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
async function updateDevServiceRecord(extra?: Record<string, unknown>) {
|
||||
await writeLocalServiceRegistryRecord({
|
||||
version: 1,
|
||||
serviceKey: devService.serviceKey,
|
||||
profileKind: "paperclip-dev",
|
||||
serviceName: devService.serviceName,
|
||||
command: "dev-runner.ts",
|
||||
cwd: repoRoot,
|
||||
envFingerprint: devService.envFingerprint,
|
||||
port: serverPort,
|
||||
url: `http://127.0.0.1:${serverPort}`,
|
||||
pid: process.pid,
|
||||
processGroupId: null,
|
||||
provider: "local_process",
|
||||
runtimeServiceId: null,
|
||||
reuseKey: null,
|
||||
startedAt: lastRestartAt ?? new Date().toISOString(),
|
||||
lastSeenAt: new Date().toISOString(),
|
||||
metadata: {
|
||||
repoRoot,
|
||||
mode,
|
||||
childPid: child?.pid ?? null,
|
||||
url: `http://127.0.0.1:${serverPort}`,
|
||||
...extra,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function runPnpm(args: string[], options: {
|
||||
stdio?: "inherit" | ["ignore", "pipe", "pipe"];
|
||||
env?: NodeJS.ProcessEnv;
|
||||
cwd?: string;
|
||||
} = {}) {
|
||||
return await new Promise<{ code: number; signal: NodeJS.Signals | null; stdout: string; stderr: string }>((resolve, reject) => {
|
||||
const spawned = spawn(pnpmBin, args, {
|
||||
stdio: options.stdio ?? ["ignore", "pipe", "pipe"],
|
||||
env: options.env ?? process.env,
|
||||
cwd: options.cwd,
|
||||
shell: process.platform === "win32",
|
||||
});
|
||||
|
||||
const stdoutBuffer = createCapturedOutputBuffer();
|
||||
const stderrBuffer = createCapturedOutputBuffer();
|
||||
|
||||
if (spawned.stdout) {
|
||||
spawned.stdout.on("data", (chunk) => {
|
||||
stdoutBuffer.append(chunk);
|
||||
});
|
||||
}
|
||||
if (spawned.stderr) {
|
||||
spawned.stderr.on("data", (chunk) => {
|
||||
stderrBuffer.append(chunk);
|
||||
});
|
||||
}
|
||||
|
||||
spawned.on("error", reject);
|
||||
spawned.on("exit", (code, signal) => {
|
||||
const stdout = stdoutBuffer.finish();
|
||||
const stderr = stderrBuffer.finish();
|
||||
resolve({
|
||||
code: code ?? 0,
|
||||
signal,
|
||||
stdout: stdout.text,
|
||||
stderr: stderr.text,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function getMigrationStatusPayload() {
|
||||
const status = await runPnpm(
|
||||
["--filter", "@paperclipai/db", "exec", "tsx", "src/migration-status.ts", "--json"],
|
||||
{ env },
|
||||
);
|
||||
if (status.code !== 0) {
|
||||
process.stderr.write(
|
||||
status.stderr ||
|
||||
status.stdout ||
|
||||
`[paperclip] Command failed with code ${status.code}: pnpm --filter @paperclipai/db exec tsx src/migration-status.ts --json\n`,
|
||||
);
|
||||
process.exit(status.code);
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(status.stdout.trim()) as { status?: string; pendingMigrations?: string[] };
|
||||
} catch (error) {
|
||||
process.stderr.write(
|
||||
status.stderr ||
|
||||
status.stdout ||
|
||||
"[paperclip] migration-status returned invalid JSON payload\n",
|
||||
);
|
||||
throw toError(error, "Unable to parse migration-status JSON output");
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshPendingMigrations() {
|
||||
const payload = await getMigrationStatusPayload();
|
||||
pendingMigrations =
|
||||
payload.status === "needsMigrations" && Array.isArray(payload.pendingMigrations)
|
||||
? payload.pendingMigrations.filter((entry) => typeof entry === "string" && entry.trim().length > 0)
|
||||
: [];
|
||||
writeDevServerStatus();
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function maybePreflightMigrations(options: { interactive?: boolean; autoApply?: boolean; exitOnDecline?: boolean } = {}) {
|
||||
const interactive = options.interactive ?? mode === "watch";
|
||||
const autoApply = options.autoApply ?? env.PAPERCLIP_MIGRATION_AUTO_APPLY === "true";
|
||||
const exitOnDecline = options.exitOnDecline ?? mode === "watch";
|
||||
|
||||
const payload = await refreshPendingMigrations();
|
||||
if (payload.status !== "needsMigrations" || pendingMigrations.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let shouldApply = autoApply;
|
||||
|
||||
if (!autoApply && interactive) {
|
||||
if (!stdin.isTTY || !stdout.isTTY) {
|
||||
shouldApply = true;
|
||||
} else {
|
||||
const prompt = createInterface({ input: stdin, output: stdout });
|
||||
try {
|
||||
const answer = (
|
||||
await prompt.question(
|
||||
`Apply pending migrations (${formatPendingMigrationSummary(pendingMigrations)}) now? (y/N): `,
|
||||
)
|
||||
)
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
shouldApply = answer === "y" || answer === "yes";
|
||||
} finally {
|
||||
prompt.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!shouldApply) {
|
||||
if (exitOnDecline) {
|
||||
process.stderr.write(
|
||||
`[paperclip] Pending migrations detected (${formatPendingMigrationSummary(pendingMigrations)}). Refusing to start watch mode against a stale schema.\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const exit = await runPnpm(["db:migrate"], {
|
||||
stdio: "inherit",
|
||||
env,
|
||||
cwd: repoRoot,
|
||||
});
|
||||
if (exit.signal) {
|
||||
exitForSignal(exit.signal);
|
||||
return;
|
||||
}
|
||||
if (exit.code !== 0) {
|
||||
process.exit(exit.code);
|
||||
}
|
||||
|
||||
await refreshPendingMigrations();
|
||||
}
|
||||
|
||||
async function buildPluginSdk() {
|
||||
console.log("[paperclip] building plugin sdk...");
|
||||
const result = await runPnpm(
|
||||
["--filter", "@paperclipai/plugin-sdk", "build"],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
if (result.signal) {
|
||||
exitForSignal(result.signal);
|
||||
return;
|
||||
}
|
||||
if (result.code !== 0) {
|
||||
console.error("[paperclip] plugin sdk build failed");
|
||||
process.exit(result.code);
|
||||
}
|
||||
}
|
||||
|
||||
async function markChildAsCurrent() {
|
||||
previousSnapshot = collectWatchedSnapshot();
|
||||
dirtyPaths = new Set();
|
||||
lastChangedAt = null;
|
||||
lastRestartAt = new Date().toISOString();
|
||||
await refreshPendingMigrations();
|
||||
await updateDevServiceRecord();
|
||||
}
|
||||
|
||||
async function scanForBackendChanges() {
|
||||
if (mode !== "dev" || scanInFlight || restartInFlight) return;
|
||||
scanInFlight = true;
|
||||
try {
|
||||
const nextSnapshot = collectWatchedSnapshot();
|
||||
const changed = diffSnapshots(previousSnapshot, nextSnapshot);
|
||||
previousSnapshot = nextSnapshot;
|
||||
if (changed.length === 0) return;
|
||||
|
||||
for (const relativePath of changed) {
|
||||
dirtyPaths.add(relativePath);
|
||||
}
|
||||
lastChangedAt = new Date().toISOString();
|
||||
await refreshPendingMigrations();
|
||||
} finally {
|
||||
scanInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function getDevHealthPayload() {
|
||||
const response = await fetch(`http://127.0.0.1:${serverPort}/api/health`, {
|
||||
headers: devServerStatusToken ? { [devServerStatusTokenHeader]: devServerStatusToken } : undefined,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Health request failed (${response.status})`);
|
||||
}
|
||||
return await parseJsonResponseWithLimit(response);
|
||||
}
|
||||
|
||||
async function waitForChildExit() {
|
||||
if (!childExitPromise) {
|
||||
return { code: 0, signal: null };
|
||||
}
|
||||
return await childExitPromise;
|
||||
}
|
||||
|
||||
async function stopChildForRestart() {
|
||||
if (!child) return { code: 0, signal: null };
|
||||
childExitWasExpected = true;
|
||||
child.kill("SIGTERM");
|
||||
const killTimer = setTimeout(() => {
|
||||
if (child) {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
}, gracefulShutdownTimeoutMs);
|
||||
try {
|
||||
return await waitForChildExit();
|
||||
} finally {
|
||||
clearTimeout(killTimer);
|
||||
}
|
||||
}
|
||||
|
||||
async function startServerChild() {
|
||||
await buildPluginSdk();
|
||||
|
||||
const serverScript = mode === "watch" ? "dev:watch" : "dev";
|
||||
child = spawn(
|
||||
pnpmBin,
|
||||
["--filter", "@paperclipai/server", serverScript, ...forwardedArgs],
|
||||
{ stdio: "inherit", env, shell: process.platform === "win32" },
|
||||
);
|
||||
|
||||
childExitPromise = new Promise((resolve, reject) => {
|
||||
child?.on("error", reject);
|
||||
child?.on("exit", (code, signal) => {
|
||||
const expected = childExitWasExpected;
|
||||
childExitWasExpected = false;
|
||||
child = null;
|
||||
childExitPromise = null;
|
||||
void touchLocalServiceRegistryRecord(devService.serviceKey, {
|
||||
metadata: {
|
||||
repoRoot,
|
||||
mode,
|
||||
childPid: null,
|
||||
url: `http://127.0.0.1:${serverPort}`,
|
||||
},
|
||||
});
|
||||
resolve({ code: code ?? 0, signal });
|
||||
|
||||
if (restartInFlight || expected || shuttingDown) {
|
||||
return;
|
||||
}
|
||||
if (signal) {
|
||||
exitForSignal(signal);
|
||||
return;
|
||||
}
|
||||
process.exit(code ?? 0);
|
||||
});
|
||||
});
|
||||
|
||||
await markChildAsCurrent();
|
||||
}
|
||||
|
||||
async function maybeAutoRestartChild() {
|
||||
if (mode !== "dev" || restartInFlight || !child) return;
|
||||
const manualRestartRequested = consumeDevServerRestartRequest();
|
||||
if (!manualRestartRequested && dirtyPaths.size === 0 && pendingMigrations.length === 0) return;
|
||||
|
||||
restartInFlight = true;
|
||||
let health: { devServer?: { enabled?: boolean; autoRestartEnabled?: boolean; activeRunCount?: number } } | null = null;
|
||||
try {
|
||||
health = await getDevHealthPayload();
|
||||
} catch {
|
||||
restartInFlight = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const devServer = health?.devServer;
|
||||
if (!devServer?.enabled) {
|
||||
restartInFlight = false;
|
||||
return;
|
||||
}
|
||||
if (!manualRestartRequested && devServer.autoRestartEnabled !== true) {
|
||||
restartInFlight = false;
|
||||
return;
|
||||
}
|
||||
if (!manualRestartRequested && (devServer.activeRunCount ?? 0) > 0) {
|
||||
restartInFlight = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await maybePreflightMigrations({
|
||||
autoApply: true,
|
||||
interactive: false,
|
||||
exitOnDecline: false,
|
||||
});
|
||||
await stopChildForRestart();
|
||||
await startServerChild();
|
||||
} catch (error) {
|
||||
const err = toError(error, "Auto-restart failed");
|
||||
process.stderr.write(`${err.stack ?? err.message}\n`);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
restartInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
function installDevIntervals() {
|
||||
if (mode !== "dev") return;
|
||||
|
||||
scanTimer = setInterval(() => {
|
||||
void scanForBackendChanges();
|
||||
}, scanIntervalMs);
|
||||
autoRestartTimer = setInterval(() => {
|
||||
void maybeAutoRestartChild();
|
||||
}, autoRestartPollIntervalMs);
|
||||
}
|
||||
|
||||
function clearDevIntervals() {
|
||||
if (scanTimer) {
|
||||
clearInterval(scanTimer);
|
||||
scanTimer = null;
|
||||
}
|
||||
if (autoRestartTimer) {
|
||||
clearInterval(autoRestartTimer);
|
||||
autoRestartTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function shutdown(signal: NodeJS.Signals) {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
clearDevIntervals();
|
||||
clearDevServerStatus();
|
||||
await removeLocalServiceRegistryRecord(devService.serviceKey);
|
||||
|
||||
if (!child) {
|
||||
exitForSignal(signal);
|
||||
return;
|
||||
}
|
||||
|
||||
childExitWasExpected = true;
|
||||
child.kill(signal);
|
||||
const exit = await waitForChildExit();
|
||||
if (exit.signal) {
|
||||
exitForSignal(exit.signal);
|
||||
return;
|
||||
}
|
||||
process.exit(exit.code ?? 0);
|
||||
}
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
void shutdown("SIGINT");
|
||||
});
|
||||
process.on("SIGTERM", () => {
|
||||
void shutdown("SIGTERM");
|
||||
});
|
||||
|
||||
await maybePreflightMigrations();
|
||||
await startServerChild();
|
||||
installDevIntervals();
|
||||
|
||||
if (mode === "watch") {
|
||||
const exit = await waitForChildExit();
|
||||
await removeLocalServiceRegistryRecord(devService.serviceKey);
|
||||
if (exit.signal) {
|
||||
exitForSignal(exit.signal);
|
||||
}
|
||||
process.exit(exit.code ?? 0);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createLocalServiceKey } from "../server/src/services/local-service-supervisor.ts";
|
||||
|
||||
export const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
export function createDevServiceIdentity(input: {
|
||||
mode: "watch" | "dev";
|
||||
forwardedArgs: string[];
|
||||
networkProfile: string;
|
||||
port: number;
|
||||
}) {
|
||||
const envFingerprint = createHash("sha256")
|
||||
.update(
|
||||
JSON.stringify({
|
||||
mode: input.mode,
|
||||
forwardedArgs: input.forwardedArgs,
|
||||
networkProfile: input.networkProfile,
|
||||
port: input.port,
|
||||
}),
|
||||
)
|
||||
.digest("hex");
|
||||
|
||||
const serviceName = input.mode === "watch" ? "paperclip-dev-watch" : "paperclip-dev-once";
|
||||
const serviceKey = createLocalServiceKey({
|
||||
profileKind: "paperclip-dev",
|
||||
serviceName,
|
||||
cwd: repoRoot,
|
||||
command: "dev-runner.ts",
|
||||
envFingerprint,
|
||||
port: input.port,
|
||||
scope: {
|
||||
repoRoot,
|
||||
mode: input.mode,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
serviceKey,
|
||||
serviceName,
|
||||
envFingerprint,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env -S node --import tsx
|
||||
import { listLocalServiceRegistryRecords, removeLocalServiceRegistryRecord, terminateLocalService } from "../server/src/services/local-service-supervisor.ts";
|
||||
import { repoRoot } from "./dev-service-profile.ts";
|
||||
|
||||
function toDisplayLines(records: Awaited<ReturnType<typeof listLocalServiceRegistryRecords>>) {
|
||||
return records.map((record) => {
|
||||
const childPid = typeof record.metadata?.childPid === "number" ? ` child=${record.metadata.childPid}` : "";
|
||||
const url = typeof record.metadata?.url === "string" ? ` url=${record.metadata.url}` : "";
|
||||
return `${record.serviceName} pid=${record.pid}${childPid} cwd=${record.cwd}${url}`;
|
||||
});
|
||||
}
|
||||
|
||||
const command = process.argv[2] ?? "list";
|
||||
const records = await listLocalServiceRegistryRecords({
|
||||
profileKind: "paperclip-dev",
|
||||
metadata: { repoRoot },
|
||||
});
|
||||
|
||||
if (command === "list") {
|
||||
if (records.length === 0) {
|
||||
console.log("No Paperclip dev services registered for this repo.");
|
||||
process.exit(0);
|
||||
}
|
||||
for (const line of toDisplayLines(records)) {
|
||||
console.log(line);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (command === "stop") {
|
||||
if (records.length === 0) {
|
||||
console.log("No Paperclip dev services registered for this repo.");
|
||||
process.exit(0);
|
||||
}
|
||||
for (const record of records) {
|
||||
await terminateLocalService(record);
|
||||
await removeLocalServiceRegistryRecord(record.serviceKey);
|
||||
console.log(`Stopped ${record.serviceName} (pid ${record.pid})`);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.error(`Unknown dev-service command: ${command}`);
|
||||
process.exit(1);
|
||||
Executable
+78
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
WEBHOOK_URL="${DISCORD_WEBHOOK_URL:-}"
|
||||
DATE="${1:-$(date +%Y-%m-%d)}"
|
||||
REPO_URL="https://github.com/paperclipai/paperclip"
|
||||
|
||||
if [[ -z "$WEBHOOK_URL" ]]; then
|
||||
echo "Error: DISCORD_WEBHOOK_URL env var is required" >&2
|
||||
echo "Usage: DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/... $0 [date]" >&2
|
||||
echo " date defaults to today (YYYY-MM-DD format)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
NEXT_DATE=$(date -j -v+1d -f "%Y-%m-%d" "$DATE" "+%Y-%m-%d" 2>/dev/null \
|
||||
|| date -d "$DATE + 1 day" "+%Y-%m-%d" 2>/dev/null)
|
||||
|
||||
COMMITS=$(git log --since="${DATE}T00:00:00" --until="${NEXT_DATE}T00:00:00" master \
|
||||
--format="%h|%s|%an" 2>/dev/null || true)
|
||||
|
||||
json_escape() {
|
||||
python3 -c 'import json, sys; print(json.dumps(sys.stdin.read().rstrip("\n"))[1:-1])'
|
||||
}
|
||||
if [[ -z "$COMMITS" ]]; then
|
||||
PAYLOAD=$(cat <<ENDJSON
|
||||
{
|
||||
"embeds": [{
|
||||
"title": "📋 Daily Merge Digest — ${DATE}",
|
||||
"description": "No commits were merged into \`master\` today.",
|
||||
"color": 9807270
|
||||
}]
|
||||
}
|
||||
ENDJSON
|
||||
)
|
||||
else
|
||||
COMMIT_COUNT=$(echo "$COMMITS" | wc -l | tr -d ' ')
|
||||
|
||||
LINES=""
|
||||
while IFS='|' read -r hash subject author; do
|
||||
escaped_subject=$(printf '%s' "$subject" | json_escape)
|
||||
escaped_author=$(printf '%s' "$author" | json_escape)
|
||||
LINES="${LINES}• [\`${hash}\`](${REPO_URL}/commit/${hash}) ${escaped_subject} — *${escaped_author}*\\n"
|
||||
done <<< "$COMMITS"
|
||||
|
||||
PAYLOAD=$(cat <<ENDJSON
|
||||
{
|
||||
"embeds": [{
|
||||
"title": "📋 Daily Merge Digest — ${DATE}",
|
||||
"description": "**${COMMIT_COUNT} commit(s)** merged into \`master\` today:\\n\\n${LINES}",
|
||||
"color": 3066993,
|
||||
"footer": {
|
||||
"text": "paperclipai/paperclip • master"
|
||||
}
|
||||
}]
|
||||
}
|
||||
ENDJSON
|
||||
)
|
||||
fi
|
||||
|
||||
if [[ "${DRY_RUN:-}" == "1" ]]; then
|
||||
echo "$PAYLOAD" | python3 -m json.tool 2>/dev/null || echo "$PAYLOAD"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$PAYLOAD" \
|
||||
"$WEBHOOK_URL")
|
||||
|
||||
if [[ "$RESPONSE" == "204" || "$RESPONSE" == "200" ]]; then
|
||||
echo "Discord digest posted for ${DATE} (${COMMIT_COUNT:-0} commits)"
|
||||
else
|
||||
echo "Error: Discord webhook returned HTTP ${RESPONSE}" >&2
|
||||
exit 1
|
||||
fi
|
||||
Executable
+46
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env bash
|
||||
# Verify the Docker image builds successfully.
|
||||
# Skips gracefully when docker/podman is not available.
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
|
||||
# Detect container runtime
|
||||
if command -v docker >/dev/null 2>&1; then
|
||||
RUNTIME=docker
|
||||
elif command -v podman >/dev/null 2>&1; then
|
||||
RUNTIME=podman
|
||||
else
|
||||
echo "SKIP: neither docker nor podman found — skipping build test"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Verify the daemon is reachable (docker may be installed but not running)
|
||||
if ! "$RUNTIME" info >/dev/null 2>&1; then
|
||||
echo "SKIP: $RUNTIME is installed but not running — skipping build test"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
IMAGE_TAG="paperclip-build-test:$$"
|
||||
trap '"$RUNTIME" rmi "$IMAGE_TAG" >/dev/null 2>&1 || true' EXIT
|
||||
|
||||
echo "==> Testing Docker build with $RUNTIME"
|
||||
"$RUNTIME" build \
|
||||
-f "$REPO_ROOT/Dockerfile" \
|
||||
-t "$IMAGE_TAG" \
|
||||
--target production \
|
||||
"$REPO_ROOT"
|
||||
|
||||
echo "==> Verifying key binaries in image"
|
||||
"$RUNTIME" run --rm "$IMAGE_TAG" sh -c '
|
||||
set -e
|
||||
node --version
|
||||
git --version
|
||||
gh --version
|
||||
rg --version
|
||||
python3 --version
|
||||
curl --version | head -1
|
||||
claude --version 2>/dev/null || echo "claude CLI not found (OK in minimal builds)"
|
||||
'
|
||||
|
||||
echo "PASS: Docker build test succeeded"
|
||||
@@ -0,0 +1,42 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Capture runtime UID/GID from environment variables, defaulting to 1000
|
||||
PUID=${USER_UID:-1000}
|
||||
PGID=${USER_GID:-1000}
|
||||
|
||||
# Without root we can neither remap the node user (usermod/groupmod/chown)
|
||||
# nor switch users (gosu needs CAP_SETUID/CAP_SETGID), so exec directly.
|
||||
# This covers Kubernetes restricted PodSecurity (runAsNonRoot + runAsUser)
|
||||
# as well as platforms that assign arbitrary UIDs (e.g. OpenShift); for the
|
||||
# latter a UID/GID mismatch is unfixable here, so warn instead of letting
|
||||
# usermod fail cryptically and keep volume-permission issues diagnosable.
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
if [ "$(id -u)" -ne "$PUID" ] || [ "$(id -g)" -ne "$PGID" ]; then
|
||||
echo "docker-entrypoint.sh: running unprivileged as $(id -u):$(id -g); cannot remap to requested ${PUID}:${PGID}" >&2
|
||||
fi
|
||||
exec "$@"
|
||||
fi
|
||||
|
||||
# Adjust the node user's UID/GID if they differ from the runtime request
|
||||
# and fix volume ownership only when a remap is needed
|
||||
changed=0
|
||||
|
||||
if [ "$(id -u node)" -ne "$PUID" ]; then
|
||||
echo "Updating node UID to $PUID"
|
||||
usermod -o -u "$PUID" node
|
||||
changed=1
|
||||
fi
|
||||
|
||||
if [ "$(id -g node)" -ne "$PGID" ]; then
|
||||
echo "Updating node GID to $PGID"
|
||||
groupmod -o -g "$PGID" node
|
||||
usermod -g "$PGID" node
|
||||
changed=1
|
||||
fi
|
||||
|
||||
if [ "$changed" = "1" ]; then
|
||||
chown -R node:node /paperclip
|
||||
fi
|
||||
|
||||
exec gosu node "$@"
|
||||
Executable
+303
@@ -0,0 +1,303 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
IMAGE_NAME="${IMAGE_NAME:-paperclip-onboard-smoke}"
|
||||
HOST_PORT="${HOST_PORT:-3131}"
|
||||
PAPERCLIPAI_VERSION="${PAPERCLIPAI_VERSION:-latest}"
|
||||
DATA_DIR="${DATA_DIR:-$REPO_ROOT/data/docker-onboard-smoke}"
|
||||
HOST_UID="${HOST_UID:-$(id -u)}"
|
||||
SMOKE_DETACH="${SMOKE_DETACH:-false}"
|
||||
SMOKE_METADATA_FILE="${SMOKE_METADATA_FILE:-}"
|
||||
PAPERCLIP_DEPLOYMENT_MODE="${PAPERCLIP_DEPLOYMENT_MODE:-authenticated}"
|
||||
PAPERCLIP_DEPLOYMENT_EXPOSURE="${PAPERCLIP_DEPLOYMENT_EXPOSURE:-private}"
|
||||
PAPERCLIP_PUBLIC_URL="${PAPERCLIP_PUBLIC_URL:-http://localhost:${HOST_PORT}}"
|
||||
SMOKE_AUTO_BOOTSTRAP="${SMOKE_AUTO_BOOTSTRAP:-true}"
|
||||
SMOKE_ADMIN_NAME="${SMOKE_ADMIN_NAME:-Smoke Admin}"
|
||||
SMOKE_ADMIN_EMAIL="${SMOKE_ADMIN_EMAIL:-smoke-admin@paperclip.local}"
|
||||
SMOKE_ADMIN_PASSWORD="${SMOKE_ADMIN_PASSWORD:-paperclip-smoke-password}"
|
||||
CONTAINER_NAME="${IMAGE_NAME//[^a-zA-Z0-9_.-]/-}"
|
||||
LOG_PID=""
|
||||
COOKIE_JAR=""
|
||||
TMP_DIR=""
|
||||
PRESERVE_CONTAINER_ON_EXIT="false"
|
||||
|
||||
mkdir -p "$DATA_DIR"
|
||||
|
||||
cleanup() {
|
||||
if [[ -n "$LOG_PID" ]]; then
|
||||
kill "$LOG_PID" >/dev/null 2>&1 || true
|
||||
fi
|
||||
if [[ "$PRESERVE_CONTAINER_ON_EXIT" != "true" ]]; then
|
||||
docker stop "$CONTAINER_NAME" >/dev/null 2>&1 || true
|
||||
fi
|
||||
if [[ -n "$TMP_DIR" && -d "$TMP_DIR" ]]; then
|
||||
rm -rf "$TMP_DIR"
|
||||
fi
|
||||
}
|
||||
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
container_is_running() {
|
||||
local running
|
||||
running="$(docker inspect -f '{{.State.Running}}' "$CONTAINER_NAME" 2>/dev/null || true)"
|
||||
[[ "$running" == "true" ]]
|
||||
}
|
||||
|
||||
wait_for_http() {
|
||||
local url="$1"
|
||||
local attempts="${2:-60}"
|
||||
local sleep_seconds="${3:-1}"
|
||||
local i
|
||||
for ((i = 1; i <= attempts; i += 1)); do
|
||||
if curl -fsS "$url" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
if ! container_is_running; then
|
||||
echo "Smoke bootstrap failed: container $CONTAINER_NAME exited before $url became ready" >&2
|
||||
docker logs "$CONTAINER_NAME" >&2 || true
|
||||
return 1
|
||||
fi
|
||||
sleep "$sleep_seconds"
|
||||
done
|
||||
if ! container_is_running; then
|
||||
echo "Smoke bootstrap failed: container $CONTAINER_NAME exited before readiness check completed" >&2
|
||||
docker logs "$CONTAINER_NAME" >&2 || true
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
write_metadata_file() {
|
||||
if [[ -z "$SMOKE_METADATA_FILE" ]]; then
|
||||
return 0
|
||||
fi
|
||||
mkdir -p "$(dirname "$SMOKE_METADATA_FILE")"
|
||||
{
|
||||
printf 'SMOKE_BASE_URL=%q\n' "$PAPERCLIP_PUBLIC_URL"
|
||||
printf 'SMOKE_ADMIN_EMAIL=%q\n' "$SMOKE_ADMIN_EMAIL"
|
||||
printf 'SMOKE_ADMIN_PASSWORD=%q\n' "$SMOKE_ADMIN_PASSWORD"
|
||||
printf 'SMOKE_CONTAINER_NAME=%q\n' "$CONTAINER_NAME"
|
||||
printf 'SMOKE_DATA_DIR=%q\n' "$DATA_DIR"
|
||||
printf 'SMOKE_IMAGE_NAME=%q\n' "$IMAGE_NAME"
|
||||
printf 'SMOKE_PAPERCLIPAI_VERSION=%q\n' "$PAPERCLIPAI_VERSION"
|
||||
} >"$SMOKE_METADATA_FILE"
|
||||
}
|
||||
|
||||
generate_bootstrap_invite_url() {
|
||||
local bootstrap_output
|
||||
local bootstrap_status
|
||||
if bootstrap_output="$(
|
||||
docker exec \
|
||||
-e PAPERCLIP_DEPLOYMENT_MODE="$PAPERCLIP_DEPLOYMENT_MODE" \
|
||||
-e PAPERCLIP_DEPLOYMENT_EXPOSURE="$PAPERCLIP_DEPLOYMENT_EXPOSURE" \
|
||||
-e PAPERCLIP_PUBLIC_URL="$PAPERCLIP_PUBLIC_URL" \
|
||||
-e PAPERCLIP_HOME="/paperclip" \
|
||||
"$CONTAINER_NAME" bash -lc \
|
||||
'timeout 20s npx --yes "paperclipai@${PAPERCLIPAI_VERSION}" auth bootstrap-ceo --data-dir "$PAPERCLIP_HOME" --base-url "$PAPERCLIP_PUBLIC_URL"' \
|
||||
2>&1
|
||||
)"; then
|
||||
bootstrap_status=0
|
||||
else
|
||||
bootstrap_status=$?
|
||||
fi
|
||||
|
||||
if [[ $bootstrap_status -ne 0 && $bootstrap_status -ne 124 ]]; then
|
||||
echo "Smoke bootstrap failed: could not run bootstrap-ceo inside container" >&2
|
||||
printf '%s\n' "$bootstrap_output" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local invite_url
|
||||
invite_url="$(
|
||||
printf '%s\n' "$bootstrap_output" \
|
||||
| grep -o 'https\?://[^[:space:]]*/invite/pcp_bootstrap_[[:alnum:]]*' \
|
||||
| tail -n 1
|
||||
)"
|
||||
|
||||
if [[ -z "$invite_url" ]]; then
|
||||
echo "Smoke bootstrap failed: bootstrap-ceo did not print an invite URL" >&2
|
||||
printf '%s\n' "$bootstrap_output" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ $bootstrap_status -eq 124 ]]; then
|
||||
echo " Smoke bootstrap: bootstrap-ceo timed out after printing invite URL; continuing" >&2
|
||||
fi
|
||||
|
||||
printf '%s\n' "$invite_url"
|
||||
}
|
||||
|
||||
post_json_with_cookies() {
|
||||
local url="$1"
|
||||
local body="$2"
|
||||
local output_file="$3"
|
||||
curl -sS \
|
||||
-o "$output_file" \
|
||||
-w "%{http_code}" \
|
||||
-c "$COOKIE_JAR" \
|
||||
-b "$COOKIE_JAR" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Origin: $PAPERCLIP_PUBLIC_URL" \
|
||||
-X POST \
|
||||
"$url" \
|
||||
--data "$body"
|
||||
}
|
||||
|
||||
get_with_cookies() {
|
||||
local url="$1"
|
||||
curl -fsS \
|
||||
-c "$COOKIE_JAR" \
|
||||
-b "$COOKIE_JAR" \
|
||||
-H "Accept: application/json" \
|
||||
"$url"
|
||||
}
|
||||
|
||||
sign_up_or_sign_in() {
|
||||
local signup_response="$TMP_DIR/signup.json"
|
||||
local signup_status
|
||||
signup_status="$(post_json_with_cookies \
|
||||
"$PAPERCLIP_PUBLIC_URL/api/auth/sign-up/email" \
|
||||
"{\"name\":\"$SMOKE_ADMIN_NAME\",\"email\":\"$SMOKE_ADMIN_EMAIL\",\"password\":\"$SMOKE_ADMIN_PASSWORD\"}" \
|
||||
"$signup_response")"
|
||||
if [[ "$signup_status" =~ ^2 ]]; then
|
||||
echo " Smoke bootstrap: created admin user $SMOKE_ADMIN_EMAIL"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local signin_response="$TMP_DIR/signin.json"
|
||||
local signin_status
|
||||
signin_status="$(post_json_with_cookies \
|
||||
"$PAPERCLIP_PUBLIC_URL/api/auth/sign-in/email" \
|
||||
"{\"email\":\"$SMOKE_ADMIN_EMAIL\",\"password\":\"$SMOKE_ADMIN_PASSWORD\"}" \
|
||||
"$signin_response")"
|
||||
if [[ "$signin_status" =~ ^2 ]]; then
|
||||
echo " Smoke bootstrap: signed in existing admin user $SMOKE_ADMIN_EMAIL"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "Smoke bootstrap failed: could not sign up or sign in admin user" >&2
|
||||
echo "Sign-up response:" >&2
|
||||
cat "$signup_response" >&2 || true
|
||||
echo >&2
|
||||
echo "Sign-in response:" >&2
|
||||
cat "$signin_response" >&2 || true
|
||||
echo >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
auto_bootstrap_authenticated_smoke() {
|
||||
local health_url="$PAPERCLIP_PUBLIC_URL/api/health"
|
||||
local health_json
|
||||
health_json="$(curl -fsS "$health_url")"
|
||||
if [[ "$health_json" != *'"deploymentMode":"authenticated"'* ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
sign_up_or_sign_in
|
||||
|
||||
if [[ "$health_json" == *'"bootstrapStatus":"ready"'* ]]; then
|
||||
echo " Smoke bootstrap: instance already ready"
|
||||
else
|
||||
local invite_url
|
||||
invite_url="$(generate_bootstrap_invite_url)"
|
||||
echo " Smoke bootstrap: generated bootstrap invite via auth bootstrap-ceo"
|
||||
|
||||
local invite_token="${invite_url##*/}"
|
||||
local accept_response="$TMP_DIR/accept.json"
|
||||
local accept_status
|
||||
accept_status="$(post_json_with_cookies \
|
||||
"$PAPERCLIP_PUBLIC_URL/api/invites/$invite_token/accept" \
|
||||
'{"requestType":"human"}' \
|
||||
"$accept_response")"
|
||||
if [[ ! "$accept_status" =~ ^2 ]]; then
|
||||
echo "Smoke bootstrap failed: bootstrap invite acceptance returned HTTP $accept_status" >&2
|
||||
cat "$accept_response" >&2 || true
|
||||
echo >&2
|
||||
return 1
|
||||
fi
|
||||
echo " Smoke bootstrap: accepted bootstrap invite"
|
||||
fi
|
||||
|
||||
local session_json
|
||||
session_json="$(get_with_cookies "$PAPERCLIP_PUBLIC_URL/api/auth/get-session")"
|
||||
if [[ "$session_json" != *'"userId"'* ]]; then
|
||||
echo "Smoke bootstrap failed: no authenticated session after bootstrap" >&2
|
||||
echo "$session_json" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local companies_json
|
||||
companies_json="$(get_with_cookies "$PAPERCLIP_PUBLIC_URL/api/companies")"
|
||||
if [[ "${companies_json:0:1}" != "[" ]]; then
|
||||
echo "Smoke bootstrap failed: board companies endpoint did not return JSON array" >&2
|
||||
echo "$companies_json" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo " Smoke bootstrap: board session verified"
|
||||
echo " Smoke admin credentials: $SMOKE_ADMIN_EMAIL / $SMOKE_ADMIN_PASSWORD"
|
||||
}
|
||||
|
||||
echo "==> Building onboard smoke image"
|
||||
docker build \
|
||||
--build-arg PAPERCLIPAI_VERSION="$PAPERCLIPAI_VERSION" \
|
||||
--build-arg HOST_UID="$HOST_UID" \
|
||||
-f "$REPO_ROOT/docker/Dockerfile.onboard-smoke" \
|
||||
-t "$IMAGE_NAME" \
|
||||
"$REPO_ROOT"
|
||||
|
||||
echo "==> Running onboard smoke container"
|
||||
echo " UI should be reachable at: http://localhost:$HOST_PORT"
|
||||
echo " Public URL: $PAPERCLIP_PUBLIC_URL"
|
||||
echo " Smoke auto-bootstrap: $SMOKE_AUTO_BOOTSTRAP"
|
||||
echo " Detached mode: $SMOKE_DETACH"
|
||||
echo " Data dir: $DATA_DIR"
|
||||
echo " Deployment: $PAPERCLIP_DEPLOYMENT_MODE/$PAPERCLIP_DEPLOYMENT_EXPOSURE"
|
||||
if [[ "$SMOKE_DETACH" != "true" ]]; then
|
||||
echo " Live output: onboard banner and server logs stream in this terminal (Ctrl+C to stop)"
|
||||
fi
|
||||
|
||||
docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
|
||||
|
||||
docker run -d --rm \
|
||||
--name "$CONTAINER_NAME" \
|
||||
-p "$HOST_PORT:3100" \
|
||||
-e HOST=0.0.0.0 \
|
||||
-e PORT=3100 \
|
||||
-e PAPERCLIP_DEPLOYMENT_MODE="$PAPERCLIP_DEPLOYMENT_MODE" \
|
||||
-e PAPERCLIP_DEPLOYMENT_EXPOSURE="$PAPERCLIP_DEPLOYMENT_EXPOSURE" \
|
||||
-e PAPERCLIP_PUBLIC_URL="$PAPERCLIP_PUBLIC_URL" \
|
||||
-v "$DATA_DIR:/paperclip" \
|
||||
"$IMAGE_NAME" >/dev/null
|
||||
|
||||
if [[ "$SMOKE_DETACH" != "true" ]]; then
|
||||
docker logs -f "$CONTAINER_NAME" &
|
||||
LOG_PID=$!
|
||||
fi
|
||||
|
||||
TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/paperclip-onboard-smoke.XXXXXX")"
|
||||
COOKIE_JAR="$TMP_DIR/cookies.txt"
|
||||
|
||||
if ! wait_for_http "$PAPERCLIP_PUBLIC_URL/api/health" 90 1; then
|
||||
echo "Smoke bootstrap failed: server did not become ready at $PAPERCLIP_PUBLIC_URL/api/health" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$SMOKE_AUTO_BOOTSTRAP" == "true" && "$PAPERCLIP_DEPLOYMENT_MODE" == "authenticated" ]]; then
|
||||
auto_bootstrap_authenticated_smoke
|
||||
fi
|
||||
|
||||
write_metadata_file
|
||||
|
||||
if [[ "$SMOKE_DETACH" == "true" ]]; then
|
||||
PRESERVE_CONTAINER_ON_EXIT="true"
|
||||
echo "==> Smoke container ready for automation"
|
||||
echo " Smoke base URL: $PAPERCLIP_PUBLIC_URL"
|
||||
echo " Smoke admin credentials: $SMOKE_ADMIN_EMAIL / $SMOKE_ADMIN_PASSWORD"
|
||||
if [[ -n "$SMOKE_METADATA_FILE" ]]; then
|
||||
echo " Smoke metadata file: $SMOKE_METADATA_FILE"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
wait "$LOG_PID"
|
||||
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const rootDir = path.resolve(scriptDir, "..");
|
||||
const tscCliPath = path.join(rootDir, "node_modules", "typescript", "bin", "tsc");
|
||||
const lockDir = path.join(rootDir, "node_modules", ".cache", "paperclip-plugin-build-deps.lock");
|
||||
const lockTimeoutMs = 60_000;
|
||||
const lockPollMs = 100;
|
||||
|
||||
const buildTargets = [
|
||||
{
|
||||
name: "@paperclipai/shared",
|
||||
output: path.join(rootDir, "packages/shared/dist/index.js"),
|
||||
sourceDir: path.join(rootDir, "packages/shared/src"),
|
||||
tsconfig: path.join(rootDir, "packages/shared/tsconfig.json"),
|
||||
},
|
||||
{
|
||||
name: "@paperclipai/plugin-sdk",
|
||||
output: path.join(rootDir, "packages/plugins/sdk/dist/index.js"),
|
||||
sourceDir: path.join(rootDir, "packages/plugins/sdk/src"),
|
||||
tsconfig: path.join(rootDir, "packages/plugins/sdk/tsconfig.json"),
|
||||
},
|
||||
];
|
||||
|
||||
if (!fs.existsSync(tscCliPath)) {
|
||||
throw new Error(`TypeScript CLI not found at ${tscCliPath}`);
|
||||
}
|
||||
|
||||
function newestSourceMtimeMs(sourceDir) {
|
||||
let newest = 0;
|
||||
|
||||
function visit(dir) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const entryPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
visit(entryPath);
|
||||
continue;
|
||||
}
|
||||
if (!/\.(tsx?|json)$/.test(entry.name)) continue;
|
||||
newest = Math.max(newest, fs.statSync(entryPath).mtimeMs);
|
||||
}
|
||||
}
|
||||
|
||||
visit(sourceDir);
|
||||
return newest;
|
||||
}
|
||||
|
||||
function needsBuild(target) {
|
||||
if (!fs.existsSync(target.output)) return true;
|
||||
const outputMtime = fs.statSync(target.output).mtimeMs;
|
||||
return newestSourceMtimeMs(target.sourceDir) > outputMtime;
|
||||
}
|
||||
|
||||
function allOutputsCurrent() {
|
||||
return buildTargets.every((target) => !needsBuild(target));
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
||||
}
|
||||
|
||||
function waitForLockRelease() {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < lockTimeoutMs) {
|
||||
if (!fs.existsSync(lockDir)) {
|
||||
return;
|
||||
}
|
||||
if (allOutputsCurrent()) {
|
||||
return;
|
||||
}
|
||||
sleep(lockPollMs);
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for plugin build dependency lock at ${lockDir}`);
|
||||
}
|
||||
|
||||
if (allOutputsCurrent()) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(lockDir), { recursive: true });
|
||||
|
||||
let holdsLock = false;
|
||||
let exitCode = 0;
|
||||
try {
|
||||
try {
|
||||
fs.mkdirSync(lockDir);
|
||||
holdsLock = true;
|
||||
} catch (error) {
|
||||
if (error && typeof error === "object" && "code" in error && error.code === "EEXIST") {
|
||||
waitForLockRelease();
|
||||
if (!allOutputsCurrent()) {
|
||||
throw new Error("Plugin build dependency lock released before all outputs were created");
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
for (const target of buildTargets) {
|
||||
if (!needsBuild(target)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = spawnSync(process.execPath, [tscCliPath, "-p", target.tsconfig], {
|
||||
cwd: rootDir,
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
if (result.status !== 0) {
|
||||
exitCode = result.status ?? 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (holdsLock) {
|
||||
fs.rmSync(lockDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (exitCode !== 0) {
|
||||
process.exit(exitCode);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env -S node --import tsx
|
||||
import fs from "node:fs/promises";
|
||||
import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { repoRoot } from "./dev-service-profile.ts";
|
||||
|
||||
type WorkspaceLinkMismatch = {
|
||||
workspaceDir: string;
|
||||
packageName: string;
|
||||
expectedPath: string;
|
||||
actualPath: string | null;
|
||||
};
|
||||
|
||||
function readJsonFile(filePath: string): Record<string, unknown> {
|
||||
return JSON.parse(readFileSync(filePath, "utf8")) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function discoverWorkspacePackagePaths(rootDir: string): Map<string, string> {
|
||||
const packagePaths = new Map<string, string>();
|
||||
const ignoredDirNames = new Set([".git", ".paperclip", "dist", "node_modules"]);
|
||||
|
||||
function visit(dirPath: string) {
|
||||
const packageJsonPath = path.join(dirPath, "package.json");
|
||||
if (existsSync(packageJsonPath)) {
|
||||
const packageJson = readJsonFile(packageJsonPath);
|
||||
if (typeof packageJson.name === "string" && packageJson.name.length > 0) {
|
||||
packagePaths.set(packageJson.name, dirPath);
|
||||
}
|
||||
}
|
||||
|
||||
for (const entry of readdirSync(dirPath, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (ignoredDirNames.has(entry.name)) continue;
|
||||
visit(path.join(dirPath, entry.name));
|
||||
}
|
||||
}
|
||||
|
||||
visit(path.join(rootDir, "packages"));
|
||||
visit(path.join(rootDir, "server"));
|
||||
visit(path.join(rootDir, "ui"));
|
||||
visit(path.join(rootDir, "cli"));
|
||||
|
||||
return packagePaths;
|
||||
}
|
||||
|
||||
const workspacePackagePaths = discoverWorkspacePackagePaths(repoRoot);
|
||||
const workspaceDirs = Array.from(
|
||||
new Set(
|
||||
Array.from(workspacePackagePaths.values())
|
||||
.map((packagePath) => path.relative(repoRoot, packagePath))
|
||||
.filter((workspaceDir) => workspaceDir.length > 0),
|
||||
),
|
||||
).sort();
|
||||
|
||||
function findWorkspaceLinkMismatches(workspaceDir: string): WorkspaceLinkMismatch[] {
|
||||
const nodeModulesDir = path.join(repoRoot, workspaceDir, "node_modules");
|
||||
if (!existsSync(nodeModulesDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const packageJson = readJsonFile(path.join(repoRoot, workspaceDir, "package.json"));
|
||||
const dependencies = {
|
||||
...(packageJson.dependencies as Record<string, unknown> | undefined),
|
||||
...(packageJson.devDependencies as Record<string, unknown> | undefined),
|
||||
};
|
||||
const mismatches: WorkspaceLinkMismatch[] = [];
|
||||
|
||||
for (const [packageName, version] of Object.entries(dependencies)) {
|
||||
if (typeof version !== "string" || !version.startsWith("workspace:")) continue;
|
||||
|
||||
const expectedPath = workspacePackagePaths.get(packageName);
|
||||
if (!expectedPath) continue;
|
||||
|
||||
const linkPath = path.join(repoRoot, workspaceDir, "node_modules", ...packageName.split("/"));
|
||||
const actualPath = existsSync(linkPath) ? path.resolve(realpathSync(linkPath)) : null;
|
||||
if (actualPath === path.resolve(expectedPath)) continue;
|
||||
|
||||
mismatches.push({
|
||||
workspaceDir,
|
||||
packageName,
|
||||
expectedPath: path.resolve(expectedPath),
|
||||
actualPath,
|
||||
});
|
||||
}
|
||||
|
||||
return mismatches;
|
||||
}
|
||||
|
||||
async function ensureWorkspaceLinksCurrent(workspaceDir: string) {
|
||||
const mismatches = findWorkspaceLinkMismatches(workspaceDir);
|
||||
if (mismatches.length === 0) return;
|
||||
|
||||
console.log(`[paperclip] detected stale workspace package links for ${workspaceDir}; relinking dependencies...`);
|
||||
for (const mismatch of mismatches) {
|
||||
console.log(
|
||||
`[paperclip] ${mismatch.packageName}: ${mismatch.actualPath ?? "missing"} -> ${mismatch.expectedPath}`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const mismatch of mismatches) {
|
||||
const linkPath = path.join(repoRoot, mismatch.workspaceDir, "node_modules", ...mismatch.packageName.split("/"));
|
||||
await fs.mkdir(path.dirname(linkPath), { recursive: true });
|
||||
await fs.rm(linkPath, { recursive: true, force: true });
|
||||
await fs.symlink(mismatch.expectedPath, linkPath);
|
||||
}
|
||||
|
||||
const remainingMismatches = findWorkspaceLinkMismatches(workspaceDir);
|
||||
if (remainingMismatches.length === 0) return;
|
||||
|
||||
throw new Error(
|
||||
`Workspace relink did not repair all ${workspaceDir} package links: ${remainingMismatches.map((item) => item.packageName).join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const workspaceDir of workspaceDirs) {
|
||||
await ensureWorkspaceLinksCurrent(workspaceDir);
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* Generate org chart images and READMEs for agent company packages.
|
||||
*
|
||||
* Reads company packages from a directory, builds manifest-like data,
|
||||
* then uses the existing server-side SVG renderer (sharp, no browser)
|
||||
* and README generator.
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx scripts/generate-company-assets.ts /path/to/companies-repo
|
||||
*
|
||||
* Processes each subdirectory that contains a COMPANY.md file.
|
||||
*/
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import { renderOrgChartPng, type OrgNode, type OrgChartOverlay } from "../server/src/routes/org-chart-svg.js";
|
||||
import { generateReadme } from "../server/src/services/company-export-readme.js";
|
||||
import type { CompanyPortabilityManifest } from "@paperclipai/shared";
|
||||
|
||||
// ── YAML frontmatter parser (minimal, no deps) ──────────────────
|
||||
|
||||
function parseFrontmatter(content: string): { data: Record<string, unknown>; body: string } {
|
||||
const match = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
||||
if (!match) return { data: {}, body: content };
|
||||
const yamlStr = match[1];
|
||||
const body = match[2];
|
||||
const data: Record<string, unknown> = {};
|
||||
|
||||
let currentKey: string | null = null;
|
||||
let currentValue: string | string[] | null = null;
|
||||
let inList = false;
|
||||
|
||||
for (const line of yamlStr.split("\n")) {
|
||||
// List item
|
||||
if (inList && /^\s+-\s+/.test(line)) {
|
||||
const val = line.replace(/^\s+-\s+/, "").trim();
|
||||
(currentValue as string[]).push(val);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Save previous key
|
||||
if (currentKey !== null && currentValue !== null) {
|
||||
data[currentKey] = currentValue;
|
||||
}
|
||||
inList = false;
|
||||
|
||||
// Key: value line
|
||||
const kvMatch = line.match(/^(\w[\w-]*)\s*:\s*(.*)$/);
|
||||
if (kvMatch) {
|
||||
currentKey = kvMatch[1];
|
||||
let val = kvMatch[2].trim();
|
||||
|
||||
if (val === "" || val === ">") {
|
||||
// Could be a multi-line value or list — peek ahead handled by next iterations
|
||||
currentValue = "";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (val === "null" || val === "~") {
|
||||
currentValue = null;
|
||||
data[currentKey] = null;
|
||||
currentKey = null;
|
||||
currentValue = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Remove surrounding quotes
|
||||
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
||||
val = val.slice(1, -1);
|
||||
}
|
||||
|
||||
currentValue = val;
|
||||
} else if (currentKey !== null && line.match(/^\s+-\s+/)) {
|
||||
// Start of list
|
||||
inList = true;
|
||||
currentValue = [];
|
||||
const val = line.replace(/^\s+-\s+/, "").trim();
|
||||
(currentValue as string[]).push(val);
|
||||
} else if (currentKey !== null && line.match(/^\s+\S/)) {
|
||||
// Continuation of multi-line scalar
|
||||
const trimmed = line.trim();
|
||||
if (typeof currentValue === "string") {
|
||||
currentValue = currentValue ? `${currentValue} ${trimmed}` : trimmed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save last key
|
||||
if (currentKey !== null && currentValue !== null) {
|
||||
data[currentKey] = currentValue;
|
||||
}
|
||||
|
||||
return { data, body };
|
||||
}
|
||||
|
||||
// ── Slug to role mapping ─────────────────────────────────────────
|
||||
|
||||
const SLUG_TO_ROLE: Record<string, string> = {
|
||||
ceo: "ceo",
|
||||
cto: "cto",
|
||||
cmo: "cmo",
|
||||
cfo: "cfo",
|
||||
coo: "coo",
|
||||
};
|
||||
|
||||
function inferRole(slug: string, title: string | null): string {
|
||||
// Check direct slug match first
|
||||
if (SLUG_TO_ROLE[slug]) return SLUG_TO_ROLE[slug];
|
||||
|
||||
// Check title for C-suite
|
||||
const t = (title || "").toLowerCase();
|
||||
if (t.includes("chief executive")) return "ceo";
|
||||
if (t.includes("chief technology")) return "cto";
|
||||
if (t.includes("chief marketing")) return "cmo";
|
||||
if (t.includes("chief financial")) return "cfo";
|
||||
if (t.includes("chief operating")) return "coo";
|
||||
if (t.includes("vp") || t.includes("vice president")) return "vp";
|
||||
if (t.includes("manager")) return "manager";
|
||||
if (t.includes("qa") || t.includes("quality")) return "engineer";
|
||||
|
||||
// Default to engineer
|
||||
return "engineer";
|
||||
}
|
||||
|
||||
// ── Parse a company package directory ────────────────────────────
|
||||
|
||||
interface CompanyPackage {
|
||||
dir: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
slug: string;
|
||||
agents: CompanyPortabilityManifest["agents"];
|
||||
skills: CompanyPortabilityManifest["skills"];
|
||||
}
|
||||
|
||||
function parseCompanyPackage(companyDir: string): CompanyPackage | null {
|
||||
const companyMdPath = path.join(companyDir, "COMPANY.md");
|
||||
if (!fs.existsSync(companyMdPath)) return null;
|
||||
|
||||
const companyMd = fs.readFileSync(companyMdPath, "utf-8");
|
||||
const { data: companyData } = parseFrontmatter(companyMd);
|
||||
|
||||
const name = (companyData.name as string) || path.basename(companyDir);
|
||||
const description = (companyData.description as string) || null;
|
||||
const slug = (companyData.slug as string) || path.basename(companyDir);
|
||||
|
||||
// Parse agents
|
||||
const agentsDir = path.join(companyDir, "agents");
|
||||
const agents: CompanyPortabilityManifest["agents"] = [];
|
||||
if (fs.existsSync(agentsDir)) {
|
||||
for (const agentSlug of fs.readdirSync(agentsDir)) {
|
||||
const agentMdName = fs.existsSync(path.join(agentsDir, agentSlug, "AGENT.md"))
|
||||
? "AGENT.md"
|
||||
: fs.existsSync(path.join(agentsDir, agentSlug, "AGENTS.md"))
|
||||
? "AGENTS.md"
|
||||
: null;
|
||||
if (!agentMdName) continue;
|
||||
const agentMdPath = path.join(agentsDir, agentSlug, agentMdName);
|
||||
|
||||
const agentMd = fs.readFileSync(agentMdPath, "utf-8");
|
||||
const { data: agentData } = parseFrontmatter(agentMd);
|
||||
|
||||
const agentName = (agentData.name as string) || agentSlug;
|
||||
const title = (agentData.title as string) || null;
|
||||
const reportsTo = agentData.reportsTo as string | null;
|
||||
const skills = (agentData.skills as string[]) || [];
|
||||
const role = inferRole(agentSlug, title);
|
||||
|
||||
agents.push({
|
||||
slug: agentSlug,
|
||||
name: agentName,
|
||||
path: `agents/${agentSlug}/${agentMdName}`,
|
||||
skills,
|
||||
role,
|
||||
title,
|
||||
icon: null,
|
||||
capabilities: null,
|
||||
reportsToSlug: reportsTo || null,
|
||||
adapterType: "claude_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
budgetMonthlyCents: 0,
|
||||
metadata: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Parse skills
|
||||
const skillsDir = path.join(companyDir, "skills");
|
||||
const skills: CompanyPortabilityManifest["skills"] = [];
|
||||
if (fs.existsSync(skillsDir)) {
|
||||
for (const skillSlug of fs.readdirSync(skillsDir)) {
|
||||
const skillMdPath = path.join(skillsDir, skillSlug, "SKILL.md");
|
||||
if (!fs.existsSync(skillMdPath)) continue;
|
||||
|
||||
const skillMd = fs.readFileSync(skillMdPath, "utf-8");
|
||||
const { data: skillData } = parseFrontmatter(skillMd);
|
||||
|
||||
const skillName = (skillData.name as string) || skillSlug;
|
||||
const skillDesc = (skillData.description as string) || null;
|
||||
|
||||
// Extract source info from metadata
|
||||
let sourceType = "local";
|
||||
let sourceLocator: string | null = null;
|
||||
const metadata = skillData.metadata as Record<string, unknown> | undefined;
|
||||
if (metadata) {
|
||||
// metadata.sources is parsed as a nested structure, but our simple parser
|
||||
// doesn't handle it well. Check for github repo in the raw SKILL.md instead.
|
||||
const repoMatch = skillMd.match(/repo:\s*(.+)/);
|
||||
const pathMatch = skillMd.match(/path:\s*(.+)/);
|
||||
if (repoMatch) {
|
||||
sourceType = "github";
|
||||
const repo = repoMatch[1].trim();
|
||||
const filePath = pathMatch ? pathMatch[1].trim() : "";
|
||||
sourceLocator = `https://github.com/${repo}/blob/main/${filePath}`;
|
||||
}
|
||||
}
|
||||
|
||||
skills.push({
|
||||
key: skillSlug,
|
||||
slug: skillSlug,
|
||||
name: skillName,
|
||||
path: `skills/${skillSlug}/SKILL.md`,
|
||||
description: skillDesc,
|
||||
sourceType,
|
||||
sourceLocator,
|
||||
sourceRef: null,
|
||||
trustLevel: null,
|
||||
compatibility: null,
|
||||
metadata: null,
|
||||
fileInventory: [{ path: `skills/${skillSlug}/SKILL.md`, kind: "skill" }],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { dir: companyDir, name, description, slug, agents, skills };
|
||||
}
|
||||
|
||||
// ── Build OrgNode tree from agents ───────────────────────────────
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
ceo: "Chief Executive",
|
||||
cto: "Technology",
|
||||
cmo: "Marketing",
|
||||
cfo: "Finance",
|
||||
coo: "Operations",
|
||||
vp: "VP",
|
||||
manager: "Manager",
|
||||
engineer: "Engineer",
|
||||
agent: "Agent",
|
||||
};
|
||||
|
||||
function buildOrgTree(agents: CompanyPortabilityManifest["agents"]): OrgNode[] {
|
||||
const bySlug = new Map(agents.map((a) => [a.slug, a]));
|
||||
const childrenOf = new Map<string | null, typeof agents>();
|
||||
for (const a of agents) {
|
||||
const parent = a.reportsToSlug ?? null;
|
||||
const list = childrenOf.get(parent) ?? [];
|
||||
list.push(a);
|
||||
childrenOf.set(parent, list);
|
||||
}
|
||||
const build = (parentSlug: string | null): OrgNode[] => {
|
||||
const members = childrenOf.get(parentSlug) ?? [];
|
||||
return members.map((m) => ({
|
||||
id: m.slug,
|
||||
name: m.name,
|
||||
role: ROLE_LABELS[m.role] ?? m.role,
|
||||
status: "active",
|
||||
reports: build(m.slug),
|
||||
}));
|
||||
};
|
||||
const roots = agents.filter((a) => !a.reportsToSlug || !bySlug.has(a.reportsToSlug));
|
||||
const tree = build(null);
|
||||
for (const root of roots) {
|
||||
if (root.reportsToSlug && !bySlug.has(root.reportsToSlug)) {
|
||||
tree.push({
|
||||
id: root.slug,
|
||||
name: root.name,
|
||||
role: ROLE_LABELS[root.role] ?? root.role,
|
||||
status: "active",
|
||||
reports: build(root.slug),
|
||||
});
|
||||
}
|
||||
}
|
||||
return tree;
|
||||
}
|
||||
|
||||
// ── Main ─────────────────────────────────────────────────────────
|
||||
|
||||
async function main() {
|
||||
const companiesDir = process.argv[2];
|
||||
if (!companiesDir) {
|
||||
console.error("Usage: npx tsx scripts/generate-company-assets.ts <companies-dir>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const resolvedDir = path.resolve(companiesDir);
|
||||
if (!fs.existsSync(resolvedDir)) {
|
||||
console.error(`Directory not found: ${resolvedDir}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const entries = fs.readdirSync(resolvedDir, { withFileTypes: true });
|
||||
let processed = 0;
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const companyDir = path.join(resolvedDir, entry.name);
|
||||
const pkg = parseCompanyPackage(companyDir);
|
||||
if (!pkg) continue;
|
||||
|
||||
console.log(`\n── ${pkg.name} (${pkg.slug}) ──`);
|
||||
console.log(` ${pkg.agents.length} agents, ${pkg.skills.length} skills`);
|
||||
|
||||
// Generate org chart PNG
|
||||
if (pkg.agents.length > 0) {
|
||||
const orgTree = buildOrgTree(pkg.agents);
|
||||
console.log(` Org tree roots: ${orgTree.map((n) => n.name).join(", ")}`);
|
||||
|
||||
const overlay: OrgChartOverlay = {
|
||||
companyName: pkg.name,
|
||||
stats: `Agents: ${pkg.agents.length}, Skills: ${pkg.skills.length}`,
|
||||
};
|
||||
const pngBuffer = await renderOrgChartPng(orgTree, "warmth", overlay);
|
||||
const imagesDir = path.join(companyDir, "images");
|
||||
fs.mkdirSync(imagesDir, { recursive: true });
|
||||
const pngPath = path.join(imagesDir, "org-chart.png");
|
||||
fs.writeFileSync(pngPath, pngBuffer);
|
||||
console.log(` ✓ ${path.relative(resolvedDir, pngPath)} (${(pngBuffer.length / 1024).toFixed(1)}kb)`);
|
||||
}
|
||||
|
||||
// Generate README
|
||||
const manifest: CompanyPortabilityManifest = {
|
||||
schemaVersion: 1,
|
||||
generatedAt: new Date().toISOString(),
|
||||
source: null,
|
||||
includes: { company: true, agents: true, projects: false, issues: false, skills: true },
|
||||
company: null,
|
||||
agents: pkg.agents,
|
||||
skills: pkg.skills,
|
||||
projects: [],
|
||||
issues: [],
|
||||
envInputs: [],
|
||||
};
|
||||
|
||||
const readme = generateReadme(manifest, {
|
||||
companyName: pkg.name,
|
||||
companyDescription: pkg.description,
|
||||
});
|
||||
const readmePath = path.join(companyDir, "README.md");
|
||||
fs.writeFileSync(readmePath, readme);
|
||||
console.log(` ✓ ${path.relative(resolvedDir, readmePath)}`);
|
||||
|
||||
processed++;
|
||||
}
|
||||
|
||||
console.log(`\n✓ Processed ${processed} companies.`);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* generate-npm-package-json.mjs
|
||||
*
|
||||
* Reads the dev package.json (which has workspace:* refs) and produces
|
||||
* a publishable package.json in cli/ with:
|
||||
* - workspace:* dependencies removed
|
||||
* - all external dependencies from workspace packages inlined
|
||||
* - proper metadata for npm
|
||||
*
|
||||
* Reads from cli/package.dev.json if it exists (build already ran),
|
||||
* otherwise from cli/package.json.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { resolve, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = resolve(__dirname, "..");
|
||||
|
||||
function readPkg(relativePath) {
|
||||
return JSON.parse(readFileSync(resolve(repoRoot, relativePath, "package.json"), "utf8"));
|
||||
}
|
||||
|
||||
// Read all workspace packages that are BUNDLED into the CLI.
|
||||
// Note: "server" is excluded — it's published separately as a dependency.
|
||||
const workspacePaths = [
|
||||
"cli",
|
||||
"packages/db",
|
||||
"packages/shared",
|
||||
"packages/adapter-utils",
|
||||
"packages/adapters/claude-local",
|
||||
"packages/adapters/codex-local",
|
||||
"packages/adapters/hermes-gateway",
|
||||
"packages/adapters/hermes",
|
||||
"packages/adapters/opencode-local",
|
||||
"packages/adapters/openclaw-gateway",
|
||||
];
|
||||
|
||||
// Workspace packages that are NOT bundled and must stay as npm dependencies.
|
||||
// These get published separately and resolved at runtime.
|
||||
const externalWorkspacePackages = new Set([
|
||||
"@paperclipai/server",
|
||||
]);
|
||||
|
||||
// Collect all external dependencies from all workspace packages
|
||||
const allDeps = {};
|
||||
const allOptionalDeps = {};
|
||||
|
||||
for (const pkgPath of workspacePaths) {
|
||||
const pkg = readPkg(pkgPath);
|
||||
const deps = pkg.dependencies || {};
|
||||
const optDeps = pkg.optionalDependencies || {};
|
||||
|
||||
for (const [name, version] of Object.entries(deps)) {
|
||||
if (name.startsWith("@paperclipai/") && !externalWorkspacePackages.has(name)) continue;
|
||||
// For external workspace packages, read their version directly
|
||||
if (externalWorkspacePackages.has(name)) {
|
||||
const pkgDirMap = { "@paperclipai/server": "server" };
|
||||
const wsPkg = readPkg(pkgDirMap[name]);
|
||||
allDeps[name] = wsPkg.version;
|
||||
continue;
|
||||
}
|
||||
// Keep the more specific (pinned) version if conflict
|
||||
if (!allDeps[name] || !version.startsWith("^")) {
|
||||
allDeps[name] = version;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [name, version] of Object.entries(optDeps)) {
|
||||
allOptionalDeps[name] = version;
|
||||
}
|
||||
}
|
||||
|
||||
// Sort alphabetically
|
||||
const sortedDeps = Object.fromEntries(Object.entries(allDeps).sort(([a], [b]) => a.localeCompare(b)));
|
||||
const sortedOptDeps = Object.fromEntries(
|
||||
Object.entries(allOptionalDeps).sort(([a], [b]) => a.localeCompare(b)),
|
||||
);
|
||||
|
||||
// Read the CLI package metadata — prefer the dev backup if it exists
|
||||
const devPkgPath = resolve(repoRoot, "cli/package.dev.json");
|
||||
const cliPkg = existsSync(devPkgPath)
|
||||
? JSON.parse(readFileSync(devPkgPath, "utf8"))
|
||||
: readPkg("cli");
|
||||
|
||||
// Build the publishable package.json
|
||||
const publishPkg = {
|
||||
name: cliPkg.name,
|
||||
version: cliPkg.version,
|
||||
description: cliPkg.description,
|
||||
type: cliPkg.type,
|
||||
bin: cliPkg.bin,
|
||||
keywords: cliPkg.keywords,
|
||||
license: cliPkg.license,
|
||||
repository: cliPkg.repository,
|
||||
homepage: cliPkg.homepage,
|
||||
bugs: cliPkg.bugs,
|
||||
files: cliPkg.files,
|
||||
engines: { node: ">=20" },
|
||||
dependencies: sortedDeps,
|
||||
};
|
||||
|
||||
if (Object.keys(sortedOptDeps).length > 0) {
|
||||
publishPkg.optionalDependencies = sortedOptDeps;
|
||||
}
|
||||
|
||||
const output = JSON.stringify(publishPkg, null, 2) + "\n";
|
||||
const outPath = resolve(repoRoot, "cli/package.json");
|
||||
writeFileSync(outPath, output);
|
||||
|
||||
console.log(` ✓ Generated publishable package.json (${Object.keys(sortedDeps).length} deps)`);
|
||||
console.log(` Version: ${cliPkg.version}`);
|
||||
@@ -0,0 +1,694 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* Standalone org chart image generator.
|
||||
*
|
||||
* Renders each of the 5 org chart styles to PNG using Playwright (headless Chromium).
|
||||
* This gives us browser-native emoji rendering, full CSS support, and pixel-perfect output.
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx scripts/generate-org-chart-images.ts
|
||||
*
|
||||
* Output: tmp/org-chart-images/<style>-<size>.png
|
||||
*/
|
||||
import { chromium } from "@playwright/test";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
|
||||
// ── Org data (same as index.html) ──────────────────────────────
|
||||
|
||||
interface OrgNode {
|
||||
name: string;
|
||||
role: string;
|
||||
icon?: string;
|
||||
tag: string;
|
||||
children?: OrgNode[];
|
||||
}
|
||||
|
||||
const ORGS: Record<string, OrgNode> = {
|
||||
sm: {
|
||||
name: "CEO",
|
||||
role: "Chief Executive",
|
||||
icon: "👑",
|
||||
tag: "ceo",
|
||||
children: [
|
||||
{ name: "Engineer", role: "Engineer", icon: "⌨️", tag: "eng" },
|
||||
{ name: "Designer", role: "Design", icon: "🪄", tag: "des" },
|
||||
],
|
||||
},
|
||||
med: {
|
||||
name: "CEO",
|
||||
role: "Chief Executive",
|
||||
icon: "👑",
|
||||
tag: "ceo",
|
||||
children: [
|
||||
{
|
||||
name: "CTO",
|
||||
role: "Technology",
|
||||
icon: "💻",
|
||||
tag: "cto",
|
||||
children: [
|
||||
{ name: "ClaudeCoder", role: "Engineer", tag: "eng" },
|
||||
{ name: "CodexCoder", role: "Engineer", tag: "eng" },
|
||||
{ name: "SparkCoder", role: "Engineer", tag: "eng" },
|
||||
{ name: "CursorCoder", role: "Engineer", tag: "eng" },
|
||||
{ name: "QA", role: "Quality", tag: "qa" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "CMO",
|
||||
role: "Marketing",
|
||||
icon: "🌐",
|
||||
tag: "cmo",
|
||||
children: [{ name: "Designer", role: "Design", tag: "des" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
lg: {
|
||||
name: "CEO",
|
||||
role: "Chief Executive",
|
||||
icon: "👑",
|
||||
tag: "ceo",
|
||||
children: [
|
||||
{
|
||||
name: "CTO",
|
||||
role: "Technology",
|
||||
icon: "💻",
|
||||
tag: "cto",
|
||||
children: [
|
||||
{ name: "Eng 1", role: "Eng", tag: "eng" },
|
||||
{ name: "Eng 2", role: "Eng", tag: "eng" },
|
||||
{ name: "Eng 3", role: "Eng", tag: "eng" },
|
||||
{ name: "QA", role: "QA", tag: "qa" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "CMO",
|
||||
role: "Marketing",
|
||||
icon: "🌐",
|
||||
tag: "cmo",
|
||||
children: [
|
||||
{ name: "Designer", role: "Design", tag: "des" },
|
||||
{ name: "Content", role: "Writer", tag: "eng" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "CFO",
|
||||
role: "Finance",
|
||||
icon: "📊",
|
||||
tag: "fin",
|
||||
children: [{ name: "Analyst", role: "Finance", tag: "fin" }],
|
||||
},
|
||||
{
|
||||
name: "COO",
|
||||
role: "Operations",
|
||||
icon: "⚙️",
|
||||
tag: "ops",
|
||||
children: [
|
||||
{ name: "Ops 1", role: "Ops", tag: "ops" },
|
||||
{ name: "Ops 2", role: "Ops", tag: "ops" },
|
||||
{ name: "DevOps", role: "Infra", tag: "ops" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// OG collapsed org
|
||||
const OG_ORG: OrgNode = {
|
||||
name: "CEO",
|
||||
role: "Chief Executive",
|
||||
tag: "ceo",
|
||||
children: [
|
||||
{ name: "CTO", role: "×5 reports", tag: "cto" },
|
||||
{ name: "CMO", role: "×1 report", tag: "cmo" },
|
||||
],
|
||||
};
|
||||
|
||||
// ── Style definitions ──────────────────────────────────────────
|
||||
|
||||
interface StyleDef {
|
||||
key: string;
|
||||
name: string;
|
||||
css: string;
|
||||
renderCard: (node: OrgNode, isOg: boolean) => string;
|
||||
}
|
||||
|
||||
const COMMON_CSS = `
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap');
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.org-tree {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: max-content;
|
||||
--line-color: #3f3f46;
|
||||
--line-w: 1.5px;
|
||||
--drop-h: 20px;
|
||||
--child-gap: 14px;
|
||||
}
|
||||
|
||||
.org-node {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.org-children {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding-top: calc(var(--drop-h) * 2);
|
||||
position: relative;
|
||||
gap: var(--child-gap);
|
||||
}
|
||||
|
||||
.org-children::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: var(--line-w);
|
||||
height: calc(var(--drop-h) * 2);
|
||||
background: var(--line-color);
|
||||
}
|
||||
|
||||
.org-children > .org-node {
|
||||
padding-top: var(--drop-h);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.org-children > .org-node::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: var(--line-w);
|
||||
height: var(--drop-h);
|
||||
background: var(--line-color);
|
||||
}
|
||||
|
||||
.org-children > .org-node::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: calc(-0.5 * var(--child-gap));
|
||||
right: calc(-0.5 * var(--child-gap));
|
||||
height: var(--line-w);
|
||||
background: var(--line-color);
|
||||
}
|
||||
|
||||
.org-children > .org-node:first-child::after { left: 50%; }
|
||||
.org-children > .org-node:last-child::after { right: 50%; }
|
||||
.org-children > .org-node:only-child::after { display: none; }
|
||||
|
||||
.org-card {
|
||||
text-align: center;
|
||||
position: relative;
|
||||
}
|
||||
.org-card .name { white-space: nowrap; }
|
||||
.org-card .role { white-space: nowrap; }
|
||||
.org-card .icon-wrap { margin-bottom: 8px; font-size: 18px; line-height: 1; }
|
||||
|
||||
/* OG compact overrides */
|
||||
.og-compact .org-card { padding: 10px 14px !important; min-width: 80px !important; }
|
||||
.og-compact .org-card .name { font-size: 11px !important; }
|
||||
.og-compact .org-card .role { font-size: 9px !important; }
|
||||
.og-compact .org-card .icon-wrap { font-size: 14px !important; margin-bottom: 5px !important; }
|
||||
.og-compact .org-card .avatar { width: 24px !important; height: 24px !important; font-size: 11px !important; margin-bottom: 6px !important; }
|
||||
.og-compact .org-children { padding-top: 20px !important; gap: 8px !important; }
|
||||
.og-compact .org-tree { --drop-h: 10px; --child-gap: 8px; }
|
||||
|
||||
/* Watermark */
|
||||
.watermark {
|
||||
position: absolute;
|
||||
bottom: 12px;
|
||||
right: 16px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: rgba(128,128,128,0.4);
|
||||
font-family: 'Inter', sans-serif;
|
||||
letter-spacing: 0.02em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
.watermark svg { opacity: 0.4; }
|
||||
`;
|
||||
|
||||
const STYLES: StyleDef[] = [
|
||||
{
|
||||
key: "mono",
|
||||
name: "Monochrome",
|
||||
css: `
|
||||
body { background: #18181b; }
|
||||
.org-tree { --line-color: #3f3f46; }
|
||||
.org-card {
|
||||
background: #18181b;
|
||||
border: 1px solid #27272a;
|
||||
border-radius: 6px;
|
||||
padding: 16px 22px;
|
||||
min-width: 130px;
|
||||
}
|
||||
.org-card .name {
|
||||
font-size: 14px; font-weight: 600; color: #fafafa;
|
||||
letter-spacing: -0.01em; margin-bottom: 3px;
|
||||
}
|
||||
.org-card .role {
|
||||
font-size: 10px; color: #71717a;
|
||||
text-transform: uppercase; letter-spacing: 0.06em; font-weight: 500;
|
||||
}
|
||||
.watermark { color: rgba(255,255,255,0.25); }
|
||||
.watermark svg { stroke: rgba(255,255,255,0.25); }
|
||||
`,
|
||||
renderCard: (node, isOg) => {
|
||||
const icon =
|
||||
node.icon && !isOg
|
||||
? `<div class="icon-wrap">${node.icon}</div>`
|
||||
: "";
|
||||
return `<div class="org-card">${icon}<div class="name">${node.name}</div><div class="role">${node.role}</div></div>`;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "nebula",
|
||||
name: "Nebula",
|
||||
css: `
|
||||
body { background: #0f0c29; }
|
||||
.org-tree {
|
||||
background: linear-gradient(135deg, #0f0c29 0%, #302b63 50%, #24243e 100%);
|
||||
border-radius: 6px;
|
||||
padding: 36px 28px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
--line-color: rgba(255,255,255,0.25);
|
||||
--line-w: 1.5px;
|
||||
}
|
||||
.org-tree::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(ellipse 600px 400px at 25% 30%, rgba(99,102,241,0.12) 0%, transparent 70%),
|
||||
radial-gradient(ellipse 500px 350px at 75% 65%, rgba(168,85,247,0.08) 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.org-node { z-index: 1; }
|
||||
.org-card {
|
||||
background: rgba(255,255,255,0.07);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid rgba(255,255,255,0.12);
|
||||
border-radius: 6px;
|
||||
padding: 16px 22px;
|
||||
min-width: 130px;
|
||||
}
|
||||
.org-card .name {
|
||||
font-size: 14px; font-weight: 600; color: #fff; margin-bottom: 3px;
|
||||
}
|
||||
.org-card .role {
|
||||
font-size: 10px; color: rgba(255,255,255,0.45);
|
||||
text-transform: uppercase; letter-spacing: 0.06em; font-weight: 500;
|
||||
}
|
||||
.watermark { color: rgba(255,255,255,0.2); }
|
||||
.watermark svg { stroke: rgba(255,255,255,0.2); }
|
||||
`,
|
||||
renderCard: (node, isOg) => {
|
||||
const icon =
|
||||
node.icon && !isOg
|
||||
? `<div class="icon-wrap">${node.icon}</div>`
|
||||
: "";
|
||||
return `<div class="org-card">${icon}<div class="name">${node.name}</div><div class="role">${node.role}</div></div>`;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "circuit",
|
||||
name: "Circuit",
|
||||
css: `
|
||||
body { background: #0c0c0e; }
|
||||
.org-tree {
|
||||
background: #0c0c0e;
|
||||
border-radius: 6px;
|
||||
padding: 36px 28px;
|
||||
--line-color: rgba(99,102,241,0.35);
|
||||
--line-w: 1.5px;
|
||||
}
|
||||
.org-card {
|
||||
background: linear-gradient(135deg, rgba(99,102,241,0.06), rgba(99,102,241,0.01));
|
||||
border: 1px solid rgba(99,102,241,0.18);
|
||||
border-radius: 5px;
|
||||
padding: 14px 20px;
|
||||
min-width: 120px;
|
||||
}
|
||||
.org-card.chief {
|
||||
border-color: rgba(168,85,247,0.35);
|
||||
background: linear-gradient(135deg, rgba(168,85,247,0.08), rgba(168,85,247,0.01));
|
||||
}
|
||||
.org-card .name {
|
||||
font-size: 13px; font-weight: 600; color: #e4e4e7;
|
||||
margin-bottom: 3px; letter-spacing: -0.005em;
|
||||
}
|
||||
.org-card .role {
|
||||
font-size: 10px; color: #6366f1;
|
||||
text-transform: uppercase; letter-spacing: 0.07em; font-weight: 500;
|
||||
}
|
||||
.watermark { color: rgba(99,102,241,0.3); }
|
||||
.watermark svg { stroke: rgba(99,102,241,0.3); }
|
||||
`,
|
||||
renderCard: (node, isOg) => {
|
||||
const cls = node.tag === "ceo" ? " chief" : "";
|
||||
const icon =
|
||||
node.icon && !isOg
|
||||
? `<div class="icon-wrap">${node.icon}</div>`
|
||||
: "";
|
||||
return `<div class="org-card${cls}">${icon}<div class="name">${node.name}</div><div class="role">${node.role}</div></div>`;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "warm",
|
||||
name: "Warmth",
|
||||
css: `
|
||||
body { background: #fafaf9; }
|
||||
.org-tree {
|
||||
background: #fafaf9;
|
||||
border-radius: 6px;
|
||||
padding: 36px 28px;
|
||||
--line-color: #d6d3d1;
|
||||
--line-w: 2px;
|
||||
}
|
||||
.org-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e7e5e4;
|
||||
border-radius: 6px;
|
||||
padding: 16px 22px;
|
||||
min-width: 130px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.05), 0 1px 2px rgba(0,0,0,0.03);
|
||||
}
|
||||
.org-card .avatar {
|
||||
width: 34px; height: 34px; border-radius: 50%;
|
||||
margin: 0 auto 10px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 15px; line-height: 1;
|
||||
}
|
||||
.org-card .avatar.r-ceo { background: #fef3c7; }
|
||||
.org-card .avatar.r-cto { background: #dbeafe; }
|
||||
.org-card .avatar.r-cmo { background: #dcfce7; }
|
||||
.org-card .avatar.r-eng { background: #f3e8ff; }
|
||||
.org-card .avatar.r-qa { background: #ffe4e6; }
|
||||
.org-card .avatar.r-des { background: #fce7f3; }
|
||||
.org-card .avatar.r-fin { background: #fef3c7; }
|
||||
.org-card .avatar.r-ops { background: #e0f2fe; }
|
||||
.org-card .name {
|
||||
font-size: 14px; font-weight: 600; color: #1c1917; margin-bottom: 2px;
|
||||
}
|
||||
.org-card .role {
|
||||
font-size: 11px; color: #78716c; font-weight: 500;
|
||||
}
|
||||
.watermark { color: rgba(0,0,0,0.25); }
|
||||
.watermark svg { stroke: rgba(0,0,0,0.25); }
|
||||
`,
|
||||
renderCard: (node, isOg) => {
|
||||
const icons: Record<string, string> = {
|
||||
ceo: "👑",
|
||||
cto: "💻",
|
||||
cmo: "🌐",
|
||||
eng: "⌨️",
|
||||
qa: "🔬",
|
||||
des: "🪄",
|
||||
fin: "📊",
|
||||
ops: "⚙️",
|
||||
};
|
||||
const ic = node.icon || icons[node.tag] || "";
|
||||
const sizeStyle = isOg
|
||||
? "width:24px;height:24px;font-size:11px;margin-bottom:6px;"
|
||||
: "";
|
||||
const avatar = `<div class="avatar r-${node.tag}" style="${sizeStyle}">${ic}</div>`;
|
||||
return `<div class="org-card">${avatar}<div class="name">${node.name}</div><div class="role">${node.role}</div></div>`;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "schema",
|
||||
name: "Schematic",
|
||||
css: `
|
||||
body { background: #0d1117; }
|
||||
.org-tree {
|
||||
font-family: 'JetBrains Mono', 'SF Mono', monospace;
|
||||
background: #0d1117;
|
||||
background-image:
|
||||
linear-gradient(rgba(48,54,61,0.25) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(48,54,61,0.25) 1px, transparent 1px);
|
||||
background-size: 20px 20px;
|
||||
border-radius: 4px;
|
||||
padding: 36px 28px;
|
||||
border: 1px solid #21262d;
|
||||
--line-color: #30363d;
|
||||
--line-w: 1.5px;
|
||||
}
|
||||
.org-card {
|
||||
background: rgba(13,17,23,0.92);
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 4px;
|
||||
padding: 12px 16px;
|
||||
min-width: 120px;
|
||||
position: relative;
|
||||
}
|
||||
.org-card::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0;
|
||||
height: 2px;
|
||||
border-radius: 4px 4px 0 0;
|
||||
}
|
||||
.org-card.t-ceo::after { background: #f0883e; }
|
||||
.org-card.t-cto::after { background: #58a6ff; }
|
||||
.org-card.t-cmo::after { background: #3fb950; }
|
||||
.org-card.t-eng::after { background: #bc8cff; }
|
||||
.org-card.t-qa::after { background: #f778ba; }
|
||||
.org-card.t-des::after { background: #79c0ff; }
|
||||
.org-card.t-fin::after { background: #f0883e; }
|
||||
.org-card.t-ops::after { background: #58a6ff; }
|
||||
.org-card .name {
|
||||
font-size: 12px; font-weight: 600; color: #c9d1d9; margin-bottom: 2px;
|
||||
}
|
||||
.org-card .role {
|
||||
font-size: 10px; color: #8b949e; letter-spacing: 0.02em;
|
||||
}
|
||||
.watermark { color: rgba(139,148,158,0.3); font-family: 'JetBrains Mono', monospace; }
|
||||
.watermark svg { stroke: rgba(139,148,158,0.3); }
|
||||
`,
|
||||
renderCard: (node, isOg) => {
|
||||
const schemaRoles: Record<string, string> = {
|
||||
ceo: "chief_executive",
|
||||
cto: "chief_technology",
|
||||
cmo: "chief_marketing",
|
||||
eng: "engineer",
|
||||
qa: "quality",
|
||||
des: "designer",
|
||||
fin: "finance",
|
||||
ops: "operations",
|
||||
};
|
||||
const icon =
|
||||
node.icon && !isOg
|
||||
? `<div class="icon-wrap">${node.icon}</div>`
|
||||
: "";
|
||||
const roleText =
|
||||
isOg
|
||||
? node.role
|
||||
: node.children
|
||||
? node.role
|
||||
: schemaRoles[node.tag] || node.role;
|
||||
return `<div class="org-card t-${node.tag}">${icon}<div class="name">${node.name}</div><div class="role">${roleText}</div></div>`;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// ── HTML rendering ─────────────────────────────────────────────
|
||||
|
||||
function renderNode(
|
||||
node: OrgNode,
|
||||
style: StyleDef,
|
||||
isOg: boolean,
|
||||
): string {
|
||||
const cardHtml = style.renderCard(node, isOg);
|
||||
if (!node.children || node.children.length === 0) {
|
||||
return `<div class="org-node">${cardHtml}</div>`;
|
||||
}
|
||||
const childrenHtml = node.children
|
||||
.map((c) => renderNode(c, style, isOg))
|
||||
.join("");
|
||||
return `<div class="org-node">${cardHtml}<div class="org-children">${childrenHtml}</div></div>`;
|
||||
}
|
||||
|
||||
function renderTree(
|
||||
orgData: OrgNode,
|
||||
style: StyleDef,
|
||||
isOg: boolean,
|
||||
): string {
|
||||
const compact = isOg ? " og-compact" : "";
|
||||
return `<div class="org-tree${compact}">${renderNode(orgData, style, isOg)}</div>`;
|
||||
}
|
||||
|
||||
const PAPERCLIP_WATERMARK = `<div class="watermark">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="m18 4-8.414 8.586a2 2 0 0 0 2.829 2.829l8.414-8.586a4 4 0 1 0-5.657-5.657l-8.379 8.551a6 6 0 1 0 8.485 8.485l8.379-8.551"/>
|
||||
</svg>
|
||||
Paperclip
|
||||
</div>`;
|
||||
|
||||
function buildHtml(
|
||||
style: StyleDef,
|
||||
orgData: OrgNode,
|
||||
isOg: boolean,
|
||||
): string {
|
||||
const tree = renderTree(orgData, style, isOg);
|
||||
return `<!DOCTYPE html>
|
||||
<html><head>
|
||||
<meta charset="UTF-8">
|
||||
<style>${COMMON_CSS}${style.css}</style>
|
||||
</head><body>
|
||||
<div style="position:relative;display:inline-block;">
|
||||
${tree}
|
||||
${PAPERCLIP_WATERMARK}
|
||||
</div>
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
// ── Main ───────────────────────────────────────────────────────
|
||||
|
||||
async function main() {
|
||||
const outDir = path.resolve("tmp/org-chart-images");
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const context = await browser.newContext({
|
||||
deviceScaleFactor: 2, // retina quality
|
||||
});
|
||||
|
||||
const sizes = ["sm", "med", "lg"] as const;
|
||||
const results: string[] = [];
|
||||
|
||||
for (const style of STYLES) {
|
||||
// README sizes
|
||||
for (const size of sizes) {
|
||||
const page = await context.newPage();
|
||||
const html = buildHtml(style, ORGS[size], false);
|
||||
await page.setContent(html, { waitUntil: "networkidle" });
|
||||
|
||||
// Wait for fonts to load
|
||||
await page.waitForFunction(() => document.fonts.ready);
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Fit to content
|
||||
const box = await page.evaluate(() => {
|
||||
const el = document.querySelector(".org-tree")!;
|
||||
const rect = el.getBoundingClientRect();
|
||||
return {
|
||||
width: Math.ceil(rect.width) + 32,
|
||||
height: Math.ceil(rect.height) + 32,
|
||||
};
|
||||
});
|
||||
|
||||
await page.setViewportSize({
|
||||
width: Math.max(box.width, 400),
|
||||
height: Math.max(box.height, 300),
|
||||
});
|
||||
|
||||
const filename = `${style.key}-${size}.png`;
|
||||
await page.screenshot({
|
||||
path: path.join(outDir, filename),
|
||||
clip: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: Math.max(box.width, 400),
|
||||
height: Math.max(box.height, 300),
|
||||
},
|
||||
});
|
||||
await page.close();
|
||||
results.push(filename);
|
||||
console.log(` ✓ ${filename}`);
|
||||
}
|
||||
|
||||
// OG card (1200×630)
|
||||
{
|
||||
const page = await context.newPage();
|
||||
await page.setViewportSize({ width: 1200, height: 630 });
|
||||
const html = buildHtml(style, OG_ORG, true);
|
||||
// For OG, center the tree in a fixed viewport
|
||||
const ogHtml = html.replace(
|
||||
"<body>",
|
||||
`<body style="width:1200px;height:630px;display:flex;align-items:center;justify-content:center;">`,
|
||||
);
|
||||
await page.setContent(ogHtml, { waitUntil: "networkidle" });
|
||||
await page.waitForFunction(() => document.fonts.ready);
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const filename = `${style.key}-og.png`;
|
||||
await page.screenshot({
|
||||
path: path.join(outDir, filename),
|
||||
clip: { x: 0, y: 0, width: 1200, height: 630 },
|
||||
});
|
||||
await page.close();
|
||||
results.push(filename);
|
||||
console.log(` ✓ ${filename}`);
|
||||
}
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
|
||||
// Build an HTML comparison page
|
||||
let compHtml = `<!DOCTYPE html>
|
||||
<html><head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Org Chart Style Comparison</title>
|
||||
<style>
|
||||
body { font-family: 'Inter', system-ui, sans-serif; background: #050505; color: #eee; padding: 40px; }
|
||||
h1 { font-size: 28px; font-weight: 700; margin-bottom: 8px; letter-spacing: -0.03em; }
|
||||
p.sub { color: #888; font-size: 14px; margin-bottom: 40px; }
|
||||
.style-section { margin-bottom: 60px; }
|
||||
.style-section h2 { font-size: 20px; font-weight: 600; margin-bottom: 16px; letter-spacing: -0.02em; }
|
||||
.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 16px; }
|
||||
.grid img { width: 100%; border-radius: 8px; border: 1px solid #222; }
|
||||
.og-row { max-width: 600px; }
|
||||
.og-row img { width: 100%; border-radius: 8px; border: 1px solid #222; }
|
||||
.label { font-size: 11px; color: #666; text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 6px; font-weight: 500; }
|
||||
</style>
|
||||
</head><body>
|
||||
<h1>Org Chart Export — Style Comparison</h1>
|
||||
<p class="sub">5 styles × 3 org sizes + OG cards. All rendered via Playwright (browser-native emojis, full CSS).</p>
|
||||
`;
|
||||
|
||||
for (const style of STYLES) {
|
||||
compHtml += `<div class="style-section">
|
||||
<h2>${style.name}</h2>
|
||||
<div class="label">README — Small / Medium / Large</div>
|
||||
<div class="grid">
|
||||
<img src="${style.key}-sm.png" />
|
||||
<img src="${style.key}-med.png" />
|
||||
<img src="${style.key}-lg.png" />
|
||||
</div>
|
||||
<div class="label">OG Card (1200×630)</div>
|
||||
<div class="og-row"><img src="${style.key}-og.png" /></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
compHtml += `</body></html>`;
|
||||
fs.writeFileSync(path.join(outDir, "comparison.html"), compHtml);
|
||||
console.log(`\n✓ All done! ${results.length} images generated.`);
|
||||
console.log(` Open: tmp/org-chart-images/comparison.html`);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* Standalone org chart comparison generator — pure SVG (no Playwright).
|
||||
*
|
||||
* Generates SVG files for all 5 styles × 3 org sizes, plus a comparison HTML page.
|
||||
* Uses the server-side SVG renderer directly — same code that powers the routes.
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx scripts/generate-org-chart-satori-comparison.ts
|
||||
*
|
||||
* Output: tmp/org-chart-svg-comparison/
|
||||
*/
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import {
|
||||
renderOrgChartSvg,
|
||||
renderOrgChartPng,
|
||||
type OrgNode,
|
||||
type OrgChartStyle,
|
||||
ORG_CHART_STYLES,
|
||||
} from "../server/src/routes/org-chart-svg.js";
|
||||
|
||||
// ── Sample org data ──────────────────────────────────────────────
|
||||
|
||||
const ORGS: Record<string, OrgNode> = {
|
||||
sm: {
|
||||
id: "ceo",
|
||||
name: "CEO",
|
||||
role: "Chief Executive",
|
||||
status: "active",
|
||||
reports: [
|
||||
{ id: "eng1", name: "Engineer", role: "Engineering", status: "active", reports: [] },
|
||||
{ id: "des1", name: "Designer", role: "Design", status: "active", reports: [] },
|
||||
],
|
||||
},
|
||||
med: {
|
||||
id: "ceo",
|
||||
name: "CEO",
|
||||
role: "Chief Executive",
|
||||
status: "active",
|
||||
reports: [
|
||||
{
|
||||
id: "cto",
|
||||
name: "CTO",
|
||||
role: "Technology",
|
||||
status: "active",
|
||||
reports: [
|
||||
{ id: "eng1", name: "ClaudeCoder", role: "Engineering", status: "active", reports: [] },
|
||||
{ id: "eng2", name: "CodexCoder", role: "Engineering", status: "active", reports: [] },
|
||||
{ id: "eng3", name: "SparkCoder", role: "Engineering", status: "active", reports: [] },
|
||||
{ id: "eng4", name: "CursorCoder", role: "Engineering", status: "active", reports: [] },
|
||||
{ id: "qa1", name: "QA", role: "Quality", status: "active", reports: [] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "cmo",
|
||||
name: "CMO",
|
||||
role: "Marketing",
|
||||
status: "active",
|
||||
reports: [
|
||||
{ id: "des1", name: "Designer", role: "Design", status: "active", reports: [] },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
lg: {
|
||||
id: "ceo",
|
||||
name: "CEO",
|
||||
role: "Chief Executive",
|
||||
status: "active",
|
||||
reports: [
|
||||
{
|
||||
id: "cto",
|
||||
name: "CTO",
|
||||
role: "Technology",
|
||||
status: "active",
|
||||
reports: [
|
||||
{ id: "eng1", name: "Eng 1", role: "Engineering", status: "active", reports: [] },
|
||||
{ id: "eng2", name: "Eng 2", role: "Engineering", status: "active", reports: [] },
|
||||
{ id: "eng3", name: "Eng 3", role: "Engineering", status: "active", reports: [] },
|
||||
{ id: "qa1", name: "QA", role: "Quality", status: "active", reports: [] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "cmo",
|
||||
name: "CMO",
|
||||
role: "Marketing",
|
||||
status: "active",
|
||||
reports: [
|
||||
{ id: "des1", name: "Designer", role: "Design", status: "active", reports: [] },
|
||||
{ id: "wrt1", name: "Content", role: "Engineering", status: "active", reports: [] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "cfo",
|
||||
name: "CFO",
|
||||
role: "Finance",
|
||||
status: "active",
|
||||
reports: [
|
||||
{ id: "fin1", name: "Analyst", role: "Finance", status: "active", reports: [] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "coo",
|
||||
name: "COO",
|
||||
role: "Operations",
|
||||
status: "active",
|
||||
reports: [
|
||||
{ id: "ops1", name: "Ops 1", role: "Operations", status: "active", reports: [] },
|
||||
{ id: "ops2", name: "Ops 2", role: "Operations", status: "active", reports: [] },
|
||||
{ id: "devops1", name: "DevOps", role: "Operations", status: "active", reports: [] },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const STYLE_META: Record<OrgChartStyle, { name: string; vibe: string; bestFor: string }> = {
|
||||
monochrome: { name: "Monochrome", vibe: "Vercel — zero color noise, dark", bestFor: "GitHub READMEs, developer docs" },
|
||||
nebula: { name: "Nebula", vibe: "Glassmorphism — cosmic gradient", bestFor: "Hero sections, marketing" },
|
||||
circuit: { name: "Circuit", vibe: "Linear/Raycast — indigo traces", bestFor: "Product pages, dev tools" },
|
||||
warmth: { name: "Warmth", vibe: "Airbnb — light, colored avatars", bestFor: "Light-mode READMEs, presentations" },
|
||||
schematic: { name: "Schematic", vibe: "Blueprint — grid bg, monospace", bestFor: "Technical docs, infra diagrams" },
|
||||
};
|
||||
|
||||
// ── Main ─────────────────────────────────────────────────────────
|
||||
|
||||
async function main() {
|
||||
const outDir = path.resolve("tmp/org-chart-svg-comparison");
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
|
||||
const sizes = ["sm", "med", "lg"] as const;
|
||||
const results: string[] = [];
|
||||
|
||||
for (const style of ORG_CHART_STYLES) {
|
||||
for (const size of sizes) {
|
||||
const svg = renderOrgChartSvg([ORGS[size]], style);
|
||||
const svgFile = `${style}-${size}.svg`;
|
||||
fs.writeFileSync(path.join(outDir, svgFile), svg);
|
||||
results.push(svgFile);
|
||||
console.log(` ✓ ${svgFile}`);
|
||||
|
||||
// Also generate PNG
|
||||
try {
|
||||
const png = await renderOrgChartPng([ORGS[size]], style);
|
||||
const pngFile = `${style}-${size}.png`;
|
||||
fs.writeFileSync(path.join(outDir, pngFile), png);
|
||||
results.push(pngFile);
|
||||
console.log(` ✓ ${pngFile}`);
|
||||
} catch (e) {
|
||||
console.log(` ⚠ PNG failed for ${style}-${size}: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build comparison HTML
|
||||
let html = `<!DOCTYPE html>
|
||||
<html><head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Org Chart Style Comparison — Pure SVG (No Playwright)</title>
|
||||
<style>
|
||||
body { font-family: 'Inter', system-ui, sans-serif; background: #050505; color: #eee; padding: 40px; }
|
||||
h1 { font-size: 28px; font-weight: 700; margin-bottom: 8px; letter-spacing: -0.03em; }
|
||||
p.sub { color: #888; font-size: 14px; margin-bottom: 16px; }
|
||||
.badge { display: inline-block; background: #1a1a2e; border: 1px solid #333; border-radius: 4px; padding: 4px 10px; font-size: 12px; color: #6366f1; margin-bottom: 32px; }
|
||||
.style-section { margin-bottom: 60px; }
|
||||
.style-section h2 { font-size: 20px; font-weight: 600; margin-bottom: 4px; letter-spacing: -0.02em; }
|
||||
.style-meta { font-size: 13px; color: #666; margin-bottom: 16px; }
|
||||
.style-meta em { color: #888; font-style: normal; }
|
||||
.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 16px; }
|
||||
.grid img, .grid object { width: 100%; border-radius: 8px; border: 1px solid #222; background: #111; }
|
||||
.label { font-size: 11px; color: #666; text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 6px; font-weight: 500; }
|
||||
.size-label { font-size: 10px; color: #555; text-align: center; margin-top: 4px; }
|
||||
.note { background: #111; border: 1px solid #222; border-radius: 6px; padding: 16px 20px; margin-top: 40px; font-size: 13px; color: #999; line-height: 1.6; }
|
||||
.note h3 { font-size: 14px; color: #ccc; margin-bottom: 8px; }
|
||||
.note code { background: #1a1a1a; padding: 2px 6px; border-radius: 3px; font-size: 12px; color: #6366f1; }
|
||||
</style>
|
||||
</head><body>
|
||||
<h1>Org Chart Export — Style Comparison</h1>
|
||||
<p class="sub">5 styles × 3 org sizes. Pure SVG — no Playwright, no Satori, no browser needed.</p>
|
||||
<div class="badge">Server-side compatible — works on any route</div>
|
||||
`;
|
||||
|
||||
for (const style of ORG_CHART_STYLES) {
|
||||
const meta = STYLE_META[style];
|
||||
html += `<div class="style-section">
|
||||
<h2>${meta.name}</h2>
|
||||
<div class="style-meta"><em>${meta.vibe}</em> — Best for: ${meta.bestFor}</div>
|
||||
<div class="label">Small / Medium / Large</div>
|
||||
<div class="grid">
|
||||
<div><img src="${style}-sm.png" onerror="this.outerHTML='<object data=\\'${style}-sm.svg\\' type=\\'image/svg+xml\\' style=\\'width:100%;border-radius:8px;border:1px solid #222\\'></object>'" /><div class="size-label">3 agents</div></div>
|
||||
<div><img src="${style}-med.png" onerror="this.outerHTML='<object data=\\'${style}-med.svg\\' type=\\'image/svg+xml\\' style=\\'width:100%;border-radius:8px;border:1px solid #222\\'></object>'" /><div class="size-label">8 agents</div></div>
|
||||
<div><img src="${style}-lg.png" onerror="this.outerHTML='<object data=\\'${style}-lg.svg\\' type=\\'image/svg+xml\\' style=\\'width:100%;border-radius:8px;border:1px solid #222\\'></object>'" /><div class="size-label">14 agents</div></div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
html += `
|
||||
<div class="note">
|
||||
<h3>Why Pure SVG instead of Satori?</h3>
|
||||
<p>
|
||||
<strong>Satori</strong> converts JSX → SVG using Yoga (flexbox). It's great for OG cards but has limitations for org charts:
|
||||
no <code>::before/::after</code> pseudo-elements, no CSS grid, limited gradient support,
|
||||
and connector lines between nodes would need post-processing.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Pure SVG rendering</strong> (what we're using here) gives us full control over layout, connectors,
|
||||
gradients, filters, and patterns — with zero runtime dependencies beyond <code>sharp</code> for PNG.
|
||||
It runs on any Node.js route, generates in <10ms, and produces identical output every time.
|
||||
</p>
|
||||
<p>
|
||||
Routes: <code>GET /api/companies/:id/org.svg?style=monochrome</code> and <code>GET /api/companies/:id/org.png?style=circuit</code>
|
||||
</p>
|
||||
</div>
|
||||
</body></html>`;
|
||||
|
||||
fs.writeFileSync(path.join(outDir, "comparison.html"), html);
|
||||
console.log(`\n✓ All done! ${results.length} files generated.`);
|
||||
console.log(` Open: tmp/org-chart-svg-comparison/comparison.html`);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = resolve(__dirname, "..");
|
||||
const packageDir = process.cwd();
|
||||
const packageJsonPath = join(packageDir, "package.json");
|
||||
const sdkPackageJsonPath = join(repoRoot, "packages", "plugins", "sdk", "package.json");
|
||||
|
||||
if (!existsSync(packageJsonPath)) {
|
||||
throw new Error(`No package.json found in plugin directory: ${packageDir}`);
|
||||
}
|
||||
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
|
||||
const sdkPackageJson = JSON.parse(readFileSync(sdkPackageJsonPath, "utf8"));
|
||||
const publishConfig = packageJson.publishConfig ?? {};
|
||||
const dependencies = {
|
||||
...(packageJson.dependencies ?? {}),
|
||||
"@paperclipai/plugin-sdk": sdkPackageJson.version,
|
||||
};
|
||||
|
||||
const publishPackageJson = {
|
||||
name: packageJson.name,
|
||||
version: packageJson.version,
|
||||
description: packageJson.description,
|
||||
license: packageJson.license,
|
||||
homepage: packageJson.homepage,
|
||||
bugs: packageJson.bugs,
|
||||
repository: packageJson.repository,
|
||||
type: packageJson.type,
|
||||
exports: publishConfig.exports ?? packageJson.exports,
|
||||
main: publishConfig.main,
|
||||
types: publishConfig.types,
|
||||
publishConfig,
|
||||
files: packageJson.files,
|
||||
paperclipPlugin: packageJson.paperclipPlugin,
|
||||
keywords: packageJson.keywords,
|
||||
dependencies,
|
||||
};
|
||||
|
||||
writeFileSync(packageJsonPath, `${JSON.stringify(publishPackageJson, null, 2)}\n`);
|
||||
|
||||
console.log(` ✓ Generated publishable plugin package.json for ${packageJson.name}`);
|
||||
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = resolve(__dirname, "..");
|
||||
const uiDir = join(repoRoot, "ui");
|
||||
const packageJsonPath = join(uiDir, "package.json");
|
||||
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
|
||||
|
||||
const publishPackageJson = {
|
||||
name: packageJson.name,
|
||||
version: packageJson.version,
|
||||
description: packageJson.description,
|
||||
license: packageJson.license,
|
||||
homepage: packageJson.homepage,
|
||||
bugs: packageJson.bugs,
|
||||
repository: packageJson.repository,
|
||||
type: packageJson.type,
|
||||
files: ["dist"],
|
||||
publishConfig: {
|
||||
access: "public",
|
||||
},
|
||||
};
|
||||
|
||||
writeFileSync(packageJsonPath, `${JSON.stringify(publishPackageJson, null, 2)}\n`);
|
||||
|
||||
console.log(" ✓ Generated publishable UI package.json");
|
||||
Executable
+65
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Kill all agent headless browser processes.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/kill-agent-browsers.sh # kill all
|
||||
# scripts/kill-agent-browsers.sh --dry # preview what would be killed
|
||||
#
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
DRY_RUN=false
|
||||
if [[ "${1:-}" == "--dry" || "${1:-}" == "--dry-run" || "${1:-}" == "-n" ]]; then
|
||||
DRY_RUN=true
|
||||
fi
|
||||
|
||||
pids=()
|
||||
lines=()
|
||||
|
||||
while IFS= read -r line; do
|
||||
[[ -z "$line" ]] && continue
|
||||
pid=$(echo "$line" | awk '{print $2}')
|
||||
pids+=("$pid")
|
||||
lines+=("$line")
|
||||
done < <(ps aux | grep -E 'Google Chrome for Testing|chrome-headless-shell' | grep -v grep || true)
|
||||
|
||||
if [[ ${#pids[@]} -eq 0 ]]; then
|
||||
echo "No agent headless browser processes found."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Found ${#pids[@]} agent headless browser process(es):"
|
||||
echo ""
|
||||
|
||||
for i in "${!pids[@]}"; do
|
||||
line="${lines[$i]}"
|
||||
pid=$(echo "$line" | awk '{print $2}')
|
||||
start=$(echo "$line" | awk '{print $9}')
|
||||
cmd=$(echo "$line" | awk '{for(i=11;i<=NF;i++) printf "%s ", $i; print ""}')
|
||||
cmd=$(echo "$cmd" | sed "s|$HOME/||g")
|
||||
printf " PID %-7s started %-10s %s\n" "$pid" "$start" "$cmd"
|
||||
done
|
||||
|
||||
echo ""
|
||||
|
||||
if [[ "$DRY_RUN" == true ]]; then
|
||||
echo "Dry run — re-run without --dry to kill these processes."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Sending SIGTERM..."
|
||||
for pid in "${pids[@]}"; do
|
||||
kill -TERM "$pid" 2>/dev/null && echo " signaled $pid" || echo " $pid already gone"
|
||||
done
|
||||
|
||||
sleep 2
|
||||
|
||||
for pid in "${pids[@]}"; do
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
echo " $pid still alive, sending SIGKILL..."
|
||||
kill -KILL "$pid" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Done."
|
||||
Executable
+248
@@ -0,0 +1,248 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Kill all local Paperclip dev server processes (across all worktrees).
|
||||
#
|
||||
# Usage:
|
||||
# scripts/kill-dev.sh # kill all paperclip dev processes
|
||||
# scripts/kill-dev.sh --dry # preview what would be killed
|
||||
#
|
||||
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
|
||||
DRY_RUN=false
|
||||
if [[ "${1:-}" == "--dry" || "${1:-}" == "--dry-run" || "${1:-}" == "-n" ]]; then
|
||||
DRY_RUN=true
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
REPO_PARENT="$(dirname "$REPO_ROOT")"
|
||||
|
||||
node_pids=()
|
||||
node_lines=()
|
||||
pg_pids=()
|
||||
pg_pidfiles=()
|
||||
pg_data_dirs=()
|
||||
browser_pids=()
|
||||
browser_lines=()
|
||||
|
||||
is_pid_running() {
|
||||
local pid="$1"
|
||||
kill -0 "$pid" 2>/dev/null
|
||||
}
|
||||
|
||||
read_pidfile_pid() {
|
||||
local pidfile="$1"
|
||||
local first_line
|
||||
first_line="$(head -n 1 "$pidfile" 2>/dev/null | tr -d '[:space:]' || true)"
|
||||
if [[ "$first_line" =~ ^[0-9]+$ ]] && (( first_line > 0 )); then
|
||||
printf '%s\n' "$first_line"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
command_for_pid() {
|
||||
local pid="$1"
|
||||
ps -o command= -p "$pid" 2>/dev/null || true
|
||||
}
|
||||
|
||||
append_postgres_from_pidfile() {
|
||||
local pidfile="$1"
|
||||
local pid cmd data_dir
|
||||
pid="$(read_pidfile_pid "$pidfile" || true)"
|
||||
[[ -n "$pid" ]] || return 0
|
||||
is_pid_running "$pid" || return 0
|
||||
cmd="$(command_for_pid "$pid")"
|
||||
[[ "$cmd" == *postgres* ]] || return 0
|
||||
|
||||
for existing_pid in "${pg_pids[@]:-}"; do
|
||||
[[ "$existing_pid" == "$pid" ]] && return 0
|
||||
done
|
||||
|
||||
data_dir="$(dirname "$pidfile")"
|
||||
pg_pids+=("$pid")
|
||||
pg_pidfiles+=("$pidfile")
|
||||
pg_data_dirs+=("$data_dir")
|
||||
}
|
||||
|
||||
wait_for_pid_exit() {
|
||||
local pid="$1"
|
||||
local timeout_sec="$2"
|
||||
local waited=0
|
||||
while is_pid_running "$pid"; do
|
||||
if (( waited >= timeout_sec * 10 )); then
|
||||
return 1
|
||||
fi
|
||||
sleep 0.1
|
||||
((waited += 1))
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
while IFS= read -r line; do
|
||||
[[ -z "$line" ]] && continue
|
||||
[[ "$line" == *postgres* ]] && continue
|
||||
pid=$(echo "$line" | awk '{print $2}')
|
||||
node_pids+=("$pid")
|
||||
node_lines+=("$line")
|
||||
done < <(ps aux | grep -E '/paperclip(-[^/]+)?/' | grep node | grep -v grep || true)
|
||||
|
||||
# --- Agent browser processes (headless Chrome from ~/.agent-browser) ---
|
||||
while IFS= read -r line; do
|
||||
[[ -z "$line" ]] && continue
|
||||
pid=$(echo "$line" | awk '{print $2}')
|
||||
browser_pids+=("$pid")
|
||||
browser_lines+=("$line")
|
||||
done < <(ps aux | grep -E 'agent-browser/browsers/chrome-.*/Google Chrome for Testing' | grep -v grep || true)
|
||||
|
||||
candidate_pidfiles=()
|
||||
candidate_pidfiles+=(
|
||||
"$HOME"/.paperclip/instances/*/db/postmaster.pid
|
||||
"$REPO_ROOT"/.paperclip/instances/*/db/postmaster.pid
|
||||
"$REPO_ROOT"/.paperclip/runtime-services/instances/*/db/postmaster.pid
|
||||
)
|
||||
|
||||
for sibling_root in "$REPO_PARENT"/paperclip*; do
|
||||
[[ -d "$sibling_root" ]] || continue
|
||||
candidate_pidfiles+=(
|
||||
"$sibling_root"/.paperclip/instances/*/db/postmaster.pid
|
||||
"$sibling_root"/.paperclip/runtime-services/instances/*/db/postmaster.pid
|
||||
)
|
||||
done
|
||||
|
||||
for pidfile in "${candidate_pidfiles[@]:-}"; do
|
||||
[[ -f "$pidfile" ]] || continue
|
||||
append_postgres_from_pidfile "$pidfile"
|
||||
done
|
||||
|
||||
if [[ ${#node_pids[@]} -eq 0 && ${#pg_pids[@]} -eq 0 && ${#browser_pids[@]} -eq 0 ]]; then
|
||||
echo "No Paperclip dev processes found."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ ${#node_pids[@]} -gt 0 ]]; then
|
||||
echo "Found ${#node_pids[@]} Paperclip dev node process(es):"
|
||||
echo ""
|
||||
|
||||
for i in "${!node_pids[@]:-}"; do
|
||||
line="${node_lines[$i]}"
|
||||
pid=$(echo "$line" | awk '{print $2}')
|
||||
start=$(echo "$line" | awk '{print $9}')
|
||||
cmd=$(echo "$line" | awk '{for(i=11;i<=NF;i++) printf "%s ", $i; print ""}')
|
||||
cmd=$(echo "$cmd" | sed "s|$HOME/||g")
|
||||
printf " PID %-7s started %-10s %s\n" "$pid" "$start" "$cmd"
|
||||
done
|
||||
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if [[ ${#pg_pids[@]} -gt 0 ]]; then
|
||||
echo "Found ${#pg_pids[@]} embedded PostgreSQL master process(es):"
|
||||
echo ""
|
||||
|
||||
for i in "${!pg_pids[@]:-}"; do
|
||||
pid="${pg_pids[$i]}"
|
||||
data_dir="${pg_data_dirs[$i]}"
|
||||
pidfile="${pg_pidfiles[$i]}"
|
||||
short_data_dir="${data_dir/#$HOME\//}"
|
||||
short_pidfile="${pidfile/#$HOME\//}"
|
||||
printf " PID %-7s data %-55s pidfile %s\n" "$pid" "$short_data_dir" "$short_pidfile"
|
||||
done
|
||||
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if [[ ${#browser_pids[@]} -gt 0 ]]; then
|
||||
echo "Found ${#browser_pids[@]} agent browser process(es):"
|
||||
echo ""
|
||||
|
||||
for i in "${!browser_pids[@]:-}"; do
|
||||
line="${browser_lines[$i]}"
|
||||
pid=$(echo "$line" | awk '{print $2}')
|
||||
start=$(echo "$line" | awk '{print $9}')
|
||||
cmd=$(echo "$line" | awk '{for(i=11;i<=NF;i++) printf "%s ", $i; print ""}')
|
||||
cmd=$(echo "$cmd" | sed "s|$HOME/||g")
|
||||
printf " PID %-7s started %-10s %s\n" "$pid" "$start" "$cmd"
|
||||
done
|
||||
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if [[ "$DRY_RUN" == true ]]; then
|
||||
echo "Dry run — re-run without --dry to kill these processes."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ ${#node_pids[@]} -gt 0 ]]; then
|
||||
echo "Sending SIGTERM to Paperclip node processes..."
|
||||
for pid in "${node_pids[@]}"; do
|
||||
kill -TERM "$pid" 2>/dev/null && echo " signaled $pid" || echo " $pid already gone"
|
||||
done
|
||||
echo "Waiting briefly for node processes to exit..."
|
||||
sleep 2
|
||||
fi
|
||||
|
||||
if [[ ${#browser_pids[@]} -gt 0 ]]; then
|
||||
echo "Sending SIGTERM to agent browser processes..."
|
||||
for pid in "${browser_pids[@]}"; do
|
||||
kill -TERM "$pid" 2>/dev/null && echo " signaled $pid" || echo " $pid already gone"
|
||||
done
|
||||
fi
|
||||
|
||||
leftover_pg_pids=()
|
||||
leftover_pg_data_dirs=()
|
||||
for i in "${!pg_pids[@]:-}"; do
|
||||
pid="${pg_pids[$i]}"
|
||||
if is_pid_running "$pid"; then
|
||||
leftover_pg_pids+=("$pid")
|
||||
leftover_pg_data_dirs+=("${pg_data_dirs[$i]}")
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ ${#leftover_pg_pids[@]} -gt 0 ]]; then
|
||||
echo "Sending SIGTERM to leftover embedded PostgreSQL processes..."
|
||||
for i in "${!leftover_pg_pids[@]:-}"; do
|
||||
pid="${leftover_pg_pids[$i]}"
|
||||
data_dir="${leftover_pg_data_dirs[$i]}"
|
||||
kill -TERM "$pid" 2>/dev/null \
|
||||
&& echo " signaled $pid ($data_dir)" \
|
||||
|| echo " $pid already gone"
|
||||
done
|
||||
echo "Waiting up to 15s for PostgreSQL to shut down cleanly..."
|
||||
for pid in "${leftover_pg_pids[@]:-}"; do
|
||||
if wait_for_pid_exit "$pid" 15; then
|
||||
echo " postgres $pid exited cleanly"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ ${#node_pids[@]} -gt 0 ]]; then
|
||||
for pid in "${node_pids[@]:-}"; do
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
echo " node $pid still alive, sending SIGKILL..."
|
||||
kill -KILL "$pid" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ ${#pg_pids[@]} -gt 0 ]]; then
|
||||
for pid in "${pg_pids[@]:-}"; do
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
echo " postgres $pid still alive, sending SIGKILL..."
|
||||
kill -KILL "$pid" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ ${#browser_pids[@]} -gt 0 ]]; then
|
||||
for pid in "${browser_pids[@]:-}"; do
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
echo " agent browser $pid still alive, sending SIGKILL..."
|
||||
kill -KILL "$pid" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
echo "Done."
|
||||
Executable
+65
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Kill all running vitest processes.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/kill-vitest.sh # kill all
|
||||
# scripts/kill-vitest.sh --dry # preview what would be killed
|
||||
#
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
DRY_RUN=false
|
||||
if [[ "${1:-}" == "--dry" || "${1:-}" == "--dry-run" || "${1:-}" == "-n" ]]; then
|
||||
DRY_RUN=true
|
||||
fi
|
||||
|
||||
pids=()
|
||||
lines=()
|
||||
|
||||
while IFS= read -r line; do
|
||||
[[ -z "$line" ]] && continue
|
||||
pid=$(echo "$line" | awk '{print $2}')
|
||||
pids+=("$pid")
|
||||
lines+=("$line")
|
||||
done < <(ps aux | grep -E '(^|/)(vitest|node .*/vitest)( |$)|/\.bin/vitest|vitest/dist|vitest\.mjs' | grep -v grep || true)
|
||||
|
||||
if [[ ${#pids[@]} -eq 0 ]]; then
|
||||
echo "No vitest processes found."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Found ${#pids[@]} vitest process(es):"
|
||||
echo ""
|
||||
|
||||
for i in "${!pids[@]}"; do
|
||||
line="${lines[$i]}"
|
||||
pid=$(echo "$line" | awk '{print $2}')
|
||||
start=$(echo "$line" | awk '{print $9}')
|
||||
cmd=$(echo "$line" | awk '{for(i=11;i<=NF;i++) printf "%s ", $i; print ""}')
|
||||
cmd=$(echo "$cmd" | sed "s|$HOME/||g")
|
||||
printf " PID %-7s started %-10s %s\n" "$pid" "$start" "$cmd"
|
||||
done
|
||||
|
||||
echo ""
|
||||
|
||||
if [[ "$DRY_RUN" == true ]]; then
|
||||
echo "Dry run — re-run without --dry to kill these processes."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Sending SIGTERM..."
|
||||
for pid in "${pids[@]}"; do
|
||||
kill -TERM "$pid" 2>/dev/null && echo " signaled $pid" || echo " $pid already gone"
|
||||
done
|
||||
|
||||
sleep 2
|
||||
|
||||
for pid in "${pids[@]}"; do
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
echo " $pid still alive, sending SIGKILL..."
|
||||
kill -KILL "$pid" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Done."
|
||||
Executable
+289
@@ -0,0 +1,289 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Kill all local Paperclip workspace runtime service processes.
|
||||
#
|
||||
# This targets managed workspace services such as preview/dev commands started
|
||||
# from project or execution workspaces. Use scripts/kill-dev.sh for Paperclip
|
||||
# server processes.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/kill-workspaces.sh # kill workspace runtime services
|
||||
# scripts/kill-workspaces.sh --dry # preview what would be killed
|
||||
#
|
||||
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
|
||||
DRY_RUN=false
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--dry|--dry-run|-n)
|
||||
DRY_RUN=true
|
||||
;;
|
||||
--help|-h)
|
||||
sed -n '2,12p' "$0" | sed 's/^# \{0,1\}//'
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown argument: $arg" >&2
|
||||
echo "Usage: scripts/kill-workspaces.sh [--dry]" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
REPO_PARENT="$(dirname "$REPO_ROOT")"
|
||||
CURRENT_PID="$$"
|
||||
CURRENT_PGID="$(ps -o pgid= -p "$CURRENT_PID" 2>/dev/null | tr -d '[:space:]' || true)"
|
||||
|
||||
expand_home() {
|
||||
local value="$1"
|
||||
if [[ "$value" == "~" ]]; then
|
||||
printf '%s\n' "$HOME"
|
||||
elif [[ "$value" == "~/"* ]]; then
|
||||
printf '%s\n' "$HOME/${value#"~/"}"
|
||||
else
|
||||
printf '%s\n' "$value"
|
||||
fi
|
||||
}
|
||||
|
||||
runtime_dirs=()
|
||||
declare -A seen_runtime_dirs=()
|
||||
|
||||
append_runtime_dir() {
|
||||
local dir="$1"
|
||||
[[ -d "$dir" ]] || return 0
|
||||
dir="$(cd "$dir" && pwd)"
|
||||
if [[ -z "${seen_runtime_dirs[$dir]:-}" ]]; then
|
||||
seen_runtime_dirs["$dir"]=1
|
||||
runtime_dirs+=("$dir")
|
||||
fi
|
||||
}
|
||||
|
||||
paperclip_home="$(expand_home "${PAPERCLIP_HOME:-$HOME/.paperclip}")"
|
||||
paperclip_instance_id="${PAPERCLIP_INSTANCE_ID:-default}"
|
||||
append_runtime_dir "$paperclip_home/instances/$paperclip_instance_id/runtime-services"
|
||||
|
||||
if [[ "${PAPERCLIP_KILL_WORKSPACES_ONLY_CURRENT:-}" != "1" ]]; then
|
||||
for dir in \
|
||||
"$HOME"/.paperclip/instances/*/runtime-services \
|
||||
"$HOME"/.paperclip-worktrees/instances/*/runtime-services \
|
||||
"$REPO_ROOT"/.paperclip/instances/*/runtime-services \
|
||||
"$REPO_ROOT"/.paperclip/runtime-services/instances/*/runtime-services
|
||||
do
|
||||
append_runtime_dir "$dir"
|
||||
done
|
||||
|
||||
for sibling_root in "$REPO_PARENT"/paperclip*; do
|
||||
[[ -d "$sibling_root" ]] || continue
|
||||
for dir in \
|
||||
"$sibling_root"/.paperclip/instances/*/runtime-services \
|
||||
"$sibling_root"/.paperclip/runtime-services/instances/*/runtime-services
|
||||
do
|
||||
append_runtime_dir "$dir"
|
||||
done
|
||||
done
|
||||
fi
|
||||
|
||||
record_lines=()
|
||||
if [[ ${#runtime_dirs[@]} -gt 0 ]]; then
|
||||
mapfile -t record_lines < <(node - "${runtime_dirs[@]}" <<'NODE'
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
function clean(value) {
|
||||
return String(value ?? "").replace(/[\t\r\n]+/g, " ");
|
||||
}
|
||||
|
||||
for (const dir of process.argv.slice(2)) {
|
||||
let entries = [];
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
|
||||
const filePath = path.resolve(dir, entry.name);
|
||||
let record;
|
||||
try {
|
||||
record = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!record || record.profileKind !== "workspace-runtime") continue;
|
||||
if (!Number.isInteger(record.pid) || record.pid <= 0) continue;
|
||||
|
||||
console.log([
|
||||
filePath,
|
||||
record.serviceKey,
|
||||
record.serviceName,
|
||||
record.pid,
|
||||
Number.isInteger(record.processGroupId) && record.processGroupId > 0 ? record.processGroupId : "",
|
||||
Number.isInteger(record.port) && record.port > 0 ? record.port : "",
|
||||
record.cwd,
|
||||
record.url,
|
||||
record.runtimeServiceId,
|
||||
].map(clean).join("\x1f"));
|
||||
}
|
||||
}
|
||||
NODE
|
||||
)
|
||||
fi
|
||||
|
||||
is_pid_running() {
|
||||
local pid="$1"
|
||||
kill -0 "$pid" 2>/dev/null
|
||||
}
|
||||
|
||||
is_group_running() {
|
||||
local pgid="$1"
|
||||
[[ -n "$pgid" && "$pgid" =~ ^[0-9]+$ && "$pgid" != "1" ]] || return 1
|
||||
[[ -z "$CURRENT_PGID" || "$pgid" != "$CURRENT_PGID" ]] || return 1
|
||||
kill -0 "-$pgid" 2>/dev/null
|
||||
}
|
||||
|
||||
target_is_running() {
|
||||
local type="$1"
|
||||
local id="$2"
|
||||
if [[ "$type" == "group" ]]; then
|
||||
is_group_running "$id"
|
||||
else
|
||||
is_pid_running "$id"
|
||||
fi
|
||||
}
|
||||
|
||||
signal_target() {
|
||||
local type="$1"
|
||||
local id="$2"
|
||||
local signal="$3"
|
||||
if [[ "$type" == "group" ]]; then
|
||||
kill "-$signal" "-$id" 2>/dev/null
|
||||
else
|
||||
kill "-$signal" "$id" 2>/dev/null
|
||||
fi
|
||||
}
|
||||
|
||||
active_files=()
|
||||
stale_files=()
|
||||
display_lines=()
|
||||
target_keys=()
|
||||
target_types=()
|
||||
target_ids=()
|
||||
declare -A seen_target_keys=()
|
||||
|
||||
for line in "${record_lines[@]}"; do
|
||||
IFS=$'\x1f' read -r file service_key service_name pid pgid port cwd url runtime_service_id <<< "$line"
|
||||
|
||||
target_type=""
|
||||
target_id=""
|
||||
if is_group_running "$pgid"; then
|
||||
target_type="group"
|
||||
target_id="$pgid"
|
||||
elif is_pid_running "$pid" && [[ "$pid" != "$CURRENT_PID" ]]; then
|
||||
target_type="pid"
|
||||
target_id="$pid"
|
||||
fi
|
||||
|
||||
if [[ -z "$target_id" ]]; then
|
||||
stale_files+=("$file")
|
||||
continue
|
||||
fi
|
||||
|
||||
active_files+=("$file")
|
||||
target_key="$target_type:$target_id"
|
||||
if [[ -z "${seen_target_keys[$target_key]:-}" ]]; then
|
||||
seen_target_keys["$target_key"]=1
|
||||
target_keys+=("$target_key")
|
||||
target_types+=("$target_type")
|
||||
target_ids+=("$target_id")
|
||||
fi
|
||||
|
||||
short_file="${file/#$HOME\//}"
|
||||
short_cwd="${cwd/#$HOME\//}"
|
||||
target_label="$target_type:$target_id"
|
||||
display_lines+=("$(printf " %-24s pid %-7s target %-14s port %-6s cwd %-45s registry %s" \
|
||||
"${service_name:-workspace-runtime}" "$pid" "$target_label" "${port:-"-"}" "${short_cwd:-"-"}" "$short_file")")
|
||||
if [[ -n "${url:-}" ]]; then
|
||||
display_lines+=("$(printf " %-24s url %s" "" "$url")")
|
||||
fi
|
||||
if [[ -n "${runtime_service_id:-}" ]]; then
|
||||
display_lines+=("$(printf " %-24s runtimeServiceId %s" "" "$runtime_service_id")")
|
||||
fi
|
||||
if [[ -n "${service_key:-}" ]]; then
|
||||
display_lines+=("$(printf " %-24s serviceKey %s" "" "$service_key")")
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ ${#active_files[@]} -eq 0 && ${#stale_files[@]} -eq 0 ]]; then
|
||||
echo "No Paperclip workspace runtime services found."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ ${#active_files[@]} -gt 0 ]]; then
|
||||
echo "Found ${#active_files[@]} Paperclip workspace runtime service record(s):"
|
||||
echo ""
|
||||
printf '%s\n' "${display_lines[@]}"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if [[ ${#stale_files[@]} -gt 0 ]]; then
|
||||
echo "Found ${#stale_files[@]} stale workspace runtime service registry record(s)."
|
||||
if [[ "$DRY_RUN" == true ]]; then
|
||||
stale_preview_limit=20
|
||||
stale_preview_count=0
|
||||
for file in "${stale_files[@]}"; do
|
||||
if (( stale_preview_count >= stale_preview_limit )); then
|
||||
remaining=$(( ${#stale_files[@]} - stale_preview_count ))
|
||||
echo " ... ${remaining} more stale record(s)"
|
||||
break
|
||||
fi
|
||||
echo " stale ${file/#$HOME\//}"
|
||||
((stale_preview_count += 1))
|
||||
done
|
||||
fi
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if [[ "$DRY_RUN" == true ]]; then
|
||||
echo "Dry run — re-run without --dry to kill these services and remove stale registry records."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ ${#target_keys[@]} -gt 0 ]]; then
|
||||
echo "Sending SIGTERM to workspace runtime process targets..."
|
||||
for i in "${!target_keys[@]}"; do
|
||||
type="${target_types[$i]}"
|
||||
id="${target_ids[$i]}"
|
||||
if signal_target "$type" "$id" "TERM"; then
|
||||
echo " signaled $type $id"
|
||||
else
|
||||
echo " $type $id already gone"
|
||||
fi
|
||||
done
|
||||
|
||||
sleep 2
|
||||
|
||||
for i in "${!target_keys[@]}"; do
|
||||
type="${target_types[$i]}"
|
||||
id="${target_ids[$i]}"
|
||||
if target_is_running "$type" "$id"; then
|
||||
echo " $type $id still alive, sending SIGKILL..."
|
||||
signal_target "$type" "$id" "KILL" || true
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ ${#active_files[@]} -gt 0 || ${#stale_files[@]} -gt 0 ]]; then
|
||||
echo "Removing workspace runtime service registry records..."
|
||||
for file in "${active_files[@]:-}" "${stale_files[@]:-}"; do
|
||||
[[ -n "$file" ]] || continue
|
||||
rm -f "$file"
|
||||
echo " removed ${file/#$HOME\//}"
|
||||
done
|
||||
fi
|
||||
|
||||
echo "Done."
|
||||
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// Dev-only: link the in-repo @paperclipai/plugin-sdk into plugin packages that
|
||||
// are excluded from the pnpm workspace (see pnpm-workspace.yaml). Workspace
|
||||
// members get their SDK link from pnpm automatically; excluded packages do not.
|
||||
//
|
||||
// Invoked from the root postinstall. Intentionally NOT referenced from any
|
||||
// plugin's own package.json so the published tarballs cannot carry a lifecycle
|
||||
// script that escapes their package directory at install time.
|
||||
|
||||
import { existsSync, lstatSync, mkdirSync, readdirSync, readlinkSync, rmSync, symlinkSync } from "node:fs";
|
||||
import { dirname, join, relative, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = resolve(__dirname, "..");
|
||||
const sdkDir = join(repoRoot, "packages", "plugins", "sdk");
|
||||
|
||||
// Plugin packages excluded from the workspace that still need @paperclipai/plugin-sdk
|
||||
// linked in for local dev. Keep in sync with pnpm-workspace.yaml exclusions.
|
||||
function excludedPluginDirs() {
|
||||
return [
|
||||
...readPluginsUnder(join(repoRoot, "packages", "plugins", "sandbox-providers")),
|
||||
join(repoRoot, "packages", "plugins", "examples", "plugin-orchestration-smoke-example"),
|
||||
];
|
||||
}
|
||||
|
||||
export function linkExcludedPlugins() {
|
||||
let linked = 0;
|
||||
let skipped = 0;
|
||||
for (const packageDir of excludedPluginDirs()) {
|
||||
if (!existsSync(join(packageDir, "package.json"))) continue;
|
||||
if (linkSdkInto(packageDir)) {
|
||||
linked += 1;
|
||||
} else {
|
||||
skipped += 1;
|
||||
}
|
||||
}
|
||||
return { linked, skipped };
|
||||
}
|
||||
|
||||
// Run as a CLI (root postinstall) when invoked directly, but stay importable so
|
||||
// repo-internal scripts (e.g. the standalone package builder) can relink a
|
||||
// single package after a fresh install.
|
||||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
const { linked, skipped } = linkExcludedPlugins();
|
||||
console.log(` ✓ Linked @paperclipai/plugin-sdk into ${linked} excluded plugin(s) (skipped ${skipped})`);
|
||||
}
|
||||
|
||||
// Recursively collect package directories (those containing a package.json)
|
||||
// under parentDir. The matching pnpm-workspace.yaml exclusion uses a recursive
|
||||
// glob (e.g. "!packages/plugins/sandbox-providers/**"), so a provider nested
|
||||
// deeper than one level must still be discovered here.
|
||||
export function readPluginsUnder(parentDir) {
|
||||
if (!existsSync(parentDir)) return [];
|
||||
const found = [];
|
||||
for (const entry of readdirSync(parentDir, { withFileTypes: true })) {
|
||||
// Skip symlinked directories so discovery can't be steered outside the
|
||||
// intended plugin subtree, and skip node_modules.
|
||||
if (!entry.isDirectory() || entry.isSymbolicLink() || entry.name === "node_modules") continue;
|
||||
const childDir = join(parentDir, entry.name);
|
||||
if (existsSync(join(childDir, "package.json"))) {
|
||||
found.push(childDir);
|
||||
} else {
|
||||
found.push(...readPluginsUnder(childDir));
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
export function linkSdkInto(packageDir) {
|
||||
const scopeDir = join(packageDir, "node_modules", "@paperclipai");
|
||||
const linkTarget = join(scopeDir, "plugin-sdk");
|
||||
const relativeSdkDir = relative(scopeDir, sdkDir);
|
||||
|
||||
mkdirSync(scopeDir, { recursive: true });
|
||||
|
||||
try {
|
||||
const stat = lstatSync(linkTarget);
|
||||
if (stat.isSymbolicLink()) {
|
||||
if (readlinkSync(linkTarget) === relativeSdkDir) {
|
||||
// Already linked to the in-repo SDK; nothing to do.
|
||||
return false;
|
||||
}
|
||||
rmSync(linkTarget, { force: true });
|
||||
} else {
|
||||
// A real install has already populated @paperclipai/plugin-sdk (e.g. the
|
||||
// plugin host did `npm install` of the published tarball). Leave it.
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
// A missing target is expected (nothing linked yet); surface anything else.
|
||||
if (error?.code !== "ENOENT") throw error;
|
||||
}
|
||||
|
||||
symlinkSync(relativeSdkDir, linkTarget, "dir");
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import { linkSdkInto, readPluginsUnder } from "./link-plugin-dev-sdk.mjs";
|
||||
|
||||
let workDir;
|
||||
|
||||
before(() => {
|
||||
workDir = mkdtempSync(join(tmpdir(), "link-plugin-dev-sdk-"));
|
||||
});
|
||||
|
||||
after(() => {
|
||||
rmSync(workDir, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
function makePackage(dir) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, "package.json"), "{}\n");
|
||||
return dir;
|
||||
}
|
||||
|
||||
test("readPluginsUnder returns [] for a missing directory", () => {
|
||||
assert.deepEqual(readPluginsUnder(join(workDir, "does-not-exist")), []);
|
||||
});
|
||||
|
||||
test("readPluginsUnder finds first-level package directories", () => {
|
||||
const parent = join(workDir, "first-level");
|
||||
const a = makePackage(join(parent, "a"));
|
||||
const b = makePackage(join(parent, "b"));
|
||||
|
||||
assert.deepEqual(readPluginsUnder(parent).sort(), [a, b].sort());
|
||||
});
|
||||
|
||||
test("readPluginsUnder recurses into directories that are not themselves packages", () => {
|
||||
// Mirrors the recursive pnpm-workspace exclusion glob: a provider nested
|
||||
// deeper than one level must still be discovered.
|
||||
const parent = join(workDir, "nested");
|
||||
const nested = makePackage(join(parent, "vendor", "my-plugin"));
|
||||
|
||||
assert.deepEqual(readPluginsUnder(parent), [nested]);
|
||||
});
|
||||
|
||||
test("readPluginsUnder stops descending once a package.json is found and skips node_modules", () => {
|
||||
const parent = join(workDir, "boundaries");
|
||||
const pkg = makePackage(join(parent, "plugin"));
|
||||
// A nested package inside an already-matched package must not be returned.
|
||||
makePackage(join(pkg, "sub-package"));
|
||||
// node_modules must be ignored entirely.
|
||||
makePackage(join(parent, "node_modules", "some-dep"));
|
||||
|
||||
assert.deepEqual(readPluginsUnder(parent), [pkg]);
|
||||
});
|
||||
|
||||
test("linkSdkInto creates the plugin-sdk symlink and is idempotent", () => {
|
||||
const pkg = makePackage(join(workDir, "link-target"));
|
||||
|
||||
assert.equal(linkSdkInto(pkg), true);
|
||||
|
||||
const link = join(pkg, "node_modules", "@paperclipai", "plugin-sdk");
|
||||
assert.ok(lstatSync(link).isSymbolicLink());
|
||||
|
||||
// Second call is a no-op because the link already points at the in-repo SDK.
|
||||
assert.equal(linkSdkInto(pkg), false);
|
||||
});
|
||||
|
||||
test("linkSdkInto leaves a real (non-symlink) install in place", () => {
|
||||
const pkg = makePackage(join(workDir, "real-install"));
|
||||
const scopeDir = join(pkg, "node_modules", "@paperclipai");
|
||||
mkdirSync(scopeDir, { recursive: true });
|
||||
// Simulate a published-tarball install: a real directory, not a symlink.
|
||||
makePackage(join(scopeDir, "plugin-sdk"));
|
||||
|
||||
assert.equal(linkSdkInto(pkg), false);
|
||||
assert.ok(!lstatSync(join(scopeDir, "plugin-sdk")).isSymbolicLink());
|
||||
});
|
||||
|
||||
test("linkSdkInto replaces a symlink that points somewhere else", () => {
|
||||
const pkg = makePackage(join(workDir, "stale-link"));
|
||||
const scopeDir = join(pkg, "node_modules", "@paperclipai");
|
||||
mkdirSync(scopeDir, { recursive: true });
|
||||
symlinkSync("../somewhere-else", join(scopeDir, "plugin-sdk"), "dir");
|
||||
|
||||
assert.equal(linkSdkInto(pkg), true);
|
||||
assert.notEqual(readlinkSync(join(scopeDir, "plugin-sdk")), "../somewhere-else");
|
||||
assert.ok(existsSync(scopeDir));
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { chromium } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const baseUrl = (process.env.PAPERCLIP_PERF_BASE_URL || "http://localhost:3100").replace(/\/$/, "");
|
||||
const companyPrefix = process.env.PAPERCLIP_PERF_COMPANY_PREFIX;
|
||||
const url = companyPrefix
|
||||
? `${baseUrl}/${companyPrefix}/tests/perf/long-thread`
|
||||
: `${baseUrl}/tests/perf/long-thread`;
|
||||
const origin = new URL(url).origin;
|
||||
|
||||
function loadBoardToken() {
|
||||
const authPath = path.resolve(os.homedir(), ".paperclip/auth.json");
|
||||
try {
|
||||
const auth = JSON.parse(fs.readFileSync(authPath, "utf-8"));
|
||||
const credentials = auth.credentials || {};
|
||||
const matching = Object.values(credentials).find((entry) => {
|
||||
if (!entry || !entry.token || !entry.apiBase) return false;
|
||||
return new URL(entry.apiBase).origin === origin;
|
||||
});
|
||||
if (matching?.token) return matching.token;
|
||||
const fallback = Object.values(credentials).find((entry) => entry?.token);
|
||||
return fallback?.token ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
|
||||
const boardToken = process.env.PAPERCLIP_PERF_BEARER_TOKEN || loadBoardToken();
|
||||
|
||||
if (boardToken) {
|
||||
await page.route(`${origin}/**`, async (route) => {
|
||||
await route.continue({
|
||||
headers: { ...route.request().headers(), Authorization: `Bearer ${boardToken}` },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const startedAt = Date.now();
|
||||
await page.goto(url, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector('[data-testid="issue-chat-long-thread-perf"]', { timeout: 30_000 });
|
||||
await page.waitForFunction(() => {
|
||||
const target = Number(document.querySelector('[data-testid="perf-fixture-row-target"]')?.textContent ?? "450");
|
||||
const renderedRows = document.querySelectorAll('[data-testid="issue-chat-message-row"]').length;
|
||||
const virtualizer = document.querySelector('[data-testid="issue-chat-thread-virtualizer"]');
|
||||
if (!virtualizer) return renderedRows >= target;
|
||||
const virtualCount = Number(virtualizer.getAttribute("data-virtual-count") ?? "0");
|
||||
return virtualCount >= target && renderedRows > 0 && renderedRows < target;
|
||||
}, null, { timeout: 60_000 });
|
||||
const rowReadyMs = Date.now() - startedAt;
|
||||
|
||||
const metrics = await page.evaluate(async () => {
|
||||
const text = (testId) => document.querySelector(`[data-testid="${testId}"]`)?.textContent?.trim() ?? "";
|
||||
const numericMs = (testId) => {
|
||||
const value = text(testId).replace(/\s*ms$/, "");
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
};
|
||||
|
||||
const rowCount = document.querySelectorAll('[data-testid="issue-chat-message-row"]').length;
|
||||
const virtualizer = document.querySelector('[data-testid="issue-chat-thread-virtualizer"]');
|
||||
const virtualCount = Number(virtualizer?.getAttribute("data-virtual-count") ?? "0");
|
||||
const assistantRowCount = document.querySelectorAll('[data-testid="issue-chat-message-row"][data-message-role="assistant"]').length;
|
||||
const systemRowCount = document.querySelectorAll('[data-testid="issue-chat-message-row"][data-message-role="system"]').length;
|
||||
const userRowCount = document.querySelectorAll('[data-testid="issue-chat-message-row"][data-message-role="user"]').length;
|
||||
const markdownRows = Number(text("perf-fixture-markdown-rows"));
|
||||
const commitCount = Number(text("perf-commit-count"));
|
||||
const scrollStartY = window.scrollY;
|
||||
const scrollTarget = Math.max(0, document.documentElement.scrollHeight - window.innerHeight);
|
||||
const scrollStartedAt = performance.now();
|
||||
window.scrollTo({ top: scrollTarget, behavior: "instant" });
|
||||
await new Promise((resolve) => requestAnimationFrame(() => resolve()));
|
||||
await new Promise((resolve) => requestAnimationFrame(() => resolve()));
|
||||
const scrollResponsiveMs = performance.now() - scrollStartedAt;
|
||||
|
||||
return {
|
||||
url: window.location.href,
|
||||
fixtureRowTarget: Number(text("perf-fixture-row-target")),
|
||||
virtualized: Boolean(virtualizer),
|
||||
virtualCount,
|
||||
rowCount,
|
||||
assistantRowCount,
|
||||
userRowCount,
|
||||
systemRowCount,
|
||||
markdownRows,
|
||||
commitCount,
|
||||
mountActualDurationMs: numericMs("perf-mount-duration"),
|
||||
latestActualDurationMs: numericMs("perf-latest-duration"),
|
||||
maxActualDurationMs: numericMs("perf-max-duration"),
|
||||
totalActualDurationMs: numericMs("perf-total-duration"),
|
||||
reactProfilerAvailable: commitCount > 0,
|
||||
scrollResponsiveMs: Number(scrollResponsiveMs.toFixed(1)),
|
||||
scrollDeltaPx: Math.round(Math.abs(window.scrollY - scrollStartY)),
|
||||
documentHeightPx: Math.round(document.documentElement.scrollHeight),
|
||||
};
|
||||
});
|
||||
|
||||
const elapsedMs = Date.now() - startedAt;
|
||||
console.log(JSON.stringify({ ...metrics, renderReadyMs: rowReadyMs, elapsedMs }, null, 2));
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { agents, createDb } from "@paperclipai/db";
|
||||
import { secretService } from "../server/src/services/secrets.js";
|
||||
|
||||
const SENSITIVE_ENV_KEY_RE =
|
||||
/(api[-_]?key|access[-_]?token|auth(?:_?token)?|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)/i;
|
||||
|
||||
type EnvBinding =
|
||||
| string
|
||||
| { type: "plain"; value: string }
|
||||
| { type: "secret_ref"; secretId: string; version?: number | "latest" };
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function toPlainValue(binding: unknown): string | null {
|
||||
if (typeof binding === "string") return binding;
|
||||
if (typeof binding !== "object" || binding === null || Array.isArray(binding)) return null;
|
||||
const rec = binding as Record<string, unknown>;
|
||||
if (rec.type === "plain" && typeof rec.value === "string") return rec.value;
|
||||
return null;
|
||||
}
|
||||
|
||||
function secretName(agentId: string, key: string) {
|
||||
return `agent_${agentId.slice(0, 8)}_${key.toLowerCase()}`;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const dbUrl = process.env.DATABASE_URL;
|
||||
if (!dbUrl) {
|
||||
console.error("DATABASE_URL is required");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const apply = process.argv.includes("--apply");
|
||||
const db = createDb(dbUrl);
|
||||
const secrets = secretService(db);
|
||||
|
||||
const allAgents = await db.select().from(agents);
|
||||
let changedAgents = 0;
|
||||
let createdSecrets = 0;
|
||||
let rotatedSecrets = 0;
|
||||
|
||||
for (const agent of allAgents) {
|
||||
const adapterConfig = asRecord(agent.adapterConfig);
|
||||
if (!adapterConfig) continue;
|
||||
const env = asRecord(adapterConfig.env);
|
||||
if (!env) continue;
|
||||
|
||||
let changed = false;
|
||||
const nextEnv: Record<string, EnvBinding> = { ...(env as Record<string, EnvBinding>) };
|
||||
|
||||
for (const [key, rawBinding] of Object.entries(env)) {
|
||||
if (!SENSITIVE_ENV_KEY_RE.test(key)) continue;
|
||||
const plain = toPlainValue(rawBinding);
|
||||
if (plain === null) continue;
|
||||
if (plain.trim().length === 0) continue;
|
||||
|
||||
const name = secretName(agent.id, key);
|
||||
if (apply) {
|
||||
const existing = await secrets.getByName(agent.companyId, name);
|
||||
if (existing) {
|
||||
await secrets.rotate(
|
||||
existing.id,
|
||||
{ value: plain },
|
||||
{ userId: "migration", agentId: null },
|
||||
);
|
||||
rotatedSecrets += 1;
|
||||
nextEnv[key] = { type: "secret_ref", secretId: existing.id, version: "latest" };
|
||||
} else {
|
||||
const created = await secrets.create(
|
||||
agent.companyId,
|
||||
{
|
||||
name,
|
||||
provider: "local_encrypted",
|
||||
value: plain,
|
||||
description: `Migrated from agent ${agent.id} env ${key}`,
|
||||
},
|
||||
{ userId: "migration", agentId: null },
|
||||
);
|
||||
createdSecrets += 1;
|
||||
nextEnv[key] = { type: "secret_ref", secretId: created.id, version: "latest" };
|
||||
}
|
||||
} else {
|
||||
nextEnv[key] = {
|
||||
type: "secret_ref",
|
||||
secretId: `<would-create:${name}>`,
|
||||
version: "latest",
|
||||
};
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!changed) continue;
|
||||
changedAgents += 1;
|
||||
|
||||
if (apply) {
|
||||
await db
|
||||
.update(agents)
|
||||
.set({
|
||||
adapterConfig: {
|
||||
...adapterConfig,
|
||||
env: nextEnv,
|
||||
},
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(agents.id, agent.id));
|
||||
}
|
||||
}
|
||||
|
||||
if (!apply) {
|
||||
console.log(`Dry run: ${changedAgents} agents would be updated`);
|
||||
console.log("Re-run with --apply to persist changes");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Updated ${changedAgents} agents, created ${createdSecrets} secrets, rotated ${rotatedSecrets} secrets`,
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
void main();
|
||||
@@ -0,0 +1,872 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
import { execFile } from "node:child_process";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const DEFAULT_QUERY = "\"Co-Authored-By: Paperclip <noreply@paperclip.ing>\"";
|
||||
const DEFAULT_CACHE_FILE = path.resolve("data/paperclip-commit-metrics-cache.json");
|
||||
const DEFAULT_SEARCH_START = "2008-01-01T00:00:00Z";
|
||||
const SEARCH_WINDOW_LIMIT = 900;
|
||||
const MIN_WINDOW_MS = 60_000;
|
||||
const DEFAULT_STATS_FETCH_LIMIT = 250;
|
||||
const DEFAULT_STATS_CONCURRENCY = 4;
|
||||
const DEFAULT_SEARCH_FIELD = "committer-date";
|
||||
const PAPERCLIP_EMAIL = "noreply@paperclip.ing";
|
||||
const PAPERCLIP_NAME = "paperclip";
|
||||
|
||||
interface CliOptions {
|
||||
cacheFile: string;
|
||||
end: Date;
|
||||
excludeOwners: string[];
|
||||
exportFormat: "csv" | "json";
|
||||
includePrivate: boolean;
|
||||
json: boolean;
|
||||
output: string | null;
|
||||
query: string;
|
||||
refreshSearch: boolean;
|
||||
refreshStats: boolean;
|
||||
searchField: "author-date" | "committer-date";
|
||||
start: Date;
|
||||
statsConcurrency: number;
|
||||
statsFetchLimit: number;
|
||||
skipStats: boolean;
|
||||
}
|
||||
|
||||
interface SearchCommitItem {
|
||||
author: {
|
||||
login?: string;
|
||||
} | null;
|
||||
commit: {
|
||||
author: {
|
||||
date: string;
|
||||
email: string | null;
|
||||
name: string | null;
|
||||
} | null;
|
||||
message: string;
|
||||
};
|
||||
html_url: string;
|
||||
repository: {
|
||||
full_name: string;
|
||||
html_url: string;
|
||||
};
|
||||
sha: string;
|
||||
}
|
||||
|
||||
interface CommitStats {
|
||||
additions: number;
|
||||
deletions: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface CachedCommit {
|
||||
authorEmail: string | null;
|
||||
authorLogin: string | null;
|
||||
authorName: string | null;
|
||||
committedAt: string | null;
|
||||
contributors: ContributorRecord[];
|
||||
htmlUrl: string;
|
||||
repositoryFullName: string;
|
||||
repositoryUrl: string;
|
||||
sha: string;
|
||||
}
|
||||
|
||||
interface CachedCommitStats extends CommitStats {
|
||||
fetchedAt: string;
|
||||
}
|
||||
|
||||
interface ContributorRecord {
|
||||
displayName: string;
|
||||
email: string | null;
|
||||
key: string;
|
||||
login: string | null;
|
||||
}
|
||||
|
||||
interface WindowCacheEntry {
|
||||
completedAt: string;
|
||||
key: string;
|
||||
shas: string[];
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
interface CacheFile {
|
||||
commits: Record<string, CachedCommit>;
|
||||
queryKey: string;
|
||||
searchField: CliOptions["searchField"];
|
||||
stats: Record<string, CachedCommitStats>;
|
||||
updatedAt: string | null;
|
||||
version: number;
|
||||
windows: Record<string, WindowCacheEntry>;
|
||||
}
|
||||
|
||||
interface SearchResponse {
|
||||
incomplete_results: boolean;
|
||||
items: SearchCommitItem[];
|
||||
total_count: number;
|
||||
}
|
||||
|
||||
interface SearchWindowResult {
|
||||
shas: Set<string>;
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
interface Summary {
|
||||
cacheFile: string;
|
||||
contributors: {
|
||||
count: number;
|
||||
sample: ContributorRecord[];
|
||||
};
|
||||
detectedQuery: string;
|
||||
lineStats: {
|
||||
additions: number;
|
||||
complete: boolean;
|
||||
coveredCommits: number;
|
||||
deletions: number;
|
||||
missingCommits: number;
|
||||
totalChanges: number;
|
||||
};
|
||||
range: {
|
||||
end: string;
|
||||
searchField: CliOptions["searchField"];
|
||||
start: string;
|
||||
};
|
||||
filters: {
|
||||
excludedOwners: string[];
|
||||
};
|
||||
repos: {
|
||||
count: number;
|
||||
sample: string[];
|
||||
};
|
||||
statsFetch: {
|
||||
fetchedThisRun: number;
|
||||
skipped: boolean;
|
||||
};
|
||||
totals: {
|
||||
commits: number;
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const cache = await loadCache(options.cacheFile, options);
|
||||
const client = new GitHubClient(await resolveGitHubToken());
|
||||
|
||||
const { shas } = await searchWindow(client, cache, options, options.start, options.end);
|
||||
const sortedShas = [...shas].sort();
|
||||
|
||||
let fetchedThisRun = 0;
|
||||
if (!options.skipStats) {
|
||||
fetchedThisRun = await enrichCommitStats(client, cache, options, sortedShas);
|
||||
}
|
||||
|
||||
cache.updatedAt = new Date().toISOString();
|
||||
await saveCache(options.cacheFile, cache);
|
||||
|
||||
const filteredShas = sortFilteredShas(cache, filterShas(cache, sortedShas, options));
|
||||
const summary = buildSummary(cache, options, filteredShas, fetchedThisRun);
|
||||
|
||||
if (options.output) {
|
||||
await writeExport(options.output, options.exportFormat, cache, filteredShas, summary);
|
||||
}
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
printSummary(summary);
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): CliOptions {
|
||||
const options: CliOptions = {
|
||||
cacheFile: DEFAULT_CACHE_FILE,
|
||||
end: new Date(),
|
||||
excludeOwners: [],
|
||||
exportFormat: "csv",
|
||||
includePrivate: false,
|
||||
json: false,
|
||||
output: null,
|
||||
query: DEFAULT_QUERY,
|
||||
refreshSearch: false,
|
||||
refreshStats: false,
|
||||
searchField: DEFAULT_SEARCH_FIELD,
|
||||
start: new Date(DEFAULT_SEARCH_START),
|
||||
statsConcurrency: DEFAULT_STATS_CONCURRENCY,
|
||||
statsFetchLimit: DEFAULT_STATS_FETCH_LIMIT,
|
||||
skipStats: false,
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
switch (arg) {
|
||||
case "--cache-file":
|
||||
options.cacheFile = requireValue(argv, ++index, arg);
|
||||
break;
|
||||
case "--end":
|
||||
options.end = parseDateArg(requireValue(argv, ++index, arg), arg);
|
||||
break;
|
||||
case "--exclude-owner":
|
||||
options.excludeOwners.push(requireValue(argv, ++index, arg).toLowerCase());
|
||||
break;
|
||||
case "--export-format": {
|
||||
const value = requireValue(argv, ++index, arg);
|
||||
if (value !== "csv" && value !== "json") {
|
||||
throw new Error(`Invalid --export-format value: ${value}`);
|
||||
}
|
||||
options.exportFormat = value;
|
||||
break;
|
||||
}
|
||||
case "--include-private":
|
||||
options.includePrivate = true;
|
||||
break;
|
||||
case "--json":
|
||||
options.json = true;
|
||||
break;
|
||||
case "--output":
|
||||
options.output = requireValue(argv, ++index, arg);
|
||||
break;
|
||||
case "--query":
|
||||
options.query = requireValue(argv, ++index, arg);
|
||||
break;
|
||||
case "--refresh-search":
|
||||
options.refreshSearch = true;
|
||||
break;
|
||||
case "--refresh-stats":
|
||||
options.refreshStats = true;
|
||||
break;
|
||||
case "--search-field": {
|
||||
const value = requireValue(argv, ++index, arg);
|
||||
if (value !== "author-date" && value !== "committer-date") {
|
||||
throw new Error(`Invalid --search-field value: ${value}`);
|
||||
}
|
||||
options.searchField = value;
|
||||
break;
|
||||
}
|
||||
case "--skip-stats":
|
||||
options.skipStats = true;
|
||||
break;
|
||||
case "--start":
|
||||
options.start = parseDateArg(requireValue(argv, ++index, arg), arg);
|
||||
break;
|
||||
case "--stats-concurrency":
|
||||
options.statsConcurrency = parsePositiveInt(requireValue(argv, ++index, arg), arg);
|
||||
break;
|
||||
case "--stats-fetch-limit":
|
||||
options.statsFetchLimit = parseNonNegativeInt(requireValue(argv, ++index, arg), arg);
|
||||
break;
|
||||
case "--help":
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (Number.isNaN(options.start.getTime()) || Number.isNaN(options.end.getTime())) {
|
||||
throw new Error("Invalid start or end date");
|
||||
}
|
||||
if (options.start >= options.end) {
|
||||
throw new Error("--start must be earlier than --end");
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function requireValue(argv: string[], index: number, flag: string): string {
|
||||
const value = argv[index];
|
||||
if (!value) {
|
||||
throw new Error(`Missing value for ${flag}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseDateArg(value: string, flag: string): Date {
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
throw new Error(`Invalid date for ${flag}: ${value}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parsePositiveInt(value: string, flag: string): number {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
throw new Error(`Invalid positive integer for ${flag}: ${value}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseNonNegativeInt(value: string, flag: string): number {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
throw new Error(`Invalid non-negative integer for ${flag}: ${value}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`Usage: tsx scripts/paperclip-commit-metrics.ts [options]
|
||||
|
||||
Options:
|
||||
--start <date> ISO date/time lower bound (default: ${DEFAULT_SEARCH_START})
|
||||
--end <date> ISO date/time upper bound (default: now)
|
||||
--query <search> Commit search string (default: ${DEFAULT_QUERY})
|
||||
--search-field <field> author-date | committer-date (default: ${DEFAULT_SEARCH_FIELD})
|
||||
--include-private Include repos visible to the current token
|
||||
--exclude-owner <owner> Exclude repositories owned by this GitHub owner/org (repeatable)
|
||||
--cache-file <path> Cache path (default: ${DEFAULT_CACHE_FILE})
|
||||
--skip-stats Skip additions/deletions enrichment
|
||||
--stats-fetch-limit <n> Max uncached commit stats to fetch this run (default: ${DEFAULT_STATS_FETCH_LIMIT})
|
||||
--stats-concurrency <n> Parallel commit stat requests (default: ${DEFAULT_STATS_CONCURRENCY})
|
||||
--output <path> Write the full filtered result set to a file
|
||||
--export-format <format> csv | json for --output exports (default: csv)
|
||||
--refresh-search Ignore cached search windows
|
||||
--refresh-stats Re-fetch cached commit stats
|
||||
--json Print JSON summary
|
||||
--help Show this help
|
||||
`);
|
||||
}
|
||||
|
||||
async function resolveGitHubToken(): Promise<string> {
|
||||
const envToken = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN;
|
||||
if (envToken) {
|
||||
return envToken;
|
||||
}
|
||||
|
||||
const { stdout } = await execFileAsync("gh", ["auth", "token"]);
|
||||
const token = stdout.trim();
|
||||
if (!token) {
|
||||
throw new Error("Unable to resolve a GitHub token. Set GITHUB_TOKEN/GH_TOKEN or run `gh auth login`.");
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
async function loadCache(cacheFile: string, options: CliOptions): Promise<CacheFile> {
|
||||
try {
|
||||
const raw = await fs.readFile(cacheFile, "utf8");
|
||||
const parsed = JSON.parse(raw) as CacheFile;
|
||||
if (parsed.version !== 1 || parsed.queryKey !== buildQueryKey(options) || parsed.searchField !== options.searchField) {
|
||||
return createEmptyCache(options);
|
||||
}
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return createEmptyCache(options);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function createEmptyCache(options: CliOptions): CacheFile {
|
||||
return {
|
||||
commits: {},
|
||||
queryKey: buildQueryKey(options),
|
||||
searchField: options.searchField,
|
||||
stats: {},
|
||||
updatedAt: null,
|
||||
version: 1,
|
||||
windows: {},
|
||||
};
|
||||
}
|
||||
|
||||
function buildQueryKey(options: CliOptions): string {
|
||||
const visibility = options.includePrivate ? "all" : "public";
|
||||
return JSON.stringify({
|
||||
query: options.query,
|
||||
searchField: options.searchField,
|
||||
visibility,
|
||||
});
|
||||
}
|
||||
|
||||
async function saveCache(cacheFile: string, cache: CacheFile): Promise<void> {
|
||||
await fs.mkdir(path.dirname(cacheFile), { recursive: true });
|
||||
await fs.writeFile(cacheFile, JSON.stringify(cache, null, 2), "utf8");
|
||||
}
|
||||
|
||||
async function searchWindow(
|
||||
client: GitHubClient,
|
||||
cache: CacheFile,
|
||||
options: CliOptions,
|
||||
start: Date,
|
||||
end: Date,
|
||||
): Promise<SearchWindowResult> {
|
||||
const windowKey = makeWindowKey(start, end);
|
||||
if (!options.refreshSearch) {
|
||||
const cached = cache.windows[windowKey];
|
||||
if (cached) {
|
||||
return { shas: new Set(cached.shas), totalCount: cached.totalCount };
|
||||
}
|
||||
}
|
||||
|
||||
const firstPage = await searchPage(client, options, start, end, 1, 100);
|
||||
if (firstPage.incomplete_results) {
|
||||
throw new Error(`GitHub returned incomplete search results for window ${windowKey}`);
|
||||
}
|
||||
|
||||
if (firstPage.total_count > SEARCH_WINDOW_LIMIT) {
|
||||
const durationMs = end.getTime() - start.getTime();
|
||||
if (durationMs <= MIN_WINDOW_MS) {
|
||||
throw new Error(
|
||||
`Search window ${windowKey} still has ${firstPage.total_count} results after splitting to ${durationMs}ms.`,
|
||||
);
|
||||
}
|
||||
|
||||
const midpoint = new Date(start.getTime() + Math.floor(durationMs / 2));
|
||||
const left = await searchWindow(client, cache, options, start, midpoint);
|
||||
const right = await searchWindow(client, cache, options, new Date(midpoint.getTime() + 1), end);
|
||||
const shas = new Set([...left.shas, ...right.shas]);
|
||||
|
||||
cache.windows[windowKey] = {
|
||||
completedAt: new Date().toISOString(),
|
||||
key: windowKey,
|
||||
shas: [...shas],
|
||||
totalCount: shas.size,
|
||||
};
|
||||
|
||||
return { shas, totalCount: shas.size };
|
||||
}
|
||||
|
||||
const pageCount = Math.ceil(firstPage.total_count / 100);
|
||||
const shas = new Set<string>();
|
||||
ingestSearchItems(cache, firstPage.items, shas);
|
||||
|
||||
for (let page = 2; page <= pageCount; page += 1) {
|
||||
const response = await searchPage(client, options, start, end, page, 100);
|
||||
ingestSearchItems(cache, response.items, shas);
|
||||
}
|
||||
|
||||
cache.windows[windowKey] = {
|
||||
completedAt: new Date().toISOString(),
|
||||
key: windowKey,
|
||||
shas: [...shas],
|
||||
totalCount: firstPage.total_count,
|
||||
};
|
||||
|
||||
return { shas, totalCount: firstPage.total_count };
|
||||
}
|
||||
|
||||
async function searchPage(
|
||||
client: GitHubClient,
|
||||
options: CliOptions,
|
||||
start: Date,
|
||||
end: Date,
|
||||
page: number,
|
||||
perPage: number,
|
||||
): Promise<SearchResponse> {
|
||||
const searchQuery = buildSearchQuery(options, start, end);
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
per_page: String(perPage),
|
||||
q: searchQuery,
|
||||
});
|
||||
|
||||
return client.getJson<SearchResponse>(`/search/commits?${params.toString()}`);
|
||||
}
|
||||
|
||||
function buildSearchQuery(options: CliOptions, start: Date, end: Date): string {
|
||||
const qualifiers = [`${options.searchField}:${formatQueryDate(start)}..${formatQueryDate(end)}`];
|
||||
if (!options.includePrivate) {
|
||||
qualifiers.push("is:public");
|
||||
}
|
||||
return `${options.query} ${qualifiers.join(" ")}`.trim();
|
||||
}
|
||||
|
||||
function filterShas(cache: CacheFile, shas: string[], options: CliOptions): string[] {
|
||||
if (options.excludeOwners.length === 0) {
|
||||
return shas;
|
||||
}
|
||||
|
||||
const excludedOwners = new Set(options.excludeOwners);
|
||||
return shas.filter((sha) => {
|
||||
const commit = cache.commits[sha];
|
||||
if (!commit) {
|
||||
return false;
|
||||
}
|
||||
return !excludedOwners.has(getRepoOwner(commit.repositoryFullName));
|
||||
});
|
||||
}
|
||||
|
||||
function sortFilteredShas(cache: CacheFile, shas: string[]): string[] {
|
||||
return [...shas].sort((leftSha, rightSha) => {
|
||||
const left = cache.commits[leftSha];
|
||||
const right = cache.commits[rightSha];
|
||||
const leftTime = left?.committedAt ? Date.parse(left.committedAt) : 0;
|
||||
const rightTime = right?.committedAt ? Date.parse(right.committedAt) : 0;
|
||||
if (rightTime !== leftTime) {
|
||||
return rightTime - leftTime;
|
||||
}
|
||||
|
||||
const repoCompare = (left?.repositoryFullName ?? "").localeCompare(right?.repositoryFullName ?? "");
|
||||
if (repoCompare !== 0) {
|
||||
return repoCompare;
|
||||
}
|
||||
return leftSha.localeCompare(rightSha);
|
||||
});
|
||||
}
|
||||
|
||||
function formatQueryDate(value: Date): string {
|
||||
return value.toISOString().replace(".000Z", "Z");
|
||||
}
|
||||
|
||||
function ingestSearchItems(cache: CacheFile, items: SearchCommitItem[], shas: Set<string>) {
|
||||
for (const item of items) {
|
||||
shas.add(item.sha);
|
||||
cache.commits[item.sha] = {
|
||||
authorEmail: item.commit.author?.email ?? null,
|
||||
authorLogin: item.author?.login ?? null,
|
||||
authorName: item.commit.author?.name ?? null,
|
||||
committedAt: item.commit.author?.date ?? null,
|
||||
contributors: extractContributors(item),
|
||||
htmlUrl: item.html_url,
|
||||
repositoryFullName: item.repository.full_name,
|
||||
repositoryUrl: item.repository.html_url,
|
||||
sha: item.sha,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function extractContributors(item: SearchCommitItem): ContributorRecord[] {
|
||||
const contributors = new Map<string, ContributorRecord>();
|
||||
|
||||
const primaryAuthor = normalizeContributor({
|
||||
email: item.commit.author?.email ?? null,
|
||||
login: item.author?.login ?? null,
|
||||
name: item.commit.author?.name ?? null,
|
||||
});
|
||||
if (primaryAuthor) {
|
||||
contributors.set(primaryAuthor.key, primaryAuthor);
|
||||
}
|
||||
|
||||
const coAuthorPattern = /^co-authored-by:\s*(.+?)\s*<([^>]+)>\s*$/gim;
|
||||
for (const match of item.commit.message.matchAll(coAuthorPattern)) {
|
||||
const contributor = normalizeContributor({
|
||||
email: match[2] ?? null,
|
||||
login: null,
|
||||
name: match[1] ?? null,
|
||||
});
|
||||
if (contributor) {
|
||||
contributors.set(contributor.key, contributor);
|
||||
}
|
||||
}
|
||||
|
||||
return [...contributors.values()];
|
||||
}
|
||||
|
||||
function normalizeContributor(input: {
|
||||
email: string | null;
|
||||
login: string | null;
|
||||
name: string | null;
|
||||
}): ContributorRecord | null {
|
||||
const email = normalizeOptional(input.email);
|
||||
const login = normalizeOptional(input.login);
|
||||
const displayName = normalizeOptional(input.name) ?? login ?? email;
|
||||
|
||||
if (!displayName && !email && !login) {
|
||||
return null;
|
||||
}
|
||||
if ((email && email === PAPERCLIP_EMAIL) || (displayName && displayName.toLowerCase() === PAPERCLIP_NAME)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const key = login ? `login:${login}` : email ? `email:${email}` : `name:${displayName!.toLowerCase()}`;
|
||||
return {
|
||||
displayName: displayName ?? email ?? login ?? "unknown",
|
||||
email,
|
||||
key,
|
||||
login,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeOptional(value: string | null | undefined): string | null {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
function getRepoOwner(repositoryFullName: string): string {
|
||||
return repositoryFullName.split("/", 1)[0]?.toLowerCase() ?? "";
|
||||
}
|
||||
|
||||
async function enrichCommitStats(
|
||||
client: GitHubClient,
|
||||
cache: CacheFile,
|
||||
options: CliOptions,
|
||||
shas: string[],
|
||||
): Promise<number> {
|
||||
const pending = shas.filter((sha) => options.refreshStats || !cache.stats[sha]).slice(0, options.statsFetchLimit);
|
||||
let nextIndex = 0;
|
||||
let fetched = 0;
|
||||
|
||||
const workers = Array.from({ length: Math.min(options.statsConcurrency, pending.length) }, async () => {
|
||||
while (true) {
|
||||
const currentIndex = nextIndex;
|
||||
nextIndex += 1;
|
||||
const sha = pending[currentIndex];
|
||||
if (!sha) {
|
||||
return;
|
||||
}
|
||||
const commit = cache.commits[sha];
|
||||
if (!commit) {
|
||||
continue;
|
||||
}
|
||||
const stats = await fetchCommitStats(client, commit.repositoryFullName, sha);
|
||||
cache.stats[sha] = {
|
||||
...stats,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
};
|
||||
fetched += 1;
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(workers);
|
||||
return fetched;
|
||||
}
|
||||
|
||||
async function fetchCommitStats(client: GitHubClient, repositoryFullName: string, sha: string): Promise<CommitStats> {
|
||||
const response = await client.getJson<{ stats?: CommitStats }>(
|
||||
`/repos/${repositoryFullName}/commits/${sha}`,
|
||||
);
|
||||
return {
|
||||
additions: response.stats?.additions ?? 0,
|
||||
deletions: response.stats?.deletions ?? 0,
|
||||
total: response.stats?.total ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSummary(cache: CacheFile, options: CliOptions, shas: string[], fetchedThisRun: number): Summary {
|
||||
const repoNames = new Set<string>();
|
||||
const contributors = new Map<string, ContributorRecord>();
|
||||
let additions = 0;
|
||||
let deletions = 0;
|
||||
let coveredCommits = 0;
|
||||
|
||||
for (const sha of shas) {
|
||||
const commit = cache.commits[sha];
|
||||
if (!commit) {
|
||||
continue;
|
||||
}
|
||||
repoNames.add(commit.repositoryFullName);
|
||||
for (const contributor of commit.contributors) {
|
||||
contributors.set(contributor.key, contributor);
|
||||
}
|
||||
|
||||
const stats = cache.stats[sha];
|
||||
if (stats) {
|
||||
additions += stats.additions;
|
||||
deletions += stats.deletions;
|
||||
coveredCommits += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const contributorSample = [...contributors.values()]
|
||||
.sort((left, right) => left.displayName.localeCompare(right.displayName))
|
||||
.slice(0, 10);
|
||||
const repoSample = [...repoNames].sort((left, right) => left.localeCompare(right)).slice(0, 10);
|
||||
|
||||
return {
|
||||
cacheFile: options.cacheFile,
|
||||
contributors: {
|
||||
count: contributors.size,
|
||||
sample: contributorSample,
|
||||
},
|
||||
detectedQuery: buildSearchQuery(options, options.start, options.end),
|
||||
lineStats: {
|
||||
additions,
|
||||
complete: coveredCommits === shas.length,
|
||||
coveredCommits,
|
||||
deletions,
|
||||
missingCommits: shas.length - coveredCommits,
|
||||
totalChanges: additions + deletions,
|
||||
},
|
||||
range: {
|
||||
end: options.end.toISOString(),
|
||||
searchField: options.searchField,
|
||||
start: options.start.toISOString(),
|
||||
},
|
||||
filters: {
|
||||
excludedOwners: [...options.excludeOwners].sort(),
|
||||
},
|
||||
repos: {
|
||||
count: repoNames.size,
|
||||
sample: repoSample,
|
||||
},
|
||||
statsFetch: {
|
||||
fetchedThisRun,
|
||||
skipped: options.skipStats,
|
||||
},
|
||||
totals: {
|
||||
commits: shas.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function printSummary(summary: Summary) {
|
||||
console.log("Paperclip commit metrics");
|
||||
console.log(`Query: ${summary.detectedQuery}`);
|
||||
console.log(`Range: ${summary.range.start} -> ${summary.range.end} (${summary.range.searchField})`);
|
||||
if (summary.filters.excludedOwners.length > 0) {
|
||||
console.log(`Excluded owners: ${summary.filters.excludedOwners.join(", ")}`);
|
||||
}
|
||||
console.log(`Commits: ${summary.totals.commits}`);
|
||||
console.log(`Distinct repos: ${summary.repos.count}`);
|
||||
console.log(`Distinct contributors: ${summary.contributors.count}`);
|
||||
console.log(
|
||||
`Line stats: +${summary.lineStats.additions} / -${summary.lineStats.deletions} / ${summary.lineStats.totalChanges} total`,
|
||||
);
|
||||
console.log(
|
||||
`Line stat coverage: ${summary.lineStats.coveredCommits}/${summary.totals.commits}` +
|
||||
(summary.lineStats.complete ? " (complete)" : " (partial; rerun to hydrate more commits)"),
|
||||
);
|
||||
console.log(`Stats fetched this run: ${summary.statsFetch.fetchedThisRun}${summary.statsFetch.skipped ? " (skipped)" : ""}`);
|
||||
console.log(`Cache: ${summary.cacheFile}`);
|
||||
|
||||
if (summary.repos.sample.length > 0) {
|
||||
console.log(`Sample repos: ${summary.repos.sample.join(", ")}`);
|
||||
}
|
||||
if (summary.contributors.sample.length > 0) {
|
||||
console.log(
|
||||
`Sample contributors: ${summary.contributors.sample
|
||||
.map((contributor) => contributor.login ?? contributor.displayName)
|
||||
.join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function writeExport(
|
||||
outputPath: string,
|
||||
format: CliOptions["exportFormat"],
|
||||
cache: CacheFile,
|
||||
shas: string[],
|
||||
summary: Summary,
|
||||
): Promise<void> {
|
||||
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
||||
if (format === "json") {
|
||||
const report = {
|
||||
summary,
|
||||
commits: shas.map((sha) => buildExportRow(cache, sha)),
|
||||
};
|
||||
await fs.writeFile(outputPath, JSON.stringify(report, null, 2), "utf8");
|
||||
return;
|
||||
}
|
||||
|
||||
const header = [
|
||||
"committedAt",
|
||||
"repository",
|
||||
"repositoryUrl",
|
||||
"sha",
|
||||
"commitUrl",
|
||||
"authorLogin",
|
||||
"authorName",
|
||||
"authorEmail",
|
||||
"contributors",
|
||||
"additions",
|
||||
"deletions",
|
||||
"totalChanges",
|
||||
];
|
||||
const rows = [header.join(",")];
|
||||
for (const sha of shas) {
|
||||
const row = buildExportRow(cache, sha);
|
||||
rows.push(
|
||||
[
|
||||
row.committedAt,
|
||||
row.repository,
|
||||
row.repositoryUrl,
|
||||
row.sha,
|
||||
row.commitUrl,
|
||||
row.authorLogin,
|
||||
row.authorName,
|
||||
row.authorEmail,
|
||||
row.contributors,
|
||||
String(row.additions),
|
||||
String(row.deletions),
|
||||
String(row.totalChanges),
|
||||
]
|
||||
.map(escapeCsv)
|
||||
.join(","),
|
||||
);
|
||||
}
|
||||
await fs.writeFile(outputPath, `${rows.join("\n")}\n`, "utf8");
|
||||
}
|
||||
|
||||
function buildExportRow(cache: CacheFile, sha: string) {
|
||||
const commit = cache.commits[sha];
|
||||
if (!commit) {
|
||||
throw new Error(`Missing cached commit for sha ${sha}`);
|
||||
}
|
||||
const stats = cache.stats[sha];
|
||||
return {
|
||||
additions: stats?.additions ?? 0,
|
||||
authorEmail: commit.authorEmail ?? "",
|
||||
authorLogin: commit.authorLogin ?? "",
|
||||
authorName: commit.authorName ?? "",
|
||||
commitUrl: commit.htmlUrl,
|
||||
committedAt: commit.committedAt ?? "",
|
||||
contributors: commit.contributors.map((contributor) => contributor.login ?? contributor.displayName).join(" | "),
|
||||
deletions: stats?.deletions ?? 0,
|
||||
repository: commit.repositoryFullName,
|
||||
repositoryUrl: commit.repositoryUrl,
|
||||
sha: commit.sha,
|
||||
totalChanges: stats?.total ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function escapeCsv(value: string): string {
|
||||
if (value.includes(",") || value.includes("\"") || value.includes("\n")) {
|
||||
return `"${value.replaceAll("\"", "\"\"")}"`;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function makeWindowKey(start: Date, end: Date): string {
|
||||
return `${start.toISOString()}..${end.toISOString()}`;
|
||||
}
|
||||
|
||||
class GitHubClient {
|
||||
private readonly apiBase = "https://api.github.com";
|
||||
private readonly token: string;
|
||||
|
||||
constructor(token: string) {
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
async getJson<T>(pathname: string): Promise<T> {
|
||||
while (true) {
|
||||
const response = await fetch(`${this.apiBase}${pathname}`, {
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
"User-Agent": "paperclip-commit-metrics",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
const remaining = response.headers.get("x-ratelimit-remaining");
|
||||
const resetAt = response.headers.get("x-ratelimit-reset");
|
||||
if ((response.status === 403 || response.status === 429) && remaining === "0" && resetAt) {
|
||||
const waitMs = Math.max(Number.parseInt(resetAt, 10) * 1000 - Date.now() + 1_000, 1_000);
|
||||
console.error(`GitHub rate limit hit for ${pathname}; waiting ${Math.ceil(waitMs / 1000)}s...`);
|
||||
await sleep(waitMs);
|
||||
continue;
|
||||
}
|
||||
|
||||
const body = await response.text();
|
||||
throw new Error(`GitHub API request failed (${response.status}) for ${pathname}: ${body}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
Executable
+110
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
scripts/paperclip-issue-update.sh [--issue-id ID] [--status STATUS] [--comment TEXT] [--dry-run]
|
||||
|
||||
Reads a multiline markdown comment from stdin when stdin is piped. This preserves
|
||||
newlines when building the JSON payload for PATCH /api/issues/{issueId}.
|
||||
|
||||
Examples:
|
||||
scripts/paperclip-issue-update.sh --issue-id "$PAPERCLIP_TASK_ID" --status in_progress <<'MD'
|
||||
Investigating formatting
|
||||
|
||||
- Pulled the raw comment body
|
||||
- Comparing it with the run transcript
|
||||
MD
|
||||
|
||||
scripts/paperclip-issue-update.sh --issue-id "$PAPERCLIP_TASK_ID" --status done --dry-run <<'MD'
|
||||
Done
|
||||
|
||||
- Fixed the issue update helper
|
||||
MD
|
||||
EOF
|
||||
}
|
||||
|
||||
require_command() {
|
||||
if ! command -v "$1" >/dev/null 2>&1; then
|
||||
printf 'Missing required command: %s\n' "$1" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
issue_id="${PAPERCLIP_TASK_ID:-}"
|
||||
status=""
|
||||
comment_arg=""
|
||||
dry_run=0
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--issue-id)
|
||||
issue_id="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--status)
|
||||
status="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--comment)
|
||||
comment_arg="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--dry-run)
|
||||
dry_run=1
|
||||
shift
|
||||
;;
|
||||
--help|-h)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
printf 'Unknown argument: %s\n' "$1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$issue_id" ]]; then
|
||||
printf 'Missing issue id. Pass --issue-id or set PAPERCLIP_TASK_ID.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
comment=""
|
||||
if [[ -n "$comment_arg" ]]; then
|
||||
comment="$comment_arg"
|
||||
elif [[ ! -t 0 ]]; then
|
||||
comment="$(cat)"
|
||||
fi
|
||||
|
||||
require_command jq
|
||||
|
||||
payload="$(
|
||||
jq -nc \
|
||||
--arg status "$status" \
|
||||
--arg comment "$comment" \
|
||||
'
|
||||
(if $status == "" then {} else {status: $status} end) +
|
||||
(if $comment == "" then {} else {comment: $comment} end)
|
||||
'
|
||||
)"
|
||||
|
||||
if [[ "$dry_run" == "1" ]]; then
|
||||
printf '%s\n' "$payload"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ -z "${PAPERCLIP_API_URL:-}" || -z "${PAPERCLIP_API_KEY:-}" || -z "${PAPERCLIP_RUN_ID:-}" ]]; then
|
||||
printf 'Missing PAPERCLIP_API_URL, PAPERCLIP_API_KEY, or PAPERCLIP_RUN_ID.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
curl -sS -X PATCH \
|
||||
"$PAPERCLIP_API_URL/api/issues/$issue_id" \
|
||||
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data-binary "$payload"
|
||||
Executable
+34
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# prepare-server-ui-dist.sh — Build the UI and copy it into server/ui-dist.
|
||||
# This keeps @paperclipai/server publish artifacts self-contained for static UI serving.
|
||||
# When PAPERCLIP_RELEASE_REUSE_UI_DIST=1 and ui/dist already exists, reuse that
|
||||
# output instead of rebuilding it again inside the release packaging flow.
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
UI_DIST="$REPO_ROOT/ui/dist"
|
||||
SERVER_UI_DIST="$REPO_ROOT/server/ui-dist"
|
||||
|
||||
should_reuse_existing_ui_dist=false
|
||||
case "${PAPERCLIP_RELEASE_REUSE_UI_DIST:-}" in
|
||||
1|true|TRUE|yes|YES)
|
||||
should_reuse_existing_ui_dist=true
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "$should_reuse_existing_ui_dist" = true ] && [ -f "$UI_DIST/index.html" ]; then
|
||||
echo " -> Reusing existing @paperclipai/ui dist output"
|
||||
else
|
||||
echo " -> Building @paperclipai/ui..."
|
||||
pnpm --dir "$REPO_ROOT" --filter @paperclipai/ui build
|
||||
fi
|
||||
|
||||
if [ ! -f "$UI_DIST/index.html" ]; then
|
||||
echo "Error: UI build output missing at $UI_DIST/index.html"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rm -rf "$SERVER_UI_DIST"
|
||||
cp -r "$UI_DIST" "$SERVER_UI_DIST"
|
||||
echo " -> Copied ui/dist to server/ui-dist"
|
||||
@@ -0,0 +1,607 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
base_cwd="${PAPERCLIP_WORKSPACE_BASE_CWD:?PAPERCLIP_WORKSPACE_BASE_CWD is required}"
|
||||
worktree_cwd="${PAPERCLIP_WORKSPACE_CWD:?PAPERCLIP_WORKSPACE_CWD is required}"
|
||||
paperclip_home="${PAPERCLIP_HOME:-$HOME/.paperclip}"
|
||||
paperclip_instance_id="${PAPERCLIP_INSTANCE_ID:-default}"
|
||||
paperclip_dir="$worktree_cwd/.paperclip"
|
||||
worktree_config_path="$paperclip_dir/config.json"
|
||||
worktree_env_path="$paperclip_dir/.env"
|
||||
worktree_name="${PAPERCLIP_WORKSPACE_BRANCH:-$(basename "$worktree_cwd")}"
|
||||
|
||||
if [[ ! -d "$base_cwd" ]]; then
|
||||
echo "Base workspace does not exist: $base_cwd" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -d "$worktree_cwd" ]]; then
|
||||
echo "Derived worktree does not exist: $worktree_cwd" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
source_config_path="${PAPERCLIP_CONFIG:-}"
|
||||
if [[ -z "$source_config_path" && ( -e "$base_cwd/.paperclip/config.json" || -L "$base_cwd/.paperclip/config.json" ) ]]; then
|
||||
source_config_path="$base_cwd/.paperclip/config.json"
|
||||
fi
|
||||
if [[ -z "$source_config_path" ]]; then
|
||||
source_config_path="$paperclip_home/instances/$paperclip_instance_id/config.json"
|
||||
fi
|
||||
source_env_path="$(dirname "$source_config_path")/.env"
|
||||
|
||||
mkdir -p "$paperclip_dir"
|
||||
|
||||
run_isolated_worktree_init() {
|
||||
local base_cli_runner="$base_cwd/cli/node_modules/tsx/dist/cli.mjs"
|
||||
local base_cli_entry="$base_cwd/cli/src/index.ts"
|
||||
|
||||
if [[ -f "$base_cli_runner" && -f "$base_cli_entry" ]]; then
|
||||
(
|
||||
cd "$worktree_cwd"
|
||||
node "$base_cli_runner" "$base_cli_entry" worktree init --force --seed-mode minimal --name "$worktree_name" --from-config "$source_config_path"
|
||||
)
|
||||
return 0
|
||||
fi
|
||||
|
||||
if command -v pnpm >/dev/null 2>&1 && pnpm paperclipai --help >/dev/null 2>&1; then
|
||||
(
|
||||
cd "$worktree_cwd"
|
||||
pnpm paperclipai worktree init --force --seed-mode minimal --name "$worktree_name" --from-config "$source_config_path"
|
||||
)
|
||||
return 0
|
||||
fi
|
||||
|
||||
if command -v paperclipai >/dev/null 2>&1; then
|
||||
(
|
||||
cd "$worktree_cwd"
|
||||
paperclipai worktree init --force --seed-mode minimal --name "$worktree_name" --from-config "$source_config_path"
|
||||
)
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 127
|
||||
}
|
||||
|
||||
paperclipai_command_available() {
|
||||
if command -v pnpm >/dev/null 2>&1 && pnpm paperclipai --help >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local base_cli_tsx_path="$base_cwd/cli/node_modules/tsx/dist/cli.mjs"
|
||||
local base_cli_entry_path="$base_cwd/cli/src/index.ts"
|
||||
if command -v node >/dev/null 2>&1 && [[ -f "$base_cli_tsx_path" ]] && [[ -f "$base_cli_entry_path" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if command -v paperclipai >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
existing_worktree_config_is_usable() {
|
||||
WORKTREE_CONFIG_PATH="$worktree_config_path" \
|
||||
WORKTREE_ENV_PATH="$worktree_env_path" \
|
||||
node <<'EOF'
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
function expandHomePrefix(value) {
|
||||
if (!value) return value;
|
||||
if (value === "~") return os.homedir();
|
||||
if (value.startsWith("~/")) return path.resolve(os.homedir(), value.slice(2));
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseEnvFile(contents) {
|
||||
const entries = {};
|
||||
for (const rawLine of contents.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith("#")) continue;
|
||||
const match = rawLine.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/);
|
||||
if (!match) continue;
|
||||
const [, key, rawValue] = match;
|
||||
const value = rawValue.trim();
|
||||
if (
|
||||
(value.startsWith("\"") && value.endsWith("\"")) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
entries[key] = value.slice(1, -1);
|
||||
continue;
|
||||
}
|
||||
entries[key] = value.replace(/\s+#.*$/, "").trim();
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function fail(reason) {
|
||||
console.error(reason);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const configPath = path.resolve(process.env.WORKTREE_CONFIG_PATH);
|
||||
const envPath = path.resolve(process.env.WORKTREE_ENV_PATH);
|
||||
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
||||
const env = parseEnvFile(fs.readFileSync(envPath, "utf8"));
|
||||
const envConfigPath = expandHomePrefix(env.PAPERCLIP_CONFIG);
|
||||
if (envConfigPath && path.resolve(envConfigPath) !== configPath) {
|
||||
fail(`existing worktree env points at ${envConfigPath}, not ${configPath}`);
|
||||
}
|
||||
|
||||
const homeDir = expandHomePrefix(env.PAPERCLIP_HOME);
|
||||
const instanceId = env.PAPERCLIP_INSTANCE_ID;
|
||||
if (!homeDir || !instanceId) {
|
||||
fail("existing worktree env is missing PAPERCLIP_HOME or PAPERCLIP_INSTANCE_ID");
|
||||
}
|
||||
if (!fs.existsSync(homeDir)) {
|
||||
fail(`existing worktree home does not exist on this host: ${homeDir}`);
|
||||
}
|
||||
|
||||
const instanceRoot = path.resolve(homeDir, "instances", instanceId);
|
||||
const runtimePaths = [
|
||||
config.database?.embeddedPostgresDataDir,
|
||||
config.database?.backup?.dir,
|
||||
config.logging?.logDir,
|
||||
config.storage?.localDisk?.baseDir,
|
||||
config.secrets?.localEncrypted?.keyFilePath,
|
||||
].filter((value) => typeof value === "string" && value.length > 0);
|
||||
|
||||
for (const rawValue of runtimePaths) {
|
||||
const resolved = path.resolve(expandHomePrefix(rawValue));
|
||||
const relative = path.relative(instanceRoot, resolved);
|
||||
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
||||
fail(`existing worktree config path is outside ${instanceRoot}: ${resolved}`);
|
||||
}
|
||||
}
|
||||
EOF
|
||||
}
|
||||
|
||||
write_fallback_worktree_config() {
|
||||
WORKTREE_NAME="$worktree_name" \
|
||||
BASE_CWD="$base_cwd" \
|
||||
WORKTREE_CWD="$worktree_cwd" \
|
||||
PAPERCLIP_DIR="$paperclip_dir" \
|
||||
SOURCE_CONFIG_PATH="$source_config_path" \
|
||||
SOURCE_ENV_PATH="$source_env_path" \
|
||||
PAPERCLIP_WORKTREES_DIR="${PAPERCLIP_WORKTREES_DIR:-}" \
|
||||
node <<'EOF'
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const net = require("node:net");
|
||||
|
||||
function expandHomePrefix(value) {
|
||||
if (!value) return value;
|
||||
if (value === "~") return os.homedir();
|
||||
if (value.startsWith("~/")) return path.resolve(os.homedir(), value.slice(2));
|
||||
return value;
|
||||
}
|
||||
|
||||
function nonEmpty(value) {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function sanitizeInstanceId(value) {
|
||||
const trimmed = String(value ?? "").trim().toLowerCase();
|
||||
const normalized = trimmed
|
||||
.replace(/[^a-z0-9_-]+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^[-_]+|[-_]+$/g, "");
|
||||
return normalized || "worktree";
|
||||
}
|
||||
|
||||
function parseEnvFile(contents) {
|
||||
const entries = {};
|
||||
for (const rawLine of contents.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith("#")) continue;
|
||||
const match = rawLine.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/);
|
||||
if (!match) continue;
|
||||
const [, key, rawValue] = match;
|
||||
const value = rawValue.trim();
|
||||
if (!value) {
|
||||
entries[key] = "";
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
(value.startsWith("\"") && value.endsWith("\"")) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
entries[key] = value.slice(1, -1);
|
||||
continue;
|
||||
}
|
||||
entries[key] = value.replace(/\s+#.*$/, "").trim();
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function findAvailablePort(preferredPort, reserved = new Set()) {
|
||||
const startPort = Number.isFinite(preferredPort) && preferredPort > 0 ? Math.trunc(preferredPort) : 0;
|
||||
if (startPort > 0) {
|
||||
for (let port = startPort; port < startPort + 100; port += 1) {
|
||||
if (reserved.has(port)) continue;
|
||||
const available = await new Promise((resolve) => {
|
||||
const server = net.createServer();
|
||||
server.unref();
|
||||
server.once("error", () => resolve(false));
|
||||
server.listen(port, "127.0.0.1", () => {
|
||||
server.close(() => resolve(true));
|
||||
});
|
||||
});
|
||||
if (available) return port;
|
||||
}
|
||||
}
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.unref();
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
server.close(() => reject(new Error("Failed to allocate a port.")));
|
||||
return;
|
||||
}
|
||||
const port = address.port;
|
||||
server.close(() => resolve(port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function isLoopbackHost(hostname) {
|
||||
const value = hostname.trim().toLowerCase();
|
||||
return value === "127.0.0.1" || value === "localhost" || value === "::1";
|
||||
}
|
||||
|
||||
function rewriteLocalUrlPort(rawUrl, port) {
|
||||
if (!rawUrl) return undefined;
|
||||
try {
|
||||
const parsed = new URL(rawUrl);
|
||||
if (!isLoopbackHost(parsed.hostname)) return rawUrl;
|
||||
parsed.port = String(port);
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return rawUrl;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRuntimeLikePath(value, configPath) {
|
||||
const expanded = expandHomePrefix(value);
|
||||
if (path.isAbsolute(expanded)) return expanded;
|
||||
return path.resolve(path.dirname(configPath), expanded);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const worktreeName = process.env.WORKTREE_NAME;
|
||||
const paperclipDir = process.env.PAPERCLIP_DIR;
|
||||
const sourceConfigPath = process.env.SOURCE_CONFIG_PATH;
|
||||
const sourceEnvPath = process.env.SOURCE_ENV_PATH;
|
||||
const worktreeHome = path.resolve(expandHomePrefix(nonEmpty(process.env.PAPERCLIP_WORKTREES_DIR) ?? "~/.paperclip-worktrees"));
|
||||
const instanceId = sanitizeInstanceId(worktreeName);
|
||||
const instanceRoot = path.resolve(worktreeHome, "instances", instanceId);
|
||||
const configPath = path.resolve(paperclipDir, "config.json");
|
||||
const envPath = path.resolve(paperclipDir, ".env");
|
||||
|
||||
let sourceConfig = null;
|
||||
if (sourceConfigPath && fs.existsSync(sourceConfigPath)) {
|
||||
sourceConfig = JSON.parse(fs.readFileSync(sourceConfigPath, "utf8"));
|
||||
}
|
||||
|
||||
const sourceEnvEntries =
|
||||
sourceEnvPath && fs.existsSync(sourceEnvPath)
|
||||
? parseEnvFile(fs.readFileSync(sourceEnvPath, "utf8"))
|
||||
: {};
|
||||
|
||||
const preferredServerPort = Number(sourceConfig?.server?.port ?? 3101) + 1;
|
||||
const serverPort = await findAvailablePort(preferredServerPort);
|
||||
const preferredDbPort = Number(sourceConfig?.database?.embeddedPostgresPort ?? 54329) + 1;
|
||||
const databasePort = await findAvailablePort(preferredDbPort, new Set([serverPort]));
|
||||
|
||||
fs.rmSync(configPath, { force: true });
|
||||
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
||||
fs.mkdirSync(instanceRoot, { recursive: true });
|
||||
|
||||
const authPublicBaseUrl = rewriteLocalUrlPort(sourceConfig?.auth?.publicBaseUrl, serverPort);
|
||||
const targetConfig = {
|
||||
$meta: {
|
||||
version: 1,
|
||||
updatedAt: new Date().toISOString(),
|
||||
source: "configure",
|
||||
},
|
||||
...(sourceConfig?.llm ? { llm: sourceConfig.llm } : {}),
|
||||
database: {
|
||||
mode: "embedded-postgres",
|
||||
embeddedPostgresDataDir: path.resolve(instanceRoot, "db"),
|
||||
embeddedPostgresPort: databasePort,
|
||||
backup: {
|
||||
enabled: sourceConfig?.database?.backup?.enabled ?? true,
|
||||
intervalMinutes: sourceConfig?.database?.backup?.intervalMinutes ?? 60,
|
||||
retentionDays: sourceConfig?.database?.backup?.retentionDays ?? 30,
|
||||
dir: path.resolve(instanceRoot, "data", "backups"),
|
||||
},
|
||||
},
|
||||
logging: {
|
||||
mode: sourceConfig?.logging?.mode ?? "file",
|
||||
logDir: path.resolve(instanceRoot, "logs"),
|
||||
},
|
||||
server: {
|
||||
deploymentMode: sourceConfig?.server?.deploymentMode ?? "local_trusted",
|
||||
exposure: sourceConfig?.server?.exposure ?? "private",
|
||||
...(sourceConfig?.server?.bind ? { bind: sourceConfig.server.bind } : {}),
|
||||
...(sourceConfig?.server?.customBindHost ? { customBindHost: sourceConfig.server.customBindHost } : {}),
|
||||
host: sourceConfig?.server?.host ?? "127.0.0.1",
|
||||
port: serverPort,
|
||||
allowedHostnames: sourceConfig?.server?.allowedHostnames ?? [],
|
||||
serveUi: sourceConfig?.server?.serveUi ?? true,
|
||||
},
|
||||
auth: {
|
||||
baseUrlMode: sourceConfig?.auth?.baseUrlMode ?? "auto",
|
||||
...(authPublicBaseUrl ? { publicBaseUrl: authPublicBaseUrl } : {}),
|
||||
disableSignUp: sourceConfig?.auth?.disableSignUp ?? false,
|
||||
},
|
||||
storage: {
|
||||
provider: sourceConfig?.storage?.provider ?? "local_disk",
|
||||
localDisk: {
|
||||
baseDir: path.resolve(instanceRoot, "data", "storage"),
|
||||
},
|
||||
s3: {
|
||||
bucket: sourceConfig?.storage?.s3?.bucket ?? "paperclip",
|
||||
region: sourceConfig?.storage?.s3?.region ?? "us-east-1",
|
||||
endpoint: sourceConfig?.storage?.s3?.endpoint,
|
||||
prefix: sourceConfig?.storage?.s3?.prefix ?? "",
|
||||
forcePathStyle: sourceConfig?.storage?.s3?.forcePathStyle ?? false,
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
provider: sourceConfig?.secrets?.provider ?? "local_encrypted",
|
||||
strictMode: sourceConfig?.secrets?.strictMode ?? false,
|
||||
localEncrypted: {
|
||||
keyFilePath: path.resolve(instanceRoot, "secrets", "master.key"),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
fs.writeFileSync(configPath, `${JSON.stringify(targetConfig, null, 2)}\n`, { mode: 0o600 });
|
||||
|
||||
const inlineMasterKey = nonEmpty(sourceEnvEntries.PAPERCLIP_SECRETS_MASTER_KEY);
|
||||
if (inlineMasterKey) {
|
||||
fs.mkdirSync(path.resolve(instanceRoot, "secrets"), { recursive: true });
|
||||
fs.writeFileSync(targetConfig.secrets.localEncrypted.keyFilePath, inlineMasterKey, {
|
||||
encoding: "utf8",
|
||||
mode: 0o600,
|
||||
});
|
||||
} else {
|
||||
const sourceKeyFilePath = nonEmpty(sourceEnvEntries.PAPERCLIP_SECRETS_MASTER_KEY_FILE)
|
||||
? resolveRuntimeLikePath(sourceEnvEntries.PAPERCLIP_SECRETS_MASTER_KEY_FILE, sourceConfigPath)
|
||||
: nonEmpty(sourceConfig?.secrets?.localEncrypted?.keyFilePath)
|
||||
? resolveRuntimeLikePath(sourceConfig.secrets.localEncrypted.keyFilePath, sourceConfigPath)
|
||||
: null;
|
||||
|
||||
if (sourceKeyFilePath && fs.existsSync(sourceKeyFilePath)) {
|
||||
fs.mkdirSync(path.resolve(instanceRoot, "secrets"), { recursive: true });
|
||||
fs.copyFileSync(sourceKeyFilePath, targetConfig.secrets.localEncrypted.keyFilePath);
|
||||
fs.chmodSync(targetConfig.secrets.localEncrypted.keyFilePath, 0o600);
|
||||
}
|
||||
}
|
||||
|
||||
const envLines = [
|
||||
"PAPERCLIP_HOME=" + JSON.stringify(worktreeHome),
|
||||
"PAPERCLIP_INSTANCE_ID=" + JSON.stringify(instanceId),
|
||||
"PAPERCLIP_CONFIG=" + JSON.stringify(configPath),
|
||||
"PAPERCLIP_CONTEXT=" + JSON.stringify(path.resolve(worktreeHome, "context.json")),
|
||||
"PAPERCLIP_IN_WORKTREE=true",
|
||||
"PAPERCLIP_WORKTREE_NAME=" + JSON.stringify(worktreeName),
|
||||
];
|
||||
|
||||
const agentJwtSecret = nonEmpty(sourceEnvEntries.PAPERCLIP_AGENT_JWT_SECRET);
|
||||
if (agentJwtSecret) {
|
||||
envLines.push("PAPERCLIP_AGENT_JWT_SECRET=" + JSON.stringify(agentJwtSecret));
|
||||
}
|
||||
|
||||
fs.writeFileSync(envPath, `${envLines.join("\n")}\n`, { mode: 0o600 });
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
EOF
|
||||
}
|
||||
|
||||
if [[ -e "$worktree_config_path" && -e "$worktree_env_path" ]] && existing_worktree_config_is_usable; then
|
||||
echo "Reusing existing isolated Paperclip worktree config at $worktree_config_path" >&2
|
||||
else
|
||||
if [[ -e "$worktree_config_path" || -e "$worktree_env_path" ]]; then
|
||||
echo "Existing isolated Paperclip worktree config is stale for this host; regenerating." >&2
|
||||
fi
|
||||
if paperclipai_command_available; then
|
||||
run_isolated_worktree_init
|
||||
else
|
||||
echo "paperclipai CLI not available in this workspace; writing isolated fallback config without DB seeding." >&2
|
||||
write_fallback_worktree_config
|
||||
fi
|
||||
fi
|
||||
|
||||
list_base_node_modules_paths() {
|
||||
cd "$base_cwd" &&
|
||||
find . \
|
||||
-mindepth 1 \
|
||||
-maxdepth 4 \
|
||||
-type d \
|
||||
-name node_modules \
|
||||
! -path './.git/*' \
|
||||
! -path './.paperclip/*' \
|
||||
| sed 's#^\./##'
|
||||
}
|
||||
|
||||
compute_pnpm_install_fingerprint() {
|
||||
WORKTREE_CWD="$worktree_cwd" node <<'EOF'
|
||||
const crypto = require("node:crypto");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const root = process.env.WORKTREE_CWD;
|
||||
const ignoredDirs = new Set([".git", ".paperclip", "node_modules", "dist", "storybook-static"]);
|
||||
const files = [];
|
||||
|
||||
function walk(dir) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (ignoredDirs.has(entry.name)) continue;
|
||||
|
||||
const absolutePath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
walk(absolutePath);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
entry.isFile()
|
||||
&& (entry.name === "package.json" || entry.name === "pnpm-lock.yaml" || entry.name === "pnpm-workspace.yaml")
|
||||
) {
|
||||
files.push(absolutePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(root);
|
||||
files.sort((left, right) => path.relative(root, left).localeCompare(path.relative(root, right)));
|
||||
|
||||
const hash = crypto.createHash("sha256");
|
||||
for (const file of files) {
|
||||
const relativePath = path.relative(root, file).replaceAll(path.sep, "/");
|
||||
hash.update(relativePath);
|
||||
hash.update("\0");
|
||||
hash.update(fs.readFileSync(file));
|
||||
hash.update("\0");
|
||||
}
|
||||
|
||||
process.stdout.write(hash.digest("hex"));
|
||||
EOF
|
||||
}
|
||||
|
||||
if [[ -f "$worktree_cwd/package.json" && -f "$worktree_cwd/pnpm-lock.yaml" ]]; then
|
||||
needs_install=0
|
||||
install_fingerprint_path="$paperclip_dir/pnpm-install-fingerprint"
|
||||
current_install_fingerprint="$(compute_pnpm_install_fingerprint)"
|
||||
previous_install_fingerprint=""
|
||||
if [[ -f "$install_fingerprint_path" ]]; then
|
||||
previous_install_fingerprint="$(cat "$install_fingerprint_path")"
|
||||
fi
|
||||
|
||||
while IFS= read -r relative_path; do
|
||||
[[ -n "$relative_path" ]] || continue
|
||||
target_path="$worktree_cwd/$relative_path"
|
||||
|
||||
if [[ -L "$target_path" || ! -e "$target_path" ]]; then
|
||||
needs_install=1
|
||||
break
|
||||
fi
|
||||
done < <(list_base_node_modules_paths)
|
||||
|
||||
if [[ "$needs_install" -eq 0 && "$current_install_fingerprint" != "$previous_install_fingerprint" ]]; then
|
||||
needs_install=1
|
||||
fi
|
||||
|
||||
if [[ "$needs_install" -eq 1 ]]; then
|
||||
backup_suffix=".paperclip-backup-${BASHPID:-$$}"
|
||||
moved_symlink_paths=()
|
||||
|
||||
while IFS= read -r relative_path; do
|
||||
[[ -n "$relative_path" ]] || continue
|
||||
target_path="$worktree_cwd/$relative_path"
|
||||
if [[ -L "$target_path" ]]; then
|
||||
backup_path="${target_path}${backup_suffix}"
|
||||
rm -rf "$backup_path"
|
||||
mv "$target_path" "$backup_path"
|
||||
moved_symlink_paths+=("$relative_path")
|
||||
fi
|
||||
done < <(list_base_node_modules_paths)
|
||||
|
||||
restore_moved_symlinks() {
|
||||
local relative_path target_path backup_path
|
||||
[[ ${#moved_symlink_paths[@]} -gt 0 ]] || return 0
|
||||
for relative_path in "${moved_symlink_paths[@]}"; do
|
||||
target_path="$worktree_cwd/$relative_path"
|
||||
backup_path="${target_path}${backup_suffix}"
|
||||
[[ -L "$backup_path" ]] || continue
|
||||
rm -rf "$target_path"
|
||||
mv "$backup_path" "$target_path"
|
||||
done
|
||||
}
|
||||
|
||||
cleanup_moved_symlinks() {
|
||||
local relative_path target_path backup_path
|
||||
[[ ${#moved_symlink_paths[@]} -gt 0 ]] || return 0
|
||||
for relative_path in "${moved_symlink_paths[@]}"; do
|
||||
target_path="$worktree_cwd/$relative_path"
|
||||
backup_path="${target_path}${backup_suffix}"
|
||||
[[ -L "$backup_path" ]] && rm "$backup_path"
|
||||
done
|
||||
}
|
||||
|
||||
run_pnpm_install() {
|
||||
local stdout_path stderr_path
|
||||
stdout_path="$(mktemp)"
|
||||
stderr_path="$(mktemp)"
|
||||
|
||||
if (
|
||||
cd "$worktree_cwd"
|
||||
pnpm install --prod=false "$@"
|
||||
) >"$stdout_path" 2>"$stderr_path"; then
|
||||
cat "$stdout_path"
|
||||
cat "$stderr_path" >&2
|
||||
rm -f "$stdout_path" "$stderr_path"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local exit_code=$?
|
||||
cat "$stdout_path"
|
||||
cat "$stderr_path" >&2
|
||||
if grep -q "ERR_PNPM_OUTDATED_LOCKFILE" "$stdout_path" "$stderr_path"; then
|
||||
rm -f "$stdout_path" "$stderr_path"
|
||||
return 90
|
||||
fi
|
||||
|
||||
rm -f "$stdout_path" "$stderr_path"
|
||||
return "$exit_code"
|
||||
}
|
||||
|
||||
if run_pnpm_install --frozen-lockfile; then
|
||||
:
|
||||
else
|
||||
install_exit_code=$?
|
||||
if [[ "$install_exit_code" -eq 90 ]]; then
|
||||
echo "pnpm-lock.yaml is out of date in this execution workspace; retrying install without --frozen-lockfile." >&2
|
||||
run_pnpm_install --no-frozen-lockfile || {
|
||||
restore_moved_symlinks
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
restore_moved_symlinks
|
||||
exit "$install_exit_code"
|
||||
fi
|
||||
fi
|
||||
|
||||
cleanup_moved_symlinks
|
||||
current_install_fingerprint="$(compute_pnpm_install_fingerprint)"
|
||||
printf '%s\n' "$current_install_fingerprint" >"$install_fingerprint_path"
|
||||
fi
|
||||
|
||||
exit 0
|
||||
fi
|
||||
|
||||
while IFS= read -r relative_path; do
|
||||
[[ -n "$relative_path" ]] || continue
|
||||
source_path="$base_cwd/$relative_path"
|
||||
target_path="$worktree_cwd/$relative_path"
|
||||
|
||||
[[ -d "$source_path" ]] || continue
|
||||
[[ -e "$target_path" || -L "$target_path" ]] && continue
|
||||
|
||||
mkdir -p "$(dirname "$target_path")"
|
||||
ln -s "$source_path" "$target_path"
|
||||
done < <(
|
||||
list_base_node_modules_paths
|
||||
)
|
||||
@@ -0,0 +1,419 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
if [ -z "${REPO_ROOT:-}" ]; then
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
fi
|
||||
|
||||
release_info() {
|
||||
echo "$@"
|
||||
}
|
||||
|
||||
release_warn() {
|
||||
echo "Warning: $*" >&2
|
||||
}
|
||||
|
||||
release_fail() {
|
||||
echo "Error: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
git_remote_exists() {
|
||||
git -C "$REPO_ROOT" remote get-url "$1" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
github_repo_from_remote() {
|
||||
local remote_url
|
||||
|
||||
remote_url="$(git -C "$REPO_ROOT" remote get-url "$1" 2>/dev/null || true)"
|
||||
[ -n "$remote_url" ] || return 1
|
||||
|
||||
remote_url="${remote_url%.git}"
|
||||
remote_url="${remote_url#ssh://}"
|
||||
|
||||
node - "$remote_url" <<'NODE'
|
||||
const remoteUrl = process.argv[2];
|
||||
|
||||
const patterns = [
|
||||
/^https?:\/\/github\.com\/([^/]+\/[^/]+)$/,
|
||||
/^git@github\.com:([^/]+\/[^/]+)$/,
|
||||
/^[^:]+:([^/]+\/[^/]+)$/
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = remoteUrl.match(pattern);
|
||||
if (!match) continue;
|
||||
process.stdout.write(match[1]);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.exit(1);
|
||||
NODE
|
||||
}
|
||||
|
||||
resolve_release_remote() {
|
||||
local remote="${RELEASE_REMOTE:-${PUBLISH_REMOTE:-}}"
|
||||
|
||||
if [ -n "$remote" ]; then
|
||||
git_remote_exists "$remote" || release_fail "git remote '$remote' does not exist."
|
||||
printf '%s\n' "$remote"
|
||||
return
|
||||
fi
|
||||
|
||||
if git_remote_exists public-gh; then
|
||||
printf 'public-gh\n'
|
||||
return
|
||||
fi
|
||||
|
||||
if git_remote_exists public; then
|
||||
printf 'public\n'
|
||||
return
|
||||
fi
|
||||
|
||||
if git_remote_exists origin; then
|
||||
printf 'origin\n'
|
||||
return
|
||||
fi
|
||||
|
||||
release_fail "no git remote found. Configure RELEASE_REMOTE or PUBLISH_REMOTE."
|
||||
}
|
||||
|
||||
fetch_release_remote() {
|
||||
git -C "$REPO_ROOT" fetch "$1" --prune --tags
|
||||
}
|
||||
|
||||
git_current_branch() {
|
||||
git -C "$REPO_ROOT" symbolic-ref --quiet --short HEAD 2>/dev/null || true
|
||||
}
|
||||
|
||||
git_local_tag_exists() {
|
||||
git -C "$REPO_ROOT" show-ref --verify --quiet "refs/tags/$1"
|
||||
}
|
||||
|
||||
git_remote_tag_exists() {
|
||||
git -C "$REPO_ROOT" ls-remote --exit-code --tags "$2" "refs/tags/$1" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
get_last_stable_tag() {
|
||||
git -C "$REPO_ROOT" tag --list 'v*' --sort=-version:refname | head -1
|
||||
}
|
||||
|
||||
get_current_stable_version() {
|
||||
local tag
|
||||
tag="$(get_last_stable_tag)"
|
||||
if [ -z "$tag" ]; then
|
||||
printf '0.0.0\n'
|
||||
else
|
||||
printf '%s\n' "${tag#v}"
|
||||
fi
|
||||
}
|
||||
|
||||
stable_version_slot_for_date() {
|
||||
node - "${1:-}" <<'NODE'
|
||||
const input = process.argv[2];
|
||||
|
||||
const date = input ? new Date(`${input}T00:00:00Z`) : new Date();
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
console.error(`invalid date: ${input}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const month = String(date.getUTCMonth() + 1);
|
||||
const day = String(date.getUTCDate()).padStart(2, '0');
|
||||
|
||||
process.stdout.write(`${date.getUTCFullYear()}.${month}${day}`);
|
||||
NODE
|
||||
}
|
||||
|
||||
utc_date_iso() {
|
||||
node <<'NODE'
|
||||
const date = new Date();
|
||||
const y = date.getUTCFullYear();
|
||||
const m = String(date.getUTCMonth() + 1).padStart(2, '0');
|
||||
const d = String(date.getUTCDate()).padStart(2, '0');
|
||||
process.stdout.write(`${y}-${m}-${d}`);
|
||||
NODE
|
||||
}
|
||||
|
||||
next_stable_version() {
|
||||
local release_date="$1"
|
||||
shift
|
||||
|
||||
node - "$release_date" "$@" <<'NODE'
|
||||
const input = process.argv[2];
|
||||
const packageNames = process.argv.slice(3);
|
||||
const { execSync } = require("node:child_process");
|
||||
const { readFileSync } = require("node:fs");
|
||||
|
||||
const date = input ? new Date(`${input}T00:00:00Z`) : new Date();
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
console.error(`invalid date: ${input}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Optional pre-fetched version data (see release-registry-versions.mjs).
|
||||
// Avoids one serial `npm view` round-trip per package.
|
||||
let versionsCache = null;
|
||||
if (process.env.RELEASE_PACKAGE_VERSIONS_FILE) {
|
||||
try {
|
||||
versionsCache = JSON.parse(readFileSync(process.env.RELEASE_PACKAGE_VERSIONS_FILE, "utf8"));
|
||||
} catch {
|
||||
versionsCache = null;
|
||||
}
|
||||
}
|
||||
|
||||
const stableSlot = `${date.getUTCFullYear()}.${date.getUTCMonth() + 1}${String(date.getUTCDate()).padStart(2, "0")}`;
|
||||
const pattern = new RegExp(`^${stableSlot.replace(/\./g, '\\.')}\.(\\d+)$`);
|
||||
let max = -1;
|
||||
|
||||
for (const packageName of packageNames) {
|
||||
let versions = [];
|
||||
|
||||
if (versionsCache && Array.isArray(versionsCache[packageName])) {
|
||||
versions = versionsCache[packageName];
|
||||
} else {
|
||||
try {
|
||||
const raw = execSync(`npm view ${JSON.stringify(packageName)} versions --json`, {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
versions = Array.isArray(parsed) ? parsed : [parsed];
|
||||
}
|
||||
} catch {
|
||||
versions = [];
|
||||
}
|
||||
}
|
||||
|
||||
for (const version of versions) {
|
||||
const match = version.match(pattern);
|
||||
if (!match) continue;
|
||||
max = Math.max(max, Number(match[1]));
|
||||
}
|
||||
}
|
||||
|
||||
process.stdout.write(`${stableSlot}.${max + 1}`);
|
||||
NODE
|
||||
}
|
||||
|
||||
next_canary_version() {
|
||||
local stable_version="$1"
|
||||
shift
|
||||
|
||||
node - "$stable_version" "$@" <<'NODE'
|
||||
const stable = process.argv[2];
|
||||
const packageNames = process.argv.slice(3);
|
||||
const { execSync } = require("node:child_process");
|
||||
const { readFileSync } = require("node:fs");
|
||||
|
||||
// Optional pre-fetched version data (see release-registry-versions.mjs).
|
||||
// Avoids one serial `npm view` round-trip per package.
|
||||
let versionsCache = null;
|
||||
if (process.env.RELEASE_PACKAGE_VERSIONS_FILE) {
|
||||
try {
|
||||
versionsCache = JSON.parse(readFileSync(process.env.RELEASE_PACKAGE_VERSIONS_FILE, "utf8"));
|
||||
} catch {
|
||||
versionsCache = null;
|
||||
}
|
||||
}
|
||||
|
||||
const pattern = new RegExp(`^${stable.replace(/\./g, '\\.')}-canary\\.(\\d+)$`);
|
||||
let max = -1;
|
||||
|
||||
for (const packageName of packageNames) {
|
||||
let versions = [];
|
||||
|
||||
if (versionsCache && Array.isArray(versionsCache[packageName])) {
|
||||
versions = versionsCache[packageName];
|
||||
} else {
|
||||
try {
|
||||
const raw = execSync(`npm view ${JSON.stringify(packageName)} versions --json`, {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
versions = Array.isArray(parsed) ? parsed : [parsed];
|
||||
}
|
||||
} catch {
|
||||
versions = [];
|
||||
}
|
||||
}
|
||||
|
||||
for (const version of versions) {
|
||||
const match = version.match(pattern);
|
||||
if (!match) continue;
|
||||
max = Math.max(max, Number(match[1]));
|
||||
}
|
||||
}
|
||||
|
||||
process.stdout.write(`${stable}-canary.${max + 1}`);
|
||||
NODE
|
||||
}
|
||||
|
||||
release_notes_file() {
|
||||
printf '%s/releases/v%s.md\n' "$REPO_ROOT" "$1"
|
||||
}
|
||||
|
||||
stable_tag_name() {
|
||||
printf 'v%s\n' "$1"
|
||||
}
|
||||
|
||||
canary_tag_name() {
|
||||
printf 'canary/v%s\n' "$1"
|
||||
}
|
||||
|
||||
npm_package_version_exists() {
|
||||
local package_name="$1"
|
||||
local version="$2"
|
||||
local resolved
|
||||
|
||||
resolved="$(npm view "${package_name}@${version}" version 2>/dev/null || true)"
|
||||
[ "$resolved" = "$version" ]
|
||||
}
|
||||
|
||||
wait_for_npm_package_version() {
|
||||
local package_name="$1"
|
||||
local version="$2"
|
||||
local attempts="${3:-12}"
|
||||
local delay_seconds="${4:-5}"
|
||||
local attempt=1
|
||||
|
||||
while [ "$attempt" -le "$attempts" ]; do
|
||||
if npm_package_version_exists "$package_name" "$version"; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "$attempt" -lt "$attempts" ]; then
|
||||
sleep "$delay_seconds"
|
||||
fi
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
is_npm_tlog_duplicate_error() {
|
||||
local output="$1"
|
||||
|
||||
grep -q "TLOG_CREATE_ENTRY_ERROR" <<< "$output" &&
|
||||
grep -q "equivalent entry already exists in the transparency log" <<< "$output"
|
||||
}
|
||||
|
||||
publish_package_to_npm() {
|
||||
local dist_tag="$1"
|
||||
local package_name="$2"
|
||||
local package_version="$3"
|
||||
local publish_log
|
||||
|
||||
publish_log="$(mktemp "${TMPDIR:-/tmp}/paperclip-npm-publish.XXXXXX")"
|
||||
|
||||
if (set -o pipefail; pnpm publish --no-git-checks --tag "$dist_tag" --access public 2>&1 | tee "$publish_log"); then
|
||||
rm -f "$publish_log"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! is_npm_tlog_duplicate_error "$(cat "$publish_log")"; then
|
||||
rm -f "$publish_log"
|
||||
return 1
|
||||
fi
|
||||
|
||||
release_warn "npm publish hit a duplicate Sigstore transparency-log entry for ${package_name}@${package_version}."
|
||||
|
||||
if npm_package_version_exists "$package_name" "$package_version"; then
|
||||
release_warn "npm already exposes ${package_name}@${package_version}; continuing to registry verification."
|
||||
rm -f "$publish_log"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "$dist_tag" != "canary" ]; then
|
||||
release_warn "Not retrying ${package_name}@${package_version} without provenance for dist-tag ${dist_tag}."
|
||||
rm -f "$publish_log"
|
||||
return 1
|
||||
fi
|
||||
|
||||
release_warn "Retrying ${package_name}@${package_version} once with npm provenance disabled."
|
||||
if pnpm publish --no-git-checks --tag "$dist_tag" --access public --provenance=false; then
|
||||
rm -f "$publish_log"
|
||||
return 0
|
||||
fi
|
||||
|
||||
rm -f "$publish_log"
|
||||
return 1
|
||||
}
|
||||
|
||||
wait_for_release_registry_state() {
|
||||
local attempts="${1:-12}"
|
||||
local delay_seconds="${2:-5}"
|
||||
shift 2
|
||||
local attempt=1
|
||||
local output
|
||||
local status
|
||||
|
||||
while [ "$attempt" -le "$attempts" ]; do
|
||||
if output="$(node "$REPO_ROOT/scripts/verify-release-registry-state.mjs" "$@" 2>&1)"; then
|
||||
[ -n "$output" ] && printf '%s\n' "$output"
|
||||
return 0
|
||||
fi
|
||||
status=$?
|
||||
|
||||
printf '%s\n' "$output" >&2
|
||||
|
||||
if [ "$status" -eq 2 ]; then
|
||||
return "$status"
|
||||
fi
|
||||
|
||||
if [ "$attempt" -lt "$attempts" ]; then
|
||||
release_warn "npm registry metadata has not converged yet (attempt ${attempt}/${attempts}); retrying in ${delay_seconds}s."
|
||||
sleep "$delay_seconds"
|
||||
fi
|
||||
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
|
||||
return "${status:-1}"
|
||||
}
|
||||
|
||||
require_clean_worktree() {
|
||||
if [ -n "$(git -C "$REPO_ROOT" status --porcelain)" ]; then
|
||||
release_fail "working tree is not clean. Commit, stash, or remove changes before releasing."
|
||||
fi
|
||||
}
|
||||
|
||||
require_on_master_branch() {
|
||||
local current_branch
|
||||
current_branch="$(git_current_branch)"
|
||||
if [ "$current_branch" != "master" ]; then
|
||||
release_fail "this release step must run from branch master, but current branch is ${current_branch:-<detached>}."
|
||||
fi
|
||||
}
|
||||
|
||||
require_npm_publish_auth() {
|
||||
local dry_run="$1"
|
||||
|
||||
if [ "$dry_run" = true ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
if npm whoami >/dev/null 2>&1; then
|
||||
release_info " ✓ Logged in to npm as $(npm whoami)"
|
||||
return
|
||||
fi
|
||||
|
||||
if [ "${GITHUB_ACTIONS:-}" = "true" ]; then
|
||||
release_info " ✓ npm publish auth will be provided by GitHub Actions trusted publishing"
|
||||
return
|
||||
fi
|
||||
|
||||
release_fail "npm publish auth is not available. Use 'npm login' locally or run from GitHub Actions with trusted publishing."
|
||||
}
|
||||
|
||||
list_public_package_info() {
|
||||
node "$REPO_ROOT/scripts/release-package-map.mjs" list
|
||||
}
|
||||
|
||||
set_public_package_version() {
|
||||
node "$REPO_ROOT/scripts/release-package-map.mjs" set-version "$1"
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
const repoRoot = new URL("..", import.meta.url).pathname.replace(/\/$/, "");
|
||||
|
||||
function writeExecutable(path, body) {
|
||||
writeFileSync(path, body, { mode: 0o755 });
|
||||
}
|
||||
|
||||
function runPublishHelper({ pnpmMode, npmVersionExists = false, distTag = "canary", callerPipefail = true }) {
|
||||
const fixtureDir = mkdtempSync(join(tmpdir(), "paperclip-release-lib-"));
|
||||
const binDir = join(fixtureDir, "bin");
|
||||
const stateDir = join(fixtureDir, "state");
|
||||
const callLog = join(fixtureDir, "calls.log");
|
||||
mkdirSync(binDir);
|
||||
mkdirSync(stateDir);
|
||||
writeFileSync(callLog, "");
|
||||
|
||||
writeExecutable(
|
||||
join(binDir, "pnpm"),
|
||||
`#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
printf 'pnpm %s\\n' "$*" >> "$FAKE_CALL_LOG"
|
||||
case "$PNPM_MODE" in
|
||||
success)
|
||||
echo "published"
|
||||
exit 0
|
||||
;;
|
||||
tlog-then-success)
|
||||
if [ ! -f "$FAKE_STATE_DIR/pnpm-called" ]; then
|
||||
touch "$FAKE_STATE_DIR/pnpm-called"
|
||||
echo "npm error code TLOG_CREATE_ENTRY_ERROR"
|
||||
echo "npm error error creating tlog entry - (409) an equivalent entry already exists in the transparency log with UUID abc"
|
||||
exit 1
|
||||
fi
|
||||
case " $* " in
|
||||
*" --provenance=false "*)
|
||||
echo "published without provenance"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "retry did not disable provenance"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
tlog-always-fails)
|
||||
echo "npm error code TLOG_CREATE_ENTRY_ERROR"
|
||||
echo "npm error error creating tlog entry - (409) an equivalent entry already exists in the transparency log with UUID abc"
|
||||
exit 1
|
||||
;;
|
||||
non-tlog-failure)
|
||||
echo "npm error code E500"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
exit 1
|
||||
`,
|
||||
);
|
||||
|
||||
writeExecutable(
|
||||
join(binDir, "npm"),
|
||||
`#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
printf 'npm %s\\n' "$*" >> "$FAKE_CALL_LOG"
|
||||
if [ "$1" = "view" ] && [ "$NPM_VERSION_EXISTS" = "true" ]; then
|
||||
echo "1.2.3"
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
`,
|
||||
);
|
||||
|
||||
const shellOptions = callerPipefail ? "set -euo pipefail" : "set -eu";
|
||||
const script = `
|
||||
${shellOptions}
|
||||
source "${repoRoot}/scripts/release-lib.sh"
|
||||
publish_package_to_npm ${distTag} @paperclipai/example 1.2.3
|
||||
`;
|
||||
|
||||
let status = 0;
|
||||
let output = "";
|
||||
try {
|
||||
output = execFileSync("bash", ["-c", script], {
|
||||
cwd: fixtureDir,
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${binDir}:${process.env.PATH}`,
|
||||
FAKE_CALL_LOG: callLog,
|
||||
FAKE_STATE_DIR: stateDir,
|
||||
NPM_VERSION_EXISTS: npmVersionExists ? "true" : "false",
|
||||
PNPM_MODE: pnpmMode,
|
||||
REPO_ROOT: fixtureDir,
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
} catch (error) {
|
||||
status = error.status ?? 1;
|
||||
output = `${error.stdout ?? ""}${error.stderr ?? ""}`;
|
||||
}
|
||||
|
||||
return {
|
||||
calls: readFileSync(callLog, "utf8"),
|
||||
output,
|
||||
status,
|
||||
};
|
||||
}
|
||||
|
||||
test("publish_package_to_npm returns after a successful pnpm publish", () => {
|
||||
const result = runPublishHelper({ pnpmMode: "success" });
|
||||
|
||||
assert.equal(result.status, 0);
|
||||
assert.match(result.calls, /^pnpm publish --no-git-checks --tag canary --access public$/m);
|
||||
assert.doesNotMatch(result.calls, /npm view/);
|
||||
assert.doesNotMatch(result.calls, /--provenance=false/);
|
||||
});
|
||||
|
||||
test("publish_package_to_npm retries duplicate tlog failures without provenance", () => {
|
||||
const result = runPublishHelper({ pnpmMode: "tlog-then-success" });
|
||||
|
||||
assert.equal(result.status, 0);
|
||||
assert.match(result.calls, /^npm view @paperclipai\/example@1\.2\.3 version$/m);
|
||||
assert.match(
|
||||
result.calls,
|
||||
/^pnpm publish --no-git-checks --tag canary --access public --provenance=false$/m,
|
||||
);
|
||||
});
|
||||
|
||||
test("publish_package_to_npm treats a duplicate tlog failure as complete when npm exposes the version", () => {
|
||||
const result = runPublishHelper({ pnpmMode: "tlog-always-fails", npmVersionExists: true });
|
||||
|
||||
assert.equal(result.status, 0);
|
||||
assert.match(result.calls, /^npm view @paperclipai\/example@1\.2\.3 version$/m);
|
||||
assert.doesNotMatch(result.calls, /--provenance=false/);
|
||||
});
|
||||
|
||||
test("publish_package_to_npm does not retry unrelated publish failures", () => {
|
||||
const result = runPublishHelper({ pnpmMode: "non-tlog-failure" });
|
||||
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.doesNotMatch(result.calls, /npm view/);
|
||||
assert.doesNotMatch(result.calls, /--provenance=false/);
|
||||
});
|
||||
|
||||
test("publish_package_to_npm does not mask failures when caller has no pipefail", () => {
|
||||
const result = runPublishHelper({ pnpmMode: "non-tlog-failure", callerPipefail: false });
|
||||
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.doesNotMatch(result.calls, /npm view/);
|
||||
assert.doesNotMatch(result.calls, /--provenance=false/);
|
||||
});
|
||||
|
||||
test("publish_package_to_npm does not retry stable publishes without provenance", () => {
|
||||
const result = runPublishHelper({ pnpmMode: "tlog-then-success", distTag: "latest" });
|
||||
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.calls, /^npm view @paperclipai\/example@1\.2\.3 version$/m);
|
||||
assert.doesNotMatch(result.calls, /--provenance=false/);
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
[
|
||||
{
|
||||
"dir": "packages/adapter-utils",
|
||||
"name": "@paperclipai/adapter-utils",
|
||||
"publishFromCi": true
|
||||
},
|
||||
{
|
||||
"dir": "packages/adapters/claude-local",
|
||||
"name": "@paperclipai/adapter-claude-local",
|
||||
"publishFromCi": true
|
||||
},
|
||||
{
|
||||
"dir": "packages/adapters/codex-local",
|
||||
"name": "@paperclipai/adapter-codex-local",
|
||||
"publishFromCi": true
|
||||
},
|
||||
{
|
||||
"dir": "packages/adapters/cursor-cloud",
|
||||
"name": "@paperclipai/adapter-cursor-cloud",
|
||||
"publishFromCi": true
|
||||
},
|
||||
{
|
||||
"dir": "packages/adapters/cursor-local",
|
||||
"name": "@paperclipai/adapter-cursor-local",
|
||||
"publishFromCi": true
|
||||
},
|
||||
{
|
||||
"dir": "packages/adapters/gemini-local",
|
||||
"name": "@paperclipai/adapter-gemini-local",
|
||||
"publishFromCi": true
|
||||
},
|
||||
{
|
||||
"dir": "packages/adapters/grok-local",
|
||||
"name": "@paperclipai/adapter-grok-local",
|
||||
"publishFromCi": true
|
||||
},
|
||||
{
|
||||
"dir": "packages/adapters/hermes",
|
||||
"name": "@paperclipai/hermes-paperclip-adapter",
|
||||
"publishFromCi": true
|
||||
},
|
||||
{
|
||||
"dir": "packages/adapters/hermes-gateway",
|
||||
"name": "@paperclipai/adapter-hermes-gateway",
|
||||
"publishFromCi": false
|
||||
},
|
||||
{
|
||||
"dir": "packages/adapters/opencode-local",
|
||||
"name": "@paperclipai/adapter-opencode-local",
|
||||
"publishFromCi": true
|
||||
},
|
||||
{
|
||||
"dir": "packages/adapters/pi-local",
|
||||
"name": "@paperclipai/adapter-pi-local",
|
||||
"publishFromCi": true
|
||||
},
|
||||
{
|
||||
"dir": "packages/adapters/openclaw-gateway",
|
||||
"name": "@paperclipai/adapter-openclaw-gateway",
|
||||
"publishFromCi": true
|
||||
},
|
||||
{
|
||||
"dir": "packages/shared",
|
||||
"name": "@paperclipai/shared",
|
||||
"publishFromCi": true
|
||||
},
|
||||
{
|
||||
"dir": "packages/skills-catalog",
|
||||
"name": "@paperclipai/skills-catalog",
|
||||
"publishFromCi": true
|
||||
},
|
||||
{
|
||||
"dir": "packages/teams-catalog",
|
||||
"name": "@paperclipai/teams-catalog",
|
||||
"publishFromCi": true
|
||||
},
|
||||
{
|
||||
"dir": "packages/db",
|
||||
"name": "@paperclipai/db",
|
||||
"publishFromCi": true
|
||||
},
|
||||
{
|
||||
"dir": "packages/plugins/sdk",
|
||||
"name": "@paperclipai/plugin-sdk",
|
||||
"publishFromCi": true
|
||||
},
|
||||
{
|
||||
"dir": "packages/plugins/plugin-workspace-diff",
|
||||
"name": "@paperclipai/plugin-workspace-diff",
|
||||
"publishFromCi": true
|
||||
},
|
||||
{
|
||||
"dir": "server",
|
||||
"name": "@paperclipai/server",
|
||||
"publishFromCi": true
|
||||
},
|
||||
{
|
||||
"dir": "cli",
|
||||
"name": "paperclipai",
|
||||
"publishFromCi": true
|
||||
},
|
||||
{
|
||||
"dir": "packages/mcp-server",
|
||||
"name": "@paperclipai/mcp-server",
|
||||
"publishFromCi": true
|
||||
},
|
||||
{
|
||||
"dir": "packages/plugins/create-paperclip-plugin",
|
||||
"name": "@paperclipai/create-paperclip-plugin",
|
||||
"publishFromCi": true
|
||||
},
|
||||
{
|
||||
"dir": "packages/plugins/sandbox-providers/cloudflare",
|
||||
"name": "@paperclipai/plugin-cloudflare-sandbox",
|
||||
"publishFromCi": true
|
||||
},
|
||||
{
|
||||
"dir": "packages/plugins/sandbox-providers/daytona",
|
||||
"name": "@paperclipai/plugin-daytona",
|
||||
"publishFromCi": true
|
||||
},
|
||||
{
|
||||
"dir": "packages/plugins/sandbox-providers/exe-dev",
|
||||
"name": "@paperclipai/plugin-exe-dev",
|
||||
"publishFromCi": true
|
||||
},
|
||||
{
|
||||
"dir": "packages/plugins/sandbox-providers/e2b",
|
||||
"name": "@paperclipai/plugin-e2b",
|
||||
"publishFromCi": true
|
||||
},
|
||||
{
|
||||
"dir": "packages/plugins/sandbox-providers/modal",
|
||||
"name": "@paperclipai/plugin-modal",
|
||||
"publishFromCi": true
|
||||
},
|
||||
{
|
||||
"dir": "packages/plugins/sandbox-providers/kubernetes",
|
||||
"name": "@paperclipai/plugin-kubernetes",
|
||||
"publishFromCi": true
|
||||
},
|
||||
{
|
||||
"dir": "packages/plugins/sandbox-providers/novita",
|
||||
"name": "@paperclipai/plugin-novita-sandbox",
|
||||
"publishFromCi": true
|
||||
},
|
||||
{
|
||||
"dir": "ui",
|
||||
"name": "@paperclipai/ui",
|
||||
"publishFromCi": true
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,330 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { readdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = resolve(__dirname, "..");
|
||||
const manifestPath = join(repoRoot, "scripts", "release-package-manifest.json");
|
||||
const roots = ["packages", "server", "ui", "cli"];
|
||||
|
||||
function readJson(filePath) {
|
||||
return JSON.parse(readFileSync(filePath, "utf8"));
|
||||
}
|
||||
|
||||
function discoverPublicPackages() {
|
||||
const packages = [];
|
||||
|
||||
function walk(relDir) {
|
||||
const absDir = join(repoRoot, relDir);
|
||||
if (!existsSync(absDir)) return;
|
||||
|
||||
const pkgPath = join(absDir, "package.json");
|
||||
if (existsSync(pkgPath)) {
|
||||
const pkg = readJson(pkgPath);
|
||||
if (!pkg.private) {
|
||||
packages.push({
|
||||
dir: relDir,
|
||||
pkgPath,
|
||||
name: pkg.name,
|
||||
version: pkg.version,
|
||||
pkg,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of readdirSync(absDir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (entry.name === "node_modules" || entry.name === "dist" || entry.name === ".git") continue;
|
||||
walk(join(relDir, entry.name));
|
||||
}
|
||||
}
|
||||
|
||||
for (const rel of roots) {
|
||||
walk(rel);
|
||||
}
|
||||
|
||||
return packages;
|
||||
}
|
||||
|
||||
function loadReleaseManifest() {
|
||||
const manifest = readJson(manifestPath);
|
||||
|
||||
if (!Array.isArray(manifest)) {
|
||||
throw new Error(`expected ${manifestPath} to contain an array.`);
|
||||
}
|
||||
|
||||
return manifest.map((entry, index) => {
|
||||
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
||||
throw new Error(`manifest entry ${index + 1} in ${manifestPath} must be an object.`);
|
||||
}
|
||||
|
||||
if (typeof entry.dir !== "string" || entry.dir.length === 0) {
|
||||
throw new Error(`manifest entry ${index + 1} in ${manifestPath} is missing a non-empty "dir".`);
|
||||
}
|
||||
|
||||
if (typeof entry.name !== "string" || entry.name.length === 0) {
|
||||
throw new Error(`manifest entry ${index + 1} in ${manifestPath} is missing a non-empty "name".`);
|
||||
}
|
||||
|
||||
if (typeof entry.publishFromCi !== "boolean") {
|
||||
throw new Error(
|
||||
`manifest entry ${index + 1} (${entry.dir}) in ${manifestPath} must set boolean "publishFromCi".`,
|
||||
);
|
||||
}
|
||||
|
||||
return entry;
|
||||
});
|
||||
}
|
||||
|
||||
// Sections whose @paperclipai workspace deps are rewritten to the calver release
|
||||
// version by replaceWorkspaceDeps() and that consumers resolve at install time.
|
||||
const RESOLVED_DEP_SECTIONS = ["dependencies", "optionalDependencies", "peerDependencies"];
|
||||
|
||||
// A publishFromCi:true package gets republished at the unified calver version every
|
||||
// release, and every @paperclipai/* workspace: dep it declares is rewritten to that
|
||||
// same calver version. If the target is NOT publishFromCi:true it never gets a calver
|
||||
// publish, so the rewritten spec points at a version that will never exist on npm and
|
||||
// the package becomes uninstallable. Detect those edges so the release fails fast
|
||||
// instead of shipping a broken canary (e.g. server -> skills-catalog from #8327).
|
||||
function findUnpublishableWorkspaceEdges(packages) {
|
||||
const publishFromCiByName = new Map(packages.map((pkg) => [pkg.name, pkg.publishFromCi]));
|
||||
const problems = [];
|
||||
|
||||
for (const pkg of packages) {
|
||||
if (!pkg.publishFromCi) continue;
|
||||
|
||||
for (const section of RESOLVED_DEP_SECTIONS) {
|
||||
const deps = pkg.pkg[section];
|
||||
if (!deps) continue;
|
||||
|
||||
for (const [depName, spec] of Object.entries(deps)) {
|
||||
if (!depName.startsWith("@paperclipai/")) continue;
|
||||
if (typeof spec !== "string" || !spec.startsWith("workspace:")) continue;
|
||||
if (publishFromCiByName.get(depName) === true) continue;
|
||||
|
||||
problems.push(
|
||||
`${pkg.name} (${pkg.dir}) is publishFromCi:true but declares a "${section}" workspace dependency on ${depName}, ` +
|
||||
`which is not publishFromCi:true. The release version rewrite would point ${depName} at the calver version, ` +
|
||||
`but that version is never published, so installs of ${pkg.name} would fail to resolve. ` +
|
||||
`Enable publishFromCi for ${depName} (bootstrap its first npm publish if needed) or drop the workspace dependency.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return problems;
|
||||
}
|
||||
|
||||
function buildReleasePackagePlan() {
|
||||
const discoveredPackages = discoverPublicPackages();
|
||||
const manifestEntries = loadReleaseManifest();
|
||||
const packageByDir = new Map(discoveredPackages.map((pkg) => [pkg.dir, pkg]));
|
||||
const manifestByDir = new Map();
|
||||
const problems = [];
|
||||
|
||||
for (const entry of manifestEntries) {
|
||||
if (manifestByDir.has(entry.dir)) {
|
||||
problems.push(`duplicate manifest entry for ${entry.dir}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
manifestByDir.set(entry.dir, entry);
|
||||
const pkg = packageByDir.get(entry.dir);
|
||||
|
||||
if (!pkg) {
|
||||
problems.push(`${entry.dir} is listed in ${manifestPath} but is not a public package in this repo`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (pkg.name !== entry.name) {
|
||||
problems.push(
|
||||
`${entry.dir} is listed as ${entry.name} in ${manifestPath}, but package.json declares ${pkg.name}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const pkg of discoveredPackages) {
|
||||
if (!manifestByDir.has(pkg.dir)) {
|
||||
problems.push(
|
||||
`${pkg.dir} (${pkg.name}) is public but missing from ${manifestPath}; add it with publishFromCi true or false`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (problems.length > 0) {
|
||||
throw new Error(`release package manifest validation failed:\n- ${problems.join("\n- ")}`);
|
||||
}
|
||||
|
||||
const packages = discoveredPackages.map((pkg) => ({
|
||||
...pkg,
|
||||
publishFromCi: manifestByDir.get(pkg.dir).publishFromCi,
|
||||
}));
|
||||
|
||||
const edgeProblems = findUnpublishableWorkspaceEdges(packages);
|
||||
if (edgeProblems.length > 0) {
|
||||
throw new Error(`release package manifest validation failed:\n- ${edgeProblems.join("\n- ")}`);
|
||||
}
|
||||
|
||||
return packages;
|
||||
}
|
||||
|
||||
function sortTopologically(packages) {
|
||||
const byName = new Map(packages.map((pkg) => [pkg.name, pkg]));
|
||||
const visited = new Set();
|
||||
const visiting = new Set();
|
||||
const ordered = [];
|
||||
|
||||
function visit(pkg) {
|
||||
if (visited.has(pkg.name)) return;
|
||||
if (visiting.has(pkg.name)) {
|
||||
throw new Error(`cycle detected in release package graph at ${pkg.name}`);
|
||||
}
|
||||
|
||||
visiting.add(pkg.name);
|
||||
|
||||
const dependencySections = [
|
||||
pkg.pkg.dependencies ?? {},
|
||||
pkg.pkg.optionalDependencies ?? {},
|
||||
pkg.pkg.peerDependencies ?? {},
|
||||
];
|
||||
|
||||
for (const deps of dependencySections) {
|
||||
for (const depName of Object.keys(deps)) {
|
||||
const dep = byName.get(depName);
|
||||
if (dep) visit(dep);
|
||||
}
|
||||
}
|
||||
|
||||
visiting.delete(pkg.name);
|
||||
visited.add(pkg.name);
|
||||
ordered.push(pkg);
|
||||
}
|
||||
|
||||
for (const pkg of [...packages].sort((a, b) => a.dir.localeCompare(b.dir))) {
|
||||
visit(pkg);
|
||||
}
|
||||
|
||||
return ordered;
|
||||
}
|
||||
|
||||
function getReleasePackages() {
|
||||
return sortTopologically(buildReleasePackagePlan().filter((pkg) => pkg.publishFromCi));
|
||||
}
|
||||
|
||||
function replaceWorkspaceDeps(deps, version) {
|
||||
if (!deps) return deps;
|
||||
const next = { ...deps };
|
||||
|
||||
for (const [name, value] of Object.entries(next)) {
|
||||
if (!name.startsWith("@paperclipai/")) continue;
|
||||
if (typeof value !== "string" || !value.startsWith("workspace:")) continue;
|
||||
next[name] = version;
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
function setVersion(version) {
|
||||
const packages = getReleasePackages();
|
||||
|
||||
for (const pkg of packages) {
|
||||
const nextPkg = {
|
||||
...pkg.pkg,
|
||||
version,
|
||||
dependencies: replaceWorkspaceDeps(pkg.pkg.dependencies, version),
|
||||
optionalDependencies: replaceWorkspaceDeps(pkg.pkg.optionalDependencies, version),
|
||||
peerDependencies: replaceWorkspaceDeps(pkg.pkg.peerDependencies, version),
|
||||
devDependencies: replaceWorkspaceDeps(pkg.pkg.devDependencies, version),
|
||||
};
|
||||
|
||||
writeFileSync(pkg.pkgPath, `${JSON.stringify(nextPkg, null, 2)}\n`);
|
||||
}
|
||||
|
||||
const cliEntryPath = join(repoRoot, "cli/src/index.ts");
|
||||
const cliEntry = readFileSync(cliEntryPath, "utf8");
|
||||
const nextCliEntry = cliEntry.replace(
|
||||
/\.version\("([^"]+)"\)/,
|
||||
`.version("${version}")`,
|
||||
);
|
||||
|
||||
if (cliEntry !== nextCliEntry) {
|
||||
writeFileSync(cliEntryPath, nextCliEntry);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!cliEntry.includes(".version(cliVersion)")) {
|
||||
throw new Error("failed to rewrite CLI version string in cli/src/index.ts");
|
||||
}
|
||||
}
|
||||
|
||||
function listPackages() {
|
||||
const packages = getReleasePackages();
|
||||
for (const pkg of packages) {
|
||||
process.stdout.write(`${pkg.dir}\t${pkg.name}\t${pkg.version}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
function checkConfiguration() {
|
||||
const packages = buildReleasePackagePlan();
|
||||
const enabledCount = packages.filter((pkg) => pkg.publishFromCi).length;
|
||||
const disabledCount = packages.length - enabledCount;
|
||||
|
||||
if (enabledCount === 0) {
|
||||
throw new Error(`no packages are enabled for CI publishing in ${manifestPath}`);
|
||||
}
|
||||
|
||||
process.stdout.write(
|
||||
`Release package manifest OK: ${enabledCount} enabled for CI publish, ${disabledCount} disabled pending bootstrap.\n`,
|
||||
);
|
||||
}
|
||||
|
||||
function usage() {
|
||||
process.stderr.write(
|
||||
[
|
||||
"Usage:",
|
||||
" node scripts/release-package-map.mjs list",
|
||||
" node scripts/release-package-map.mjs check",
|
||||
" node scripts/release-package-map.mjs set-version <version>",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
const [command, arg] = process.argv.slice(2);
|
||||
const isDirectRun = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
||||
|
||||
if (isDirectRun) {
|
||||
if (command === "list") {
|
||||
listPackages();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (command === "check") {
|
||||
checkConfiguration();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (command === "set-version") {
|
||||
if (!arg) {
|
||||
usage();
|
||||
process.exit(1);
|
||||
}
|
||||
setVersion(arg);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
usage();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
export {
|
||||
buildReleasePackagePlan,
|
||||
checkConfiguration,
|
||||
discoverPublicPackages,
|
||||
findUnpublishableWorkspaceEdges,
|
||||
getReleasePackages,
|
||||
loadReleaseManifest,
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
buildReleasePackagePlan,
|
||||
checkConfiguration,
|
||||
findUnpublishableWorkspaceEdges,
|
||||
getReleasePackages,
|
||||
} from "./release-package-map.mjs";
|
||||
|
||||
function pkg(name, { publishFromCi, ...deps } = {}) {
|
||||
return { name, dir: name, publishFromCi, pkg: { name, ...deps } };
|
||||
}
|
||||
|
||||
test("release package manifest covers all public packages with explicit CI enrollment", () => {
|
||||
const packages = buildReleasePackagePlan();
|
||||
assert.ok(packages.length > 0);
|
||||
assert.ok(packages.every((pkg) => typeof pkg.publishFromCi === "boolean"));
|
||||
});
|
||||
|
||||
test("release package list only contains CI-enrolled packages", () => {
|
||||
const enabledPackages = getReleasePackages();
|
||||
assert.ok(enabledPackages.length > 0);
|
||||
assert.ok(enabledPackages.every((pkg) => pkg.publishFromCi === true));
|
||||
});
|
||||
|
||||
test("Hermes release surface publishes the unified built-in package and keeps gateway as a shim", () => {
|
||||
const packages = buildReleasePackagePlan();
|
||||
const hermes = packages.find((pkg) => pkg.name === "@paperclipai/hermes-paperclip-adapter");
|
||||
const gatewayShim = packages.find((pkg) => pkg.name === "@paperclipai/adapter-hermes-gateway");
|
||||
|
||||
assert.equal(hermes?.dir, "packages/adapters/hermes");
|
||||
assert.equal(hermes?.publishFromCi, true);
|
||||
assert.equal(gatewayShim?.dir, "packages/adapters/hermes-gateway");
|
||||
assert.equal(gatewayShim?.publishFromCi, false);
|
||||
});
|
||||
|
||||
test("release package configuration validates successfully", () => {
|
||||
assert.doesNotThrow(() => checkConfiguration());
|
||||
});
|
||||
|
||||
test("guard flags a publishFromCi:true package depending on a publishFromCi:false package", () => {
|
||||
const problems = findUnpublishableWorkspaceEdges([
|
||||
pkg("@paperclipai/server", {
|
||||
publishFromCi: true,
|
||||
dependencies: { "@paperclipai/skills-catalog": "workspace:*" },
|
||||
}),
|
||||
pkg("@paperclipai/skills-catalog", { publishFromCi: false }),
|
||||
]);
|
||||
|
||||
assert.equal(problems.length, 1);
|
||||
assert.match(problems[0], /@paperclipai\/server/);
|
||||
assert.match(problems[0], /@paperclipai\/skills-catalog/);
|
||||
});
|
||||
|
||||
test("guard inspects optional and peer dependency sections too", () => {
|
||||
const problems = findUnpublishableWorkspaceEdges([
|
||||
pkg("@paperclipai/server", {
|
||||
publishFromCi: true,
|
||||
optionalDependencies: { "@paperclipai/opt": "workspace:^" },
|
||||
peerDependencies: { "@paperclipai/peer": "workspace:*" },
|
||||
}),
|
||||
pkg("@paperclipai/opt", { publishFromCi: false }),
|
||||
pkg("@paperclipai/peer", { publishFromCi: false }),
|
||||
]);
|
||||
|
||||
assert.equal(problems.length, 2);
|
||||
});
|
||||
|
||||
test("guard treats a workspace dep on an unknown @paperclipai package as unpublishable", () => {
|
||||
const problems = findUnpublishableWorkspaceEdges([
|
||||
pkg("@paperclipai/server", {
|
||||
publishFromCi: true,
|
||||
dependencies: { "@paperclipai/private-internal": "workspace:*" },
|
||||
}),
|
||||
]);
|
||||
|
||||
assert.equal(problems.length, 1);
|
||||
});
|
||||
|
||||
test("guard allows true->true workspace edges", () => {
|
||||
const problems = findUnpublishableWorkspaceEdges([
|
||||
pkg("@paperclipai/server", {
|
||||
publishFromCi: true,
|
||||
dependencies: { "@paperclipai/shared": "workspace:*" },
|
||||
}),
|
||||
pkg("@paperclipai/shared", { publishFromCi: true }),
|
||||
]);
|
||||
|
||||
assert.deepEqual(problems, []);
|
||||
});
|
||||
|
||||
test("guard ignores non-workspace specs, non-internal deps, and edges from off-train packages", () => {
|
||||
const problems = findUnpublishableWorkspaceEdges([
|
||||
pkg("@paperclipai/server", {
|
||||
publishFromCi: true,
|
||||
dependencies: {
|
||||
"@paperclipai/pinned": "0.3.1",
|
||||
zod: "^3.0.0",
|
||||
},
|
||||
}),
|
||||
pkg("@paperclipai/pinned", { publishFromCi: false }),
|
||||
pkg("@paperclipai/offtrain", {
|
||||
publishFromCi: false,
|
||||
dependencies: { "@paperclipai/also-off": "workspace:*" },
|
||||
}),
|
||||
pkg("@paperclipai/also-off", { publishFromCi: false }),
|
||||
]);
|
||||
|
||||
assert.deepEqual(problems, []);
|
||||
});
|
||||
|
||||
test("the live release manifest has no unpublishable workspace edges", () => {
|
||||
assert.deepEqual(findUnpublishableWorkspaceEdges(buildReleasePackagePlan()), []);
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env node
|
||||
// Batched npm registry version queries for the release tooling.
|
||||
//
|
||||
// The release flow needs published-version data for every public workspace
|
||||
// package. Querying them one `npm view` at a time is serial network latency
|
||||
// that dominates the non-build time of `release.sh` (and the PR workflow's
|
||||
// Canary Dry Run job). This helper runs the same `npm view` queries with
|
||||
// bounded concurrency instead.
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/release-registry-versions.mjs fetch <pkg...>
|
||||
// Prints a JSON object mapping each package name to its published
|
||||
// versions array. Packages that are missing from the registry (or fail
|
||||
// to resolve) map to [].
|
||||
//
|
||||
// node scripts/release-registry-versions.mjs assert-absent <version> <pkg...>
|
||||
// Freshly checks that <version> is not published for any <pkg>. Exits 0
|
||||
// when absent everywhere; prints the offending package@version pairs to
|
||||
// stderr and exits 1 otherwise.
|
||||
|
||||
import { execFile } from "node:child_process";
|
||||
|
||||
const CONCURRENCY = Number(process.env.RELEASE_REGISTRY_CONCURRENCY || 10);
|
||||
if (!Number.isInteger(CONCURRENCY) || CONCURRENCY < 1) {
|
||||
console.error("RELEASE_REGISTRY_CONCURRENCY must be a positive integer.");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
function npmView(args) {
|
||||
return new Promise((resolve) => {
|
||||
execFile("npm", ["view", ...args], { encoding: "utf8" }, (error, stdout) => {
|
||||
if (error) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
resolve(stdout.trim());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function mapWithConcurrency(items, limit, fn) {
|
||||
const results = new Array(items.length);
|
||||
let next = 0;
|
||||
|
||||
async function worker() {
|
||||
while (next < items.length) {
|
||||
const index = next;
|
||||
next += 1;
|
||||
results[index] = await fn(items[index]);
|
||||
}
|
||||
}
|
||||
|
||||
const workers = [];
|
||||
for (let i = 0; i < Math.min(limit, items.length); i += 1) {
|
||||
workers.push(worker());
|
||||
}
|
||||
await Promise.all(workers);
|
||||
return results;
|
||||
}
|
||||
|
||||
async function fetchVersions(packageNames) {
|
||||
const versionLists = await mapWithConcurrency(packageNames, CONCURRENCY, async (packageName) => {
|
||||
const raw = await npmView([packageName, "versions", "--json"]);
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : [parsed];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
const map = {};
|
||||
packageNames.forEach((packageName, index) => {
|
||||
map[packageName] = versionLists[index];
|
||||
});
|
||||
return map;
|
||||
}
|
||||
|
||||
async function assertAbsent(version, packageNames) {
|
||||
const resolved = await mapWithConcurrency(packageNames, CONCURRENCY, async (packageName) => {
|
||||
const raw = await npmView([`${packageName}@${version}`, "version"]);
|
||||
return raw === version ? packageName : null;
|
||||
});
|
||||
|
||||
return resolved.filter((packageName) => packageName !== null);
|
||||
}
|
||||
|
||||
const [mode, ...rest] = process.argv.slice(2);
|
||||
|
||||
if (mode === "fetch") {
|
||||
if (rest.length === 0) {
|
||||
console.error("usage: release-registry-versions.mjs fetch <pkg...>");
|
||||
process.exit(2);
|
||||
}
|
||||
const map = await fetchVersions(rest);
|
||||
process.stdout.write(`${JSON.stringify(map)}\n`);
|
||||
} else if (mode === "assert-absent") {
|
||||
const [version, ...packageNames] = rest;
|
||||
if (!version || packageNames.length === 0) {
|
||||
console.error("usage: release-registry-versions.mjs assert-absent <version> <pkg...>");
|
||||
process.exit(2);
|
||||
}
|
||||
const existing = await assertAbsent(version, packageNames);
|
||||
if (existing.length > 0) {
|
||||
for (const packageName of existing) {
|
||||
console.error(`npm version ${packageName}@${version} already exists.`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
console.error("usage: release-registry-versions.mjs <fetch|assert-absent> ...");
|
||||
process.exit(2);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
const repoRoot = new URL("..", import.meta.url).pathname.replace(/\/$/, "");
|
||||
const scriptPath = join(repoRoot, "scripts", "release-registry-versions.mjs");
|
||||
|
||||
function writeExecutable(path, body) {
|
||||
writeFileSync(path, body, { mode: 0o755 });
|
||||
}
|
||||
|
||||
function makeFixture() {
|
||||
const fixtureDir = mkdtempSync(join(tmpdir(), "paperclip-release-registry-"));
|
||||
const binDir = join(fixtureDir, "bin");
|
||||
const callLog = join(fixtureDir, "calls.log");
|
||||
mkdirSync(binDir);
|
||||
writeFileSync(callLog, "");
|
||||
|
||||
writeExecutable(
|
||||
join(binDir, "npm"),
|
||||
`#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
printf 'npm %s\\n' "$*" >> "$FAKE_CALL_LOG"
|
||||
target="$2"
|
||||
case "$target" in
|
||||
"@paperclipai/present@"*)
|
||||
printf '%s\\n' "\${target##*@}"
|
||||
;;
|
||||
"@paperclipai/absent@"*)
|
||||
exit 1
|
||||
;;
|
||||
"@paperclipai/present")
|
||||
echo '["1.0.0","2026.707.0","2026.707.1","2026.707.1-canary.4"]'
|
||||
;;
|
||||
*)
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
`,
|
||||
);
|
||||
|
||||
return { fixtureDir, binDir, callLog };
|
||||
}
|
||||
|
||||
function runScript(args, { binDir, callLog }, extraEnv = {}) {
|
||||
let status = 0;
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
try {
|
||||
stdout = execFileSync("node", [scriptPath, ...args], {
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${binDir}:${process.env.PATH}`,
|
||||
FAKE_CALL_LOG: callLog,
|
||||
...extraEnv,
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
} catch (error) {
|
||||
status = error.status ?? 1;
|
||||
stdout = error.stdout ?? "";
|
||||
stderr = error.stderr ?? "";
|
||||
}
|
||||
return { status, stdout, stderr, calls: readFileSync(callLog, "utf8") };
|
||||
}
|
||||
|
||||
function runReleaseLibHelper(fnCall, { binDir, callLog }, extraEnv = {}) {
|
||||
const script = `
|
||||
set -euo pipefail
|
||||
source "${repoRoot}/scripts/release-lib.sh"
|
||||
${fnCall}
|
||||
`;
|
||||
let status = 0;
|
||||
let output = "";
|
||||
try {
|
||||
output = execFileSync("bash", ["-c", script], {
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${binDir}:${process.env.PATH}`,
|
||||
FAKE_CALL_LOG: callLog,
|
||||
REPO_ROOT: repoRoot,
|
||||
...extraEnv,
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
} catch (error) {
|
||||
status = error.status ?? 1;
|
||||
output = `${error.stdout ?? ""}${error.stderr ?? ""}`;
|
||||
}
|
||||
return { status, output, calls: readFileSync(callLog, "utf8") };
|
||||
}
|
||||
|
||||
test("fetch prints a JSON version map and treats missing packages as empty", () => {
|
||||
const fixture = makeFixture();
|
||||
const result = runScript(["fetch", "@paperclipai/present", "@paperclipai/missing"], fixture);
|
||||
|
||||
assert.equal(result.status, 0);
|
||||
const map = JSON.parse(result.stdout);
|
||||
assert.deepEqual(map["@paperclipai/present"], [
|
||||
"1.0.0",
|
||||
"2026.707.0",
|
||||
"2026.707.1",
|
||||
"2026.707.1-canary.4",
|
||||
]);
|
||||
assert.deepEqual(map["@paperclipai/missing"], []);
|
||||
assert.match(result.calls, /^npm view @paperclipai\/present versions --json$/m);
|
||||
assert.match(result.calls, /^npm view @paperclipai\/missing versions --json$/m);
|
||||
});
|
||||
|
||||
test("assert-absent succeeds when no package has the version", () => {
|
||||
const fixture = makeFixture();
|
||||
const result = runScript(
|
||||
["assert-absent", "2026.707.2", "@paperclipai/absent", "@paperclipai/absent"],
|
||||
fixture,
|
||||
);
|
||||
|
||||
assert.equal(result.status, 0);
|
||||
assert.match(result.calls, /^npm view @paperclipai\/absent@2026\.707\.2 version$/m);
|
||||
});
|
||||
|
||||
test("assert-absent fails and names packages that already have the version", () => {
|
||||
const fixture = makeFixture();
|
||||
const result = runScript(
|
||||
["assert-absent", "2026.707.2", "@paperclipai/present", "@paperclipai/absent"],
|
||||
fixture,
|
||||
);
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /npm version @paperclipai\/present@2026\.707\.2 already exists\./);
|
||||
assert.doesNotMatch(result.stderr, /@paperclipai\/absent@/);
|
||||
});
|
||||
|
||||
test("invalid concurrency fails instead of skipping registry checks", () => {
|
||||
const fixture = makeFixture();
|
||||
const result = runScript(["assert-absent", "2026.707.2", "@paperclipai/present"], fixture, {
|
||||
RELEASE_REGISTRY_CONCURRENCY: "0",
|
||||
});
|
||||
|
||||
assert.equal(result.status, 2);
|
||||
assert.match(result.stderr, /RELEASE_REGISTRY_CONCURRENCY must be a positive integer\./);
|
||||
assert.equal(result.calls, "");
|
||||
});
|
||||
|
||||
test("next_stable_version reads RELEASE_PACKAGE_VERSIONS_FILE without calling npm", () => {
|
||||
const fixture = makeFixture();
|
||||
const versionsFile = join(fixture.fixtureDir, "versions.json");
|
||||
writeFileSync(
|
||||
versionsFile,
|
||||
JSON.stringify({
|
||||
"@paperclipai/a": ["2026.707.0", "2026.707.1", "2026.707.1-canary.4"],
|
||||
"@paperclipai/b": [],
|
||||
}),
|
||||
);
|
||||
|
||||
const result = runReleaseLibHelper(
|
||||
'next_stable_version 2026-07-07 "@paperclipai/a" "@paperclipai/b"',
|
||||
fixture,
|
||||
{ RELEASE_PACKAGE_VERSIONS_FILE: versionsFile },
|
||||
);
|
||||
|
||||
assert.equal(result.status, 0);
|
||||
assert.equal(result.output, "2026.707.2");
|
||||
assert.doesNotMatch(result.calls, /npm view/);
|
||||
});
|
||||
|
||||
test("next_canary_version reads RELEASE_PACKAGE_VERSIONS_FILE without calling npm", () => {
|
||||
const fixture = makeFixture();
|
||||
const versionsFile = join(fixture.fixtureDir, "versions.json");
|
||||
writeFileSync(
|
||||
versionsFile,
|
||||
JSON.stringify({
|
||||
"@paperclipai/a": ["2026.707.0", "2026.707.1", "2026.707.1-canary.4"],
|
||||
}),
|
||||
);
|
||||
|
||||
const result = runReleaseLibHelper('next_canary_version 2026.707.1 "@paperclipai/a"', fixture, {
|
||||
RELEASE_PACKAGE_VERSIONS_FILE: versionsFile,
|
||||
});
|
||||
|
||||
assert.equal(result.status, 0);
|
||||
assert.equal(result.output, "2026.707.1-canary.5");
|
||||
assert.doesNotMatch(result.calls, /npm view/);
|
||||
});
|
||||
|
||||
test("next_stable_version falls back to npm view without a versions file", () => {
|
||||
const fixture = makeFixture();
|
||||
const result = runReleaseLibHelper('next_stable_version 2026-07-07 "@paperclipai/present"', fixture);
|
||||
|
||||
assert.equal(result.status, 0);
|
||||
assert.equal(result.output, "2026.707.2");
|
||||
assert.match(result.calls, /^npm view @paperclipai\/present versions --json$/m);
|
||||
});
|
||||
Executable
+352
@@ -0,0 +1,352 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
# shellcheck source=./release-lib.sh
|
||||
. "$REPO_ROOT/scripts/release-lib.sh"
|
||||
CLI_DIR="$REPO_ROOT/cli"
|
||||
|
||||
channel=""
|
||||
release_date=""
|
||||
dry_run=false
|
||||
skip_verify=false
|
||||
print_version_only=false
|
||||
tag_name=""
|
||||
|
||||
cleanup_on_exit=false
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
./scripts/release.sh <canary|stable> [--date YYYY-MM-DD] [--dry-run] [--skip-verify] [--print-version]
|
||||
|
||||
Examples:
|
||||
./scripts/release.sh canary
|
||||
./scripts/release.sh canary --date 2026-03-17 --dry-run
|
||||
./scripts/release.sh stable
|
||||
./scripts/release.sh stable --date 2026-03-17 --dry-run
|
||||
./scripts/release.sh stable --date 2026-03-18 --print-version
|
||||
|
||||
Notes:
|
||||
- Stable versions use YYYY.MDD.P, where M is the UTC month, DD is the
|
||||
zero-padded UTC day, and P is the same-day stable patch slot.
|
||||
- Canary releases publish YYYY.MDD.P-canary.N under the npm dist-tag
|
||||
"canary" and create the git tag canary/vYYYY.MDD.P-canary.N.
|
||||
- Stable releases publish YYYY.MDD.P under the npm dist-tag "latest" and
|
||||
create the git tag vYYYY.MDD.P.
|
||||
- Stable release notes must already exist at releases/vYYYY.MDD.P.md.
|
||||
- The script rewrites versions temporarily and restores the working tree on
|
||||
exit. Tags always point at the original source commit, not a generated
|
||||
release commit.
|
||||
EOF
|
||||
}
|
||||
|
||||
restore_publish_artifacts() {
|
||||
if [ -f "$CLI_DIR/package.dev.json" ]; then
|
||||
mv "$CLI_DIR/package.dev.json" "$CLI_DIR/package.json"
|
||||
fi
|
||||
|
||||
rm -f "$CLI_DIR/README.md"
|
||||
rm -rf "$REPO_ROOT/server/ui-dist"
|
||||
|
||||
for pkg_dir in server packages/adapters/claude-local packages/adapters/codex-local; do
|
||||
rm -rf "$REPO_ROOT/$pkg_dir/skills"
|
||||
done
|
||||
}
|
||||
|
||||
cleanup_release_state() {
|
||||
restore_publish_artifacts
|
||||
|
||||
tracked_changes="$(git -C "$REPO_ROOT" diff --name-only; git -C "$REPO_ROOT" diff --cached --name-only)"
|
||||
if [ -n "$tracked_changes" ]; then
|
||||
printf '%s\n' "$tracked_changes" | sort -u | while IFS= read -r path; do
|
||||
[ -z "$path" ] && continue
|
||||
git -C "$REPO_ROOT" checkout -q HEAD -- "$path" || true
|
||||
done
|
||||
fi
|
||||
|
||||
untracked_changes="$(git -C "$REPO_ROOT" ls-files --others --exclude-standard)"
|
||||
if [ -n "$untracked_changes" ]; then
|
||||
printf '%s\n' "$untracked_changes" | while IFS= read -r path; do
|
||||
[ -z "$path" ] && continue
|
||||
if [ -d "$REPO_ROOT/$path" ]; then
|
||||
rm -rf "$REPO_ROOT/$path"
|
||||
else
|
||||
rm -f "$REPO_ROOT/$path"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
}
|
||||
|
||||
set_cleanup_trap() {
|
||||
cleanup_on_exit=true
|
||||
trap cleanup_release_state EXIT
|
||||
}
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
canary|stable)
|
||||
if [ -n "$channel" ]; then
|
||||
release_fail "only one release channel may be provided."
|
||||
fi
|
||||
channel="$1"
|
||||
;;
|
||||
--date)
|
||||
shift
|
||||
[ $# -gt 0 ] || release_fail "--date requires YYYY-MM-DD."
|
||||
release_date="$1"
|
||||
;;
|
||||
--dry-run) dry_run=true ;;
|
||||
--skip-verify) skip_verify=true ;;
|
||||
--print-version) print_version_only=true ;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
release_fail "unexpected argument: $1"
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
[ -n "$channel" ] || {
|
||||
usage
|
||||
exit 1
|
||||
}
|
||||
|
||||
PUBLISH_REMOTE="$(resolve_release_remote)"
|
||||
fetch_release_remote "$PUBLISH_REMOTE"
|
||||
|
||||
CURRENT_BRANCH="$(git_current_branch)"
|
||||
CURRENT_SHA="$(git -C "$REPO_ROOT" rev-parse HEAD)"
|
||||
LAST_STABLE_TAG="$(get_last_stable_tag)"
|
||||
CURRENT_STABLE_VERSION="$(get_current_stable_version)"
|
||||
RELEASE_DATE="${release_date:-$(utc_date_iso)}"
|
||||
|
||||
PUBLIC_PACKAGE_INFO="$(list_public_package_info)"
|
||||
PUBLIC_PACKAGE_NAMES=()
|
||||
while IFS= read -r package_name; do
|
||||
[ -n "$package_name" ] || continue
|
||||
PUBLIC_PACKAGE_NAMES+=("$package_name")
|
||||
done < <(printf '%s\n' "$PUBLIC_PACKAGE_INFO" | cut -f2)
|
||||
|
||||
[ -n "$PUBLIC_PACKAGE_INFO" ] || release_fail "no public packages were found in the workspace."
|
||||
|
||||
# Pre-fetch published versions for every public package in parallel so the
|
||||
# version helpers below do not each issue one serial `npm view` call per
|
||||
# package (see scripts/release-registry-versions.mjs).
|
||||
RELEASE_PACKAGE_VERSIONS_FILE="$(mktemp)"
|
||||
export RELEASE_PACKAGE_VERSIONS_FILE
|
||||
node "$REPO_ROOT/scripts/release-registry-versions.mjs" fetch "${PUBLIC_PACKAGE_NAMES[@]}" > "$RELEASE_PACKAGE_VERSIONS_FILE"
|
||||
|
||||
TARGET_STABLE_VERSION="$(next_stable_version "$RELEASE_DATE" "${PUBLIC_PACKAGE_NAMES[@]}")"
|
||||
TARGET_PUBLISH_VERSION="$TARGET_STABLE_VERSION"
|
||||
DIST_TAG="latest"
|
||||
|
||||
if [ "$channel" = "canary" ]; then
|
||||
require_on_master_branch
|
||||
TARGET_PUBLISH_VERSION="$(next_canary_version "$TARGET_STABLE_VERSION" "${PUBLIC_PACKAGE_NAMES[@]}")"
|
||||
DIST_TAG="canary"
|
||||
tag_name="$(canary_tag_name "$TARGET_PUBLISH_VERSION")"
|
||||
else
|
||||
tag_name="$(stable_tag_name "$TARGET_STABLE_VERSION")"
|
||||
fi
|
||||
|
||||
rm -f "$RELEASE_PACKAGE_VERSIONS_FILE"
|
||||
unset RELEASE_PACKAGE_VERSIONS_FILE
|
||||
|
||||
if [ "$print_version_only" = true ]; then
|
||||
printf '%s\n' "$TARGET_PUBLISH_VERSION"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
NOTES_FILE="$(release_notes_file "$TARGET_STABLE_VERSION")"
|
||||
|
||||
require_clean_worktree
|
||||
require_npm_publish_auth "$dry_run"
|
||||
|
||||
if [ "$channel" = "stable" ] && [ ! -f "$NOTES_FILE" ]; then
|
||||
release_fail "stable release notes file is required at $NOTES_FILE before publishing stable."
|
||||
fi
|
||||
|
||||
if [ "$channel" = "canary" ] && [ -f "$NOTES_FILE" ]; then
|
||||
release_info " ✓ Stable release notes already exist at $NOTES_FILE"
|
||||
fi
|
||||
|
||||
if git_local_tag_exists "$tag_name" || git_remote_tag_exists "$tag_name" "$PUBLISH_REMOTE"; then
|
||||
release_fail "git tag $tag_name already exists locally or on $PUBLISH_REMOTE."
|
||||
fi
|
||||
|
||||
# Fresh (non-cached) existence check, batched in parallel. Prints the
|
||||
# offending package@version pairs itself before failing.
|
||||
node "$REPO_ROOT/scripts/release-registry-versions.mjs" assert-absent "$TARGET_PUBLISH_VERSION" "${PUBLIC_PACKAGE_NAMES[@]}" \
|
||||
|| release_fail "npm version ${TARGET_PUBLISH_VERSION} already exists for one or more packages."
|
||||
|
||||
release_info ""
|
||||
release_info "==> Release plan"
|
||||
release_info " Remote: $PUBLISH_REMOTE"
|
||||
release_info " Channel: $channel"
|
||||
release_info " Current branch: ${CURRENT_BRANCH:-<detached>}"
|
||||
release_info " Source commit: $CURRENT_SHA"
|
||||
release_info " Last stable tag: ${LAST_STABLE_TAG:-<none>}"
|
||||
release_info " Current stable version: $CURRENT_STABLE_VERSION"
|
||||
release_info " Release date (UTC): $RELEASE_DATE"
|
||||
release_info " Target stable version: $TARGET_STABLE_VERSION"
|
||||
if [ "$channel" = "canary" ]; then
|
||||
release_info " Canary version: $TARGET_PUBLISH_VERSION"
|
||||
else
|
||||
release_info " Stable version: $TARGET_PUBLISH_VERSION"
|
||||
fi
|
||||
release_info " Dist-tag: $DIST_TAG"
|
||||
release_info " Git tag: $tag_name"
|
||||
if [ "$channel" = "stable" ]; then
|
||||
release_info " Release notes: $NOTES_FILE"
|
||||
fi
|
||||
|
||||
set_cleanup_trap
|
||||
|
||||
# The release flow already prepares ui/dist before packaging. Reuse that output
|
||||
# so server prepack does not rebuild the UI a second time during preview/publish.
|
||||
export PAPERCLIP_RELEASE_REUSE_UI_DIST=1
|
||||
|
||||
if [ "$skip_verify" = false ]; then
|
||||
release_info ""
|
||||
release_info "==> Step 1/7: Verification gate..."
|
||||
cd "$REPO_ROOT"
|
||||
pnpm -r typecheck
|
||||
pnpm test:run
|
||||
pnpm build
|
||||
else
|
||||
release_info ""
|
||||
release_info "==> Step 1/7: Verification gate skipped (--skip-verify)"
|
||||
fi
|
||||
|
||||
release_info ""
|
||||
release_info "==> Step 2/7: Building workspace artifacts..."
|
||||
cd "$REPO_ROOT"
|
||||
pnpm build
|
||||
node "$REPO_ROOT/scripts/build-standalone-public-packages.mjs"
|
||||
bash "$REPO_ROOT/scripts/prepare-server-ui-dist.sh"
|
||||
for pkg_dir in server packages/adapters/claude-local packages/adapters/codex-local; do
|
||||
rm -rf "$REPO_ROOT/$pkg_dir/skills"
|
||||
cp -r "$REPO_ROOT/skills" "$REPO_ROOT/$pkg_dir/skills"
|
||||
done
|
||||
release_info " ✓ Workspace build complete"
|
||||
|
||||
release_info ""
|
||||
release_info "==> Step 3/7: Rewriting workspace versions..."
|
||||
set_public_package_version "$TARGET_PUBLISH_VERSION"
|
||||
release_info " ✓ Versioned workspace to $TARGET_PUBLISH_VERSION"
|
||||
|
||||
release_info ""
|
||||
release_info "==> Step 4/7: Building publishable CLI bundle..."
|
||||
"$REPO_ROOT/scripts/build-npm.sh" --skip-checks --skip-typecheck
|
||||
release_info " ✓ CLI bundle ready"
|
||||
|
||||
VERSIONED_PACKAGE_INFO="$(list_public_package_info)"
|
||||
VERSION_IN_CLI_PACKAGE="$(node -e "console.log(require('$CLI_DIR/package.json').version)")"
|
||||
if [ "$VERSION_IN_CLI_PACKAGE" != "$TARGET_PUBLISH_VERSION" ]; then
|
||||
release_fail "versioning drift detected. Expected $TARGET_PUBLISH_VERSION but found $VERSION_IN_CLI_PACKAGE."
|
||||
fi
|
||||
|
||||
release_info ""
|
||||
if [ "$dry_run" = true ]; then
|
||||
release_info "==> Step 5/7: Previewing publish payloads (--dry-run)..."
|
||||
while IFS=$'\t' read -r pkg_dir _pkg_name _pkg_version; do
|
||||
[ -z "$pkg_dir" ] && continue
|
||||
release_info " --- $pkg_dir ---"
|
||||
cd "$REPO_ROOT/$pkg_dir"
|
||||
pnpm publish --dry-run --no-git-checks --tag "$DIST_TAG" 2>&1 | tail -3
|
||||
done <<< "$VERSIONED_PACKAGE_INFO"
|
||||
release_info " [dry-run] Would create git tag $tag_name on $CURRENT_SHA"
|
||||
else
|
||||
release_info "==> Step 5/7: Publishing packages to npm..."
|
||||
while IFS=$'\t' read -r pkg_dir pkg_name pkg_version; do
|
||||
[ -z "$pkg_dir" ] && continue
|
||||
release_info " Publishing $pkg_name@$pkg_version"
|
||||
cd "$REPO_ROOT/$pkg_dir"
|
||||
publish_package_to_npm "$DIST_TAG" "$pkg_name" "$pkg_version"
|
||||
done <<< "$VERSIONED_PACKAGE_INFO"
|
||||
release_info " ✓ Published all packages under dist-tag $DIST_TAG"
|
||||
fi
|
||||
|
||||
release_info ""
|
||||
if [ "$dry_run" = true ]; then
|
||||
release_info "==> Step 6/7: Skipping npm verification in dry-run mode..."
|
||||
else
|
||||
release_info "==> Step 6/7: Confirming npm package availability and dist-tag integrity..."
|
||||
VERIFY_ATTEMPTS="${NPM_PUBLISH_VERIFY_ATTEMPTS:-12}"
|
||||
VERIFY_DELAY_SECONDS="${NPM_PUBLISH_VERIFY_DELAY_SECONDS:-5}"
|
||||
REGISTRY_STATE_VERIFY_ATTEMPTS="${NPM_REGISTRY_STATE_VERIFY_ATTEMPTS:-12}"
|
||||
REGISTRY_STATE_VERIFY_DELAY_SECONDS="${NPM_REGISTRY_STATE_VERIFY_DELAY_SECONDS:-5}"
|
||||
MISSING_PUBLISHED_PACKAGES=""
|
||||
|
||||
while IFS=$'\t' read -r _pkg_dir pkg_name pkg_version; do
|
||||
[ -z "$pkg_name" ] && continue
|
||||
release_info " Checking $pkg_name@$pkg_version"
|
||||
if wait_for_npm_package_version "$pkg_name" "$pkg_version" "$VERIFY_ATTEMPTS" "$VERIFY_DELAY_SECONDS"; then
|
||||
release_info " ✓ Found on npm"
|
||||
continue
|
||||
fi
|
||||
|
||||
if [ -n "$MISSING_PUBLISHED_PACKAGES" ]; then
|
||||
MISSING_PUBLISHED_PACKAGES="${MISSING_PUBLISHED_PACKAGES}, "
|
||||
fi
|
||||
MISSING_PUBLISHED_PACKAGES="${MISSING_PUBLISHED_PACKAGES}${pkg_name}@${pkg_version}"
|
||||
done <<< "$VERSIONED_PACKAGE_INFO"
|
||||
|
||||
[ -z "$MISSING_PUBLISHED_PACKAGES" ] || release_fail "publish completed but npm never exposed: $MISSING_PUBLISHED_PACKAGES"
|
||||
|
||||
release_info " ✓ Verified all versioned packages are available on npm"
|
||||
|
||||
verify_args=(
|
||||
--channel "$channel"
|
||||
--dist-tag "$DIST_TAG"
|
||||
--target-version "$TARGET_PUBLISH_VERSION"
|
||||
)
|
||||
while IFS=$'\t' read -r _pkg_dir pkg_name _pkg_version; do
|
||||
[ -z "$pkg_name" ] && continue
|
||||
verify_args+=(--package "$pkg_name")
|
||||
done <<< "$VERSIONED_PACKAGE_INFO"
|
||||
|
||||
release_info " Waiting for npm dist-tags and package metadata to converge..."
|
||||
if wait_for_release_registry_state \
|
||||
"$REGISTRY_STATE_VERIFY_ATTEMPTS" \
|
||||
"$REGISTRY_STATE_VERIFY_DELAY_SECONDS" \
|
||||
"${verify_args[@]}"; then
|
||||
:
|
||||
else
|
||||
verify_status=$?
|
||||
if [ "$verify_status" -eq 2 ]; then
|
||||
release_fail "publish completed, but registry verification failed immediately for ${TARGET_PUBLISH_VERSION}; dist-tag state is wrong or requires operator intervention"
|
||||
fi
|
||||
|
||||
release_fail "publish completed, but npm dist-tags or registry metadata never converged for ${TARGET_PUBLISH_VERSION}"
|
||||
fi
|
||||
fi
|
||||
|
||||
release_info ""
|
||||
if [ "$dry_run" = true ]; then
|
||||
release_info "==> Step 7/7: Dry run complete..."
|
||||
else
|
||||
release_info "==> Step 7/7: Creating git tag..."
|
||||
git -C "$REPO_ROOT" tag "$tag_name" "$CURRENT_SHA"
|
||||
release_info " ✓ Created tag $tag_name on $CURRENT_SHA"
|
||||
fi
|
||||
|
||||
release_info ""
|
||||
if [ "$dry_run" = true ]; then
|
||||
release_info "Dry run complete for $channel ${TARGET_PUBLISH_VERSION}."
|
||||
else
|
||||
if [ "$channel" = "canary" ]; then
|
||||
release_info "Published canary ${TARGET_PUBLISH_VERSION}."
|
||||
release_info "Install with: npx paperclipai@canary onboard"
|
||||
release_info "Next step: git push ${PUBLISH_REMOTE} refs/tags/${tag_name}"
|
||||
else
|
||||
release_info "Published stable ${TARGET_PUBLISH_VERSION}."
|
||||
release_info "Next steps:"
|
||||
release_info " git push ${PUBLISH_REMOTE} refs/tags/${tag_name}"
|
||||
release_info " ./scripts/create-github-release.sh $TARGET_STABLE_VERSION"
|
||||
fi
|
||||
fi
|
||||
Executable
+111
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
|
||||
dry_run=false
|
||||
version=""
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
./scripts/rollback-latest.sh <stable-version> [--dry-run]
|
||||
|
||||
Examples:
|
||||
./scripts/rollback-latest.sh 2026.318.0
|
||||
./scripts/rollback-latest.sh 2026.318.0 --dry-run
|
||||
|
||||
Notes:
|
||||
- This repoints the npm dist-tag "latest" for every public package.
|
||||
- It does not unpublish anything.
|
||||
EOF
|
||||
}
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--dry-run) dry_run=true ;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
if [ -n "$version" ]; then
|
||||
echo "Error: only one version may be provided." >&2
|
||||
exit 1
|
||||
fi
|
||||
version="$1"
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
if [ -z "$version" ]; then
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "Error: version must be a stable calendar version like 2026.318.0." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$dry_run" = false ] && ! npm whoami >/dev/null 2>&1; then
|
||||
echo "Error: npm publish rights are required. Run 'npm login' first." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
list_public_package_names() {
|
||||
node - "$REPO_ROOT" <<'NODE'
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const root = process.argv[2];
|
||||
const roots = ['packages', 'server', 'ui', 'cli'];
|
||||
const seen = new Set();
|
||||
|
||||
function walk(relDir) {
|
||||
const absDir = path.join(root, relDir);
|
||||
const pkgPath = path.join(absDir, 'package.json');
|
||||
|
||||
if (fs.existsSync(pkgPath)) {
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
||||
if (!pkg.private && !seen.has(pkg.name)) {
|
||||
seen.add(pkg.name);
|
||||
process.stdout.write(`${pkg.name}\n`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(absDir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of fs.readdirSync(absDir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.git') continue;
|
||||
walk(path.join(relDir, entry.name));
|
||||
}
|
||||
}
|
||||
|
||||
for (const rel of roots) {
|
||||
walk(rel);
|
||||
}
|
||||
NODE
|
||||
}
|
||||
|
||||
package_names="$(list_public_package_names)"
|
||||
|
||||
if [ -z "$package_names" ]; then
|
||||
echo "Error: no public packages were found in the workspace." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
while IFS= read -r package_name; do
|
||||
[ -z "$package_name" ] && continue
|
||||
if [ "$dry_run" = true ]; then
|
||||
echo "[dry-run] npm dist-tag add ${package_name}@${version} latest"
|
||||
else
|
||||
npm dist-tag add "${package_name}@${version}" latest
|
||||
echo "Updated latest -> ${package_name}@${version}"
|
||||
fi
|
||||
done <<< "$package_names"
|
||||
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env node
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
|
||||
function fail(message) {
|
||||
console.error(`[typecheck:build-gaps] ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function run(command, args) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: repoRoot,
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
console.error(`[typecheck:build-gaps] Failed to spawn ${command}: ${result.error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
}
|
||||
|
||||
function readJson(filePath) {
|
||||
return JSON.parse(readFileSync(filePath, "utf8"));
|
||||
}
|
||||
|
||||
function listWorkspacePackages() {
|
||||
const result = spawnSync("pnpm", ["ls", "-r", "--depth", "-1", "--json"], {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
fail(`Unable to spawn pnpm to list workspace packages: ${result.error.message}`);
|
||||
}
|
||||
|
||||
if (result.status !== 0) {
|
||||
fail("Unable to list pnpm workspace packages.");
|
||||
}
|
||||
|
||||
return JSON.parse(result.stdout);
|
||||
}
|
||||
|
||||
function buildSkipsTypeScript(pkg) {
|
||||
const buildScript = pkg.scripts?.build;
|
||||
if (typeof buildScript !== "string") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !/\btsc\b/.test(buildScript);
|
||||
}
|
||||
|
||||
const workspacePackages = listWorkspacePackages();
|
||||
const buildGapCandidates = workspacePackages
|
||||
.filter((workspacePkg) => workspacePkg.path !== repoRoot)
|
||||
.map((workspacePkg) => ({
|
||||
name: workspacePkg.name,
|
||||
path: workspacePkg.path,
|
||||
pkg: readJson(path.join(workspacePkg.path, "package.json")),
|
||||
}))
|
||||
.filter(({ pkg }) => buildSkipsTypeScript(pkg));
|
||||
const packagesMissingTypecheck = buildGapCandidates.filter(
|
||||
({ pkg }) => typeof pkg.scripts?.typecheck !== "string",
|
||||
);
|
||||
if (packagesMissingTypecheck.length > 0) {
|
||||
const missingNames = packagesMissingTypecheck.map((workspacePkg) => workspacePkg.name).join(", ");
|
||||
fail(
|
||||
`Workspace packages with build scripts that skip tsc must define a typecheck script. Missing: ${missingNames}`,
|
||||
);
|
||||
}
|
||||
const buildGapPackages = buildGapCandidates.filter(
|
||||
({ pkg }) => typeof pkg.scripts?.typecheck === "string",
|
||||
);
|
||||
|
||||
console.log(
|
||||
`[typecheck:build-gaps] typechecking ${buildGapPackages.length} workspace(s): ${buildGapPackages.map(({ name }) => name).join(", ") || "(none)"}`,
|
||||
);
|
||||
|
||||
if (buildGapPackages.length === 0) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
run("pnpm", ["--filter", "@paperclipai/plugin-sdk", "ensure-build-deps"]);
|
||||
|
||||
for (const workspacePkg of buildGapPackages) {
|
||||
run("pnpm", ["--filter", workspacePkg.name, "typecheck"]);
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, readdirSync, statSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
const serverRoot = path.join(repoRoot, "server");
|
||||
const serverSrcDir = path.join(repoRoot, "server", "src");
|
||||
const serverTestsDir = path.join(repoRoot, "server", "src", "__tests__");
|
||||
const nonServerProjects = [
|
||||
"@paperclipai/shared",
|
||||
"@paperclipai/skills-catalog",
|
||||
"@paperclipai/db",
|
||||
"@paperclipai/adapter-utils",
|
||||
"@paperclipai/adapter-codex-local",
|
||||
"@paperclipai/adapter-opencode-local",
|
||||
"@paperclipai/plugin-sdk",
|
||||
"@paperclipai/create-paperclip-plugin",
|
||||
"@paperclipai/ui",
|
||||
"paperclipai",
|
||||
];
|
||||
const routeTestPattern = /[^/]*(?:route|routes|authz)[^/]*\.test\.ts$/;
|
||||
const additionalSerializedServerTests = new Set([
|
||||
"server/src/__tests__/approval-routes-idempotency.test.ts",
|
||||
"server/src/__tests__/assets.test.ts",
|
||||
"server/src/__tests__/authz-company-access.test.ts",
|
||||
"server/src/__tests__/companies-route-path-guard.test.ts",
|
||||
"server/src/__tests__/company-portability.test.ts",
|
||||
"server/src/__tests__/costs-service.test.ts",
|
||||
"server/src/__tests__/express5-auth-wildcard.test.ts",
|
||||
"server/src/__tests__/health-dev-server-token.test.ts",
|
||||
"server/src/__tests__/health.test.ts",
|
||||
"server/src/__tests__/heartbeat-dependency-scheduling.test.ts",
|
||||
"server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts",
|
||||
"server/src/__tests__/heartbeat-process-recovery.test.ts",
|
||||
"server/src/__tests__/invite-accept-existing-member.test.ts",
|
||||
"server/src/__tests__/invite-accept-gateway-defaults.test.ts",
|
||||
"server/src/__tests__/invite-accept-replay.test.ts",
|
||||
"server/src/__tests__/invite-expiry.test.ts",
|
||||
"server/src/__tests__/invite-join-manager.test.ts",
|
||||
"server/src/__tests__/invite-onboarding-text.test.ts",
|
||||
"server/src/__tests__/issues-checkout-wakeup.test.ts",
|
||||
"server/src/__tests__/issues-service.test.ts",
|
||||
"server/src/__tests__/opencode-local-adapter-environment.test.ts",
|
||||
"server/src/__tests__/project-routes-env.test.ts",
|
||||
"server/src/__tests__/redaction.test.ts",
|
||||
"server/src/__tests__/routines-e2e.test.ts",
|
||||
]);
|
||||
let invocationIndex = 0;
|
||||
const serializedModeName = "serialized";
|
||||
const generalModeName = "general";
|
||||
const allModeName = "all";
|
||||
const generalServerGroupName = "general-server";
|
||||
const generalWorkspacesAGroupName = "general-workspaces-a";
|
||||
const generalWorkspacesBGroupName = "general-workspaces-b";
|
||||
const generalWorkspacesAProjects = ["@paperclipai/ui", "paperclipai"];
|
||||
const generalWorkspacesBProjects = nonServerProjects.filter((project) => !generalWorkspacesAProjects.includes(project));
|
||||
const generalGroupNames = [generalServerGroupName, generalWorkspacesAGroupName, generalWorkspacesBGroupName];
|
||||
const serializedServerVitestArgs = [
|
||||
"--no-file-parallelism",
|
||||
"--maxWorkers=1",
|
||||
];
|
||||
|
||||
function walk(dir) {
|
||||
const entries = readdirSync(dir);
|
||||
const files = [];
|
||||
for (const entry of entries) {
|
||||
const absolute = path.join(dir, entry);
|
||||
const stats = statSync(absolute);
|
||||
if (stats.isDirectory()) {
|
||||
files.push(...walk(absolute));
|
||||
} else if (stats.isFile()) {
|
||||
files.push(absolute);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function toRepoPath(file) {
|
||||
return path.relative(repoRoot, file).split(path.sep).join("/");
|
||||
}
|
||||
|
||||
function toServerPath(file) {
|
||||
return path.relative(serverRoot, file).split(path.sep).join("/");
|
||||
}
|
||||
|
||||
function isRouteOrAuthzTest(file) {
|
||||
if (routeTestPattern.test(file)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return additionalSerializedServerTests.has(file);
|
||||
}
|
||||
|
||||
function fail(message) {
|
||||
console.error(`[test:run] ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function readOptionValue(argv, index, argName) {
|
||||
const value = argv[index + 1];
|
||||
if (value === undefined) {
|
||||
fail(`Missing value for ${argName}`);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseNonNegativeInteger(value, argName) {
|
||||
const parsed = Number(value);
|
||||
if (value.trim() === "" || !Number.isInteger(parsed) || parsed < 0) {
|
||||
fail(`${argName} must be a non-negative integer. Received "${value}".`);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value, argName) {
|
||||
const parsed = Number(value);
|
||||
if (value.trim() === "" || !Number.isInteger(parsed) || parsed < 1) {
|
||||
fail(`${argName} must be a positive integer. Received "${value}".`);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseCliOptions(argv) {
|
||||
let mode = allModeName;
|
||||
let shardIndex = null;
|
||||
let shardCount = null;
|
||||
let group = null;
|
||||
let dryRun = false;
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === "--") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--mode") {
|
||||
mode = readOptionValue(argv, index, arg);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.startsWith("--mode=")) {
|
||||
mode = arg.slice("--mode=".length);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--shard-index") {
|
||||
shardIndex = parseNonNegativeInteger(readOptionValue(argv, index, arg), arg);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.startsWith("--shard-index=")) {
|
||||
shardIndex = parseNonNegativeInteger(arg.slice("--shard-index=".length), "--shard-index");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--shard-count") {
|
||||
shardCount = parsePositiveInteger(readOptionValue(argv, index, arg), arg);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.startsWith("--shard-count=")) {
|
||||
shardCount = parsePositiveInteger(arg.slice("--shard-count=".length), "--shard-count");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--dry-run") {
|
||||
dryRun = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--group") {
|
||||
group = readOptionValue(argv, index, arg);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.startsWith("--group=")) {
|
||||
group = arg.slice("--group=".length);
|
||||
continue;
|
||||
}
|
||||
|
||||
fail(`Unknown argument "${arg}".`);
|
||||
}
|
||||
|
||||
if (!new Set([allModeName, generalModeName, serializedModeName]).has(mode)) {
|
||||
fail(`Unknown mode "${mode}". Expected one of: ${allModeName}, ${generalModeName}, ${serializedModeName}.`);
|
||||
}
|
||||
|
||||
if ((shardIndex === null) !== (shardCount === null)) {
|
||||
fail("--shard-index and --shard-count must be provided together.");
|
||||
}
|
||||
|
||||
const shardAllowed =
|
||||
mode === serializedModeName ||
|
||||
(mode === generalModeName && group === generalServerGroupName);
|
||||
if (!shardAllowed && shardIndex !== null) {
|
||||
fail(
|
||||
"--shard-index/--shard-count are only valid with --mode serialized or --mode general --group general-server.",
|
||||
);
|
||||
}
|
||||
|
||||
if (group !== null && mode !== generalModeName) {
|
||||
fail("--group is only valid with --mode general.");
|
||||
}
|
||||
|
||||
if (group !== null && !generalGroupNames.includes(group)) {
|
||||
fail(`Unknown group "${group}". Expected one of: ${generalGroupNames.join(", ")}.`);
|
||||
}
|
||||
|
||||
if (shardIndex !== null) {
|
||||
if (shardIndex >= shardCount) {
|
||||
fail(`--shard-index must be less than --shard-count. Received ${shardIndex} of ${shardCount}.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === serializedModeName) {
|
||||
return {
|
||||
mode,
|
||||
shardIndex: shardIndex ?? 0,
|
||||
shardCount: shardCount ?? 1,
|
||||
group: null,
|
||||
dryRun,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
mode,
|
||||
shardIndex,
|
||||
shardCount,
|
||||
group,
|
||||
dryRun,
|
||||
};
|
||||
}
|
||||
|
||||
function selectSerializedSuites(routeTests, shardIndex, shardCount) {
|
||||
return routeTests.filter((_, index) => index % shardCount === shardIndex);
|
||||
}
|
||||
|
||||
function runVitest(args, label) {
|
||||
console.log(`\n[test:run] ${label}`);
|
||||
invocationIndex += 1;
|
||||
const tempRootParent = process.platform === "win32" ? os.tmpdir() : "/tmp";
|
||||
const testRoot = mkdtempSync(path.join(tempRootParent, `pcvt-${process.pid}-${invocationIndex}-`));
|
||||
// Keep per-run paths compact so Unix socket fixtures stay under macOS path limits.
|
||||
const env = {
|
||||
...process.env,
|
||||
NODE_ENV: "test",
|
||||
PAPERCLIP_HOME: path.join(testRoot, "h"),
|
||||
PAPERCLIP_INSTANCE_ID: `vt-${process.pid}-${invocationIndex}`,
|
||||
TMPDIR: path.join(testRoot, "t"),
|
||||
};
|
||||
mkdirSync(env.PAPERCLIP_HOME, { recursive: true });
|
||||
mkdirSync(env.TMPDIR, { recursive: true });
|
||||
const result = spawnSync("pnpm", ["exec", "vitest", "run", ...args], {
|
||||
cwd: repoRoot,
|
||||
env,
|
||||
stdio: "inherit",
|
||||
});
|
||||
if (result.error) {
|
||||
console.error(`[test:run] Failed to start Vitest: ${result.error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
}
|
||||
|
||||
function runGeneralSuites(routeTests) {
|
||||
for (const groupName of generalGroupNames) {
|
||||
runGeneralGroup(routeTests, groupName);
|
||||
}
|
||||
}
|
||||
|
||||
function runProjectGroup(projects, groupName) {
|
||||
for (const project of projects) {
|
||||
runVitest(["--project", project], `${groupName} project ${project}`);
|
||||
}
|
||||
}
|
||||
|
||||
function runGeneralGroup(routeTests, groupName, shardIndex = null, shardCount = null) {
|
||||
if (groupName === generalServerGroupName) {
|
||||
if (shardCount !== null && shardCount > 1) {
|
||||
const shardFiles = generalServerTestFiles.filter(
|
||||
(_, index) => index % shardCount === shardIndex,
|
||||
);
|
||||
console.log(
|
||||
`\n[test:run] general-server shard ${shardIndex + 1}/${shardCount} running ${shardFiles.length} of ${generalServerTestFiles.length} suites`,
|
||||
);
|
||||
if (shardFiles.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
runVitest(
|
||||
[
|
||||
"--project",
|
||||
"@paperclipai/server",
|
||||
...serializedServerVitestArgs,
|
||||
...shardFiles,
|
||||
],
|
||||
`${groupName} shard ${shardIndex + 1}/${shardCount}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const excludeRouteArgs = routeTests.flatMap((file) => ["--exclude", file.serverPath]);
|
||||
runVitest(
|
||||
[
|
||||
"--project",
|
||||
"@paperclipai/server",
|
||||
...serializedServerVitestArgs,
|
||||
...excludeRouteArgs,
|
||||
],
|
||||
`${groupName} server suites excluding ${routeTests.length} serialized suites`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (groupName === generalWorkspacesAGroupName) {
|
||||
runProjectGroup(generalWorkspacesAProjects, groupName);
|
||||
return;
|
||||
}
|
||||
|
||||
if (groupName === generalWorkspacesBGroupName) {
|
||||
runProjectGroup(generalWorkspacesBProjects, groupName);
|
||||
return;
|
||||
}
|
||||
|
||||
fail(`Unknown group "${groupName}".`);
|
||||
}
|
||||
|
||||
function runSerializedSuites(routeTests, shardIndex, shardCount) {
|
||||
const shardTests = selectSerializedSuites(routeTests, shardIndex, shardCount);
|
||||
console.log(
|
||||
`\n[test:run] serialized shard ${shardIndex + 1}/${shardCount} running ${shardTests.length} of ${routeTests.length} suites`,
|
||||
);
|
||||
|
||||
for (const routeTest of shardTests) {
|
||||
runVitest(
|
||||
[
|
||||
"--project",
|
||||
"@paperclipai/server",
|
||||
routeTest.repoPath,
|
||||
"--pool=forks",
|
||||
"--isolate",
|
||||
],
|
||||
routeTest.repoPath,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const routeTests = walk(serverTestsDir)
|
||||
.filter((file) => isRouteOrAuthzTest(toRepoPath(file)))
|
||||
.map((file) => ({
|
||||
repoPath: toRepoPath(file),
|
||||
serverPath: toServerPath(file),
|
||||
}))
|
||||
.sort((a, b) => a.repoPath.localeCompare(b.repoPath));
|
||||
|
||||
// Every server test file that the general-server group is responsible for,
|
||||
// i.e. the whole server project minus the route/authz suites that run in the
|
||||
// dedicated serialized shards. Sharding this list across runners is what keeps
|
||||
// the general-server lane from becoming the PR critical path: the server vitest
|
||||
// config pins maxWorkers to 1, so the only way to parallelize is across jobs.
|
||||
const generalServerTestFiles = walk(serverSrcDir)
|
||||
.map((file) => toRepoPath(file))
|
||||
.filter((repoPath) => repoPath.endsWith(".test.ts"))
|
||||
.filter((repoPath) => !isRouteOrAuthzTest(repoPath))
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
|
||||
const options = parseCliOptions(process.argv.slice(2));
|
||||
if (options.dryRun) {
|
||||
const serializedSuites =
|
||||
options.mode === serializedModeName
|
||||
? selectSerializedSuites(routeTests, options.shardIndex, options.shardCount)
|
||||
: routeTests;
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
mode: options.mode,
|
||||
shardIndex: options.shardIndex,
|
||||
shardCount: options.shardCount,
|
||||
group: options.group,
|
||||
availableGeneralGroups: generalGroupNames,
|
||||
serializedSuiteCount: routeTests.length,
|
||||
selectedSerializedSuites: serializedSuites.map((routeTest) => routeTest.repoPath),
|
||||
generalServerSuiteCount: generalServerTestFiles.length,
|
||||
selectedGeneralServerSuites:
|
||||
options.mode === generalModeName &&
|
||||
options.group === generalServerGroupName &&
|
||||
options.shardCount !== null
|
||||
? generalServerTestFiles.filter(
|
||||
(_, index) => index % options.shardCount === options.shardIndex,
|
||||
)
|
||||
: null,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (options.mode === generalModeName || options.mode === allModeName) {
|
||||
if (options.group) {
|
||||
runGeneralGroup(routeTests, options.group, options.shardIndex, options.shardCount);
|
||||
} else {
|
||||
runGeneralSuites(routeTests);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.mode === serializedModeName || options.mode === allModeName) {
|
||||
runSerializedSuites(routeTests, options.shardIndex ?? 0, options.shardCount ?? 1);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env node
|
||||
// Capture screenshots of the Blocked Inbox storybook stories.
|
||||
// Usage: node scripts/screenshot-blocked-inbox.mjs <storybook-static-dir> <output-dir>
|
||||
|
||||
import http from "node:http";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs/promises";
|
||||
import { chromium } from "@playwright/test";
|
||||
|
||||
async function main() {
|
||||
const [, , staticDir, outDir] = process.argv;
|
||||
if (!staticDir || !outDir) {
|
||||
console.error("usage: node scripts/screenshot-blocked-inbox.mjs <storybook-static-dir> <output-dir>");
|
||||
process.exit(1);
|
||||
}
|
||||
await fs.mkdir(outDir, { recursive: true });
|
||||
const absStaticDir = path.resolve(staticDir);
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
let urlPath = decodeURIComponent((req.url || "/").split("?")[0]);
|
||||
if (urlPath.endsWith("/")) urlPath += "iframe.html";
|
||||
const filePath = path.resolve(absStaticDir, `.${urlPath}`);
|
||||
if (!filePath.startsWith(absStaticDir + path.sep) && filePath !== absStaticDir) {
|
||||
res.writeHead(403);
|
||||
res.end("Forbidden");
|
||||
return;
|
||||
}
|
||||
const buf = await fs.readFile(filePath);
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const mime = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".js": "application/javascript",
|
||||
".css": "text/css",
|
||||
".json": "application/json",
|
||||
".svg": "image/svg+xml",
|
||||
".png": "image/png",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
".map": "application/json",
|
||||
}[ext] || "application/octet-stream";
|
||||
res.writeHead(200, { "content-type": mime });
|
||||
res.end(buf);
|
||||
} catch (err) {
|
||||
res.writeHead(404);
|
||||
res.end(String(err));
|
||||
}
|
||||
});
|
||||
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const port = server.address().port;
|
||||
const baseUrl = `http://127.0.0.1:${port}/iframe.html`;
|
||||
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
const stories = [
|
||||
{ id: "product-inbox-blocked-tab--desktop-loaded", file: "01-desktop-loaded.png", width: 1440, height: 1100, dark: false },
|
||||
{ id: "product-inbox-blocked-tab--desktop-loaded", file: "02-desktop-loaded-dark.png", width: 1440, height: 1100, dark: true },
|
||||
{ id: "product-inbox-blocked-tab--desktop-with-search", file: "03-desktop-with-search.png", width: 1440, height: 800, dark: false },
|
||||
{ id: "product-inbox-blocked-tab--mobile-layout", file: "04-mobile-layout.png", width: 390, height: 1100, dark: false },
|
||||
{ id: "product-inbox-blocked-tab--reason-chip-catalog", file: "05-reason-chip-catalog.png", width: 900, height: 600, dark: false },
|
||||
{ id: "product-inbox-blocked-tab--empty-state", file: "06-empty-state.png", width: 900, height: 500, dark: false },
|
||||
];
|
||||
|
||||
for (const story of stories) {
|
||||
const ctx = await browser.newContext({
|
||||
viewport: { width: story.width, height: story.height },
|
||||
deviceScaleFactor: 2,
|
||||
colorScheme: story.dark ? "dark" : "light",
|
||||
});
|
||||
const page = await ctx.newPage();
|
||||
const url = `${baseUrl}?id=${story.id}&viewMode=story`;
|
||||
await page.goto(url, { waitUntil: "networkidle" });
|
||||
// Force light/dark class on <html> for tailwind dark mode tokens
|
||||
await page.evaluate((dark) => {
|
||||
const html = document.documentElement;
|
||||
html.classList.toggle("dark", dark);
|
||||
html.style.colorScheme = dark ? "dark" : "light";
|
||||
}, story.dark);
|
||||
await page.waitForTimeout(500);
|
||||
const out = path.join(outDir, story.file);
|
||||
await page.screenshot({ path: out, fullPage: true });
|
||||
console.log("wrote", out);
|
||||
await ctx.close();
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
server.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env node
|
||||
// Screenshots every FileViewerSheet Storybook state at desktop + mobile viewports.
|
||||
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { chromium } from "@playwright/test";
|
||||
|
||||
const BASE = "http://localhost:6006/iframe.html";
|
||||
const OUT_DIR = path.resolve(process.argv[2] || "/tmp/pap-1963");
|
||||
|
||||
const SHOTS = [
|
||||
{ id: "components-file-viewer-sheet--text-content-with-highlighted-line", label: "content-highlighted-line" },
|
||||
{ id: "components-file-viewer-sheet--text-content-long-path-truncation", label: "content-long-path" },
|
||||
{ id: "components-file-viewer-sheet--open-file-prompt", label: "open-file-prompt" },
|
||||
{ id: "components-file-viewer-sheet--loading-spinner", label: "loading-spinner" },
|
||||
{ id: "components-file-viewer-sheet--error-not-found-with-fallback", label: "error-not-found" },
|
||||
{ id: "components-file-viewer-sheet--error-no-workspace", label: "error-no-workspace" },
|
||||
{ id: "components-file-viewer-sheet--error-outside-workspace", label: "error-outside-workspace" },
|
||||
{ id: "components-file-viewer-sheet--error-denied-sensitive", label: "error-denied" },
|
||||
{ id: "components-file-viewer-sheet--remote-workspace", label: "remote-workspace" },
|
||||
{ id: "components-file-viewer-sheet--workspace-archived", label: "workspace-archived" },
|
||||
{ id: "components-file-viewer-sheet--binary-unsupported", label: "binary-unsupported" },
|
||||
{ id: "components-file-viewer-sheet--too-large-to-preview", label: "too-large" },
|
||||
];
|
||||
|
||||
const VIEWPORTS = [
|
||||
{ name: "desktop", width: 1440, height: 900 },
|
||||
{ name: "mobile", width: 390, height: 844 },
|
||||
];
|
||||
|
||||
async function run() {
|
||||
await fs.mkdir(OUT_DIR, { recursive: true });
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
for (const vp of VIEWPORTS) {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: vp.width, height: vp.height },
|
||||
deviceScaleFactor: 2,
|
||||
});
|
||||
const page = await context.newPage();
|
||||
for (const shot of SHOTS) {
|
||||
const url = `${BASE}?id=${shot.id}&viewMode=story&args=`;
|
||||
console.log(`[${vp.name}] → ${shot.label}`);
|
||||
await page.goto(url, { waitUntil: "domcontentloaded" });
|
||||
// Give the sheet animation + potential network stubs ~1.5s to settle.
|
||||
await page.waitForTimeout(shot.label === "loading-spinner" ? 900 : 1600);
|
||||
const out = path.join(OUT_DIR, `${vp.name}-${shot.label}.png`);
|
||||
await page.screenshot({ path: out, fullPage: false });
|
||||
}
|
||||
// Extra capture: mobile view story (its own viewport story) as a cross-check.
|
||||
if (vp.name === "mobile") {
|
||||
await page.goto(
|
||||
`${BASE}?id=components-file-viewer-sheet--mobile-view&viewMode=story&args=`,
|
||||
{ waitUntil: "domcontentloaded" },
|
||||
);
|
||||
await page.waitForTimeout(1600);
|
||||
await page.screenshot({
|
||||
path: path.join(OUT_DIR, `mobile-story-variant.png`),
|
||||
fullPage: false,
|
||||
});
|
||||
}
|
||||
await context.close();
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
console.log(`\nScreenshots written to ${OUT_DIR}`);
|
||||
}
|
||||
|
||||
run().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env node
|
||||
// Screenshot a single story id at desktop 1440x900.
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { chromium } from "@playwright/test";
|
||||
|
||||
const [, , storyId, out, widthArg, heightArg, waitArg] = process.argv;
|
||||
const width = Number(widthArg) || 1440;
|
||||
const height = Number(heightArg) || 900;
|
||||
const waitMs = Number(waitArg) || 2500;
|
||||
const url = `http://localhost:6006/iframe.html?id=${storyId}&viewMode=story`;
|
||||
|
||||
await fs.mkdir(path.dirname(path.resolve(out)), { recursive: true });
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
const ctx = await browser.newContext({
|
||||
viewport: { width, height },
|
||||
deviceScaleFactor: 2,
|
||||
});
|
||||
const page = await ctx.newPage();
|
||||
await page.goto(url, { waitUntil: "networkidle" });
|
||||
await page.waitForTimeout(waitMs);
|
||||
await page.screenshot({ path: out, fullPage: false });
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
console.log(`Wrote ${out}`);
|
||||
@@ -0,0 +1,35 @@
|
||||
import { chromium } from "@playwright/test";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { argv } from "node:process";
|
||||
|
||||
const outDir = argv[2] ?? "/tmp/paperclip/pap-2373-stalled-blocker-screens";
|
||||
const baseUrl = argv[3] ?? "http://localhost:6610";
|
||||
await mkdir(outDir, { recursive: true });
|
||||
|
||||
const id = "foundations-status-language--full-matrix";
|
||||
const runs = [
|
||||
{ name: "desktop-1440x900-light", w: 1440, h: 900, theme: "light" },
|
||||
{ name: "desktop-1440x900-dark", w: 1440, h: 900, theme: "dark" },
|
||||
{ name: "mobile-390x844-light", w: 390, h: 844, theme: "light" },
|
||||
{ name: "mobile-390x844-dark", w: 390, h: 844, theme: "dark" },
|
||||
];
|
||||
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
for (const run of runs) {
|
||||
const url = `${baseUrl}/iframe.html?id=${id}&viewMode=story&globals=theme:${run.theme}`;
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: run.w, height: run.h },
|
||||
deviceScaleFactor: 2,
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await page.goto(url, { waitUntil: "networkidle", timeout: 30_000 });
|
||||
await page.waitForTimeout(1500);
|
||||
const file = `${outDir}/${run.name}.png`;
|
||||
await page.screenshot({ path: file, fullPage: true });
|
||||
console.log("wrote", file);
|
||||
await context.close();
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env node
|
||||
/* eslint-disable no-console */
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
|
||||
const repoRoot = path.resolve(__dirname, "..");
|
||||
const playwright = require(path.join(
|
||||
repoRoot,
|
||||
"node_modules/.pnpm/playwright@1.58.2/node_modules/playwright/index.js",
|
||||
));
|
||||
|
||||
const STORYBOOK_BASE = process.env.STORYBOOK_URL ?? "http://localhost:6006";
|
||||
const OUT_DIR = path.resolve(repoRoot, "tmp/pap-9134-recovery-screens");
|
||||
|
||||
const VIEWPORTS = [
|
||||
{ name: "desktop", width: 1440, height: 900 },
|
||||
{ name: "mobile", width: 390, height: 844 },
|
||||
];
|
||||
|
||||
const STORIES = [
|
||||
{
|
||||
id: "paperclip-source-issue-recovery--recovery-action-card-states",
|
||||
label: "card-states",
|
||||
},
|
||||
{
|
||||
id: "paperclip-source-issue-recovery--inbox-row-chips",
|
||||
label: "inbox-rows",
|
||||
},
|
||||
{
|
||||
id: "paperclip-source-issue-recovery--blocker-notice-recovery-indicators",
|
||||
label: "blocker-notice",
|
||||
},
|
||||
{
|
||||
id: "paperclip-source-issue-recovery--active-run-panel-recovery-chips",
|
||||
label: "active-run-panel",
|
||||
},
|
||||
];
|
||||
|
||||
const THEMES = [
|
||||
{ name: "light", apply: () => document.documentElement.classList.remove("dark") },
|
||||
{ name: "dark", apply: () => document.documentElement.classList.add("dark") },
|
||||
];
|
||||
|
||||
(async () => {
|
||||
fs.mkdirSync(OUT_DIR, { recursive: true });
|
||||
const browser = await playwright.chromium.launch();
|
||||
try {
|
||||
for (const viewport of VIEWPORTS) {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: viewport.width, height: viewport.height },
|
||||
deviceScaleFactor: 2,
|
||||
});
|
||||
for (const story of STORIES) {
|
||||
for (const theme of THEMES) {
|
||||
const page = await context.newPage();
|
||||
const url = `${STORYBOOK_BASE}/iframe.html?id=${story.id}&viewMode=story`;
|
||||
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30_000 });
|
||||
await page.waitForLoadState("networkidle", { timeout: 30_000 }).catch(() => {});
|
||||
await page.evaluate((darkTheme) => {
|
||||
const html = document.documentElement;
|
||||
if (darkTheme) {
|
||||
html.classList.add("dark");
|
||||
} else {
|
||||
html.classList.remove("dark");
|
||||
}
|
||||
}, theme.name === "dark");
|
||||
await page.waitForTimeout(400);
|
||||
const outPath = path.join(
|
||||
OUT_DIR,
|
||||
`${story.label}_${viewport.name}_${theme.name}.png`,
|
||||
);
|
||||
await page.screenshot({ path: outPath, fullPage: true });
|
||||
console.log(`Saved ${outPath}`);
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
await context.close();
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
})().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { chromium } from "@playwright/test";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { argv } from "node:process";
|
||||
|
||||
const outDir = argv[2] ?? "/tmp/paperclip/pap-2189-subissues-screens";
|
||||
const baseUrl = argv[3] ?? "http://localhost:6006";
|
||||
await mkdir(outDir, { recursive: true });
|
||||
|
||||
const id = "ux-labs-sub-issues-workflow-checklist--default";
|
||||
const runs = [
|
||||
{ name: "desktop-1440x900-dark", w: 1440, h: 900, theme: "dark" },
|
||||
{ name: "desktop-1440x900-light", w: 1440, h: 900, theme: "light" },
|
||||
{ name: "mobile-390x844-dark", w: 390, h: 844, theme: "dark" },
|
||||
{ name: "mobile-390x844-light", w: 390, h: 844, theme: "light" },
|
||||
];
|
||||
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
for (const run of runs) {
|
||||
const url = `${baseUrl}/iframe.html?id=${id}&viewMode=story&globals=theme:${run.theme}`;
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: run.w, height: run.h },
|
||||
deviceScaleFactor: 2,
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await page.goto(url, { waitUntil: "networkidle", timeout: 30_000 });
|
||||
await page.waitForTimeout(1200);
|
||||
const file = `${outDir}/${run.name}.png`;
|
||||
await page.screenshot({ path: file, fullPage: true });
|
||||
console.log("wrote", file);
|
||||
await context.close();
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Screenshot utility for Paperclip UI.
|
||||
*
|
||||
* Reads the board token from ~/.paperclip/auth.json and injects it as a
|
||||
* Bearer header so Playwright can access authenticated pages.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/screenshot.cjs <url-or-path> [output.png] [--width 1280] [--height 800] [--wait 2000]
|
||||
*
|
||||
* Examples:
|
||||
* node scripts/screenshot.cjs /PAPA/agents/cto/instructions /tmp/shot.png
|
||||
* node scripts/screenshot.cjs http://localhost:5173/PAPA/agents/cto/instructions
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const os = require("os");
|
||||
|
||||
// --- CLI args -----------------------------------------------------------
|
||||
const args = process.argv.slice(2);
|
||||
function flag(name, fallback) {
|
||||
const i = args.indexOf(`--${name}`);
|
||||
if (i === -1) return fallback;
|
||||
const val = args.splice(i, 2)[1];
|
||||
return Number.isNaN(Number(val)) ? fallback : Number(val);
|
||||
}
|
||||
const width = flag("width", 1280);
|
||||
const height = flag("height", 800);
|
||||
const waitMs = flag("wait", 2000);
|
||||
|
||||
const rawUrl = args[0];
|
||||
const outPath = args[1] || "/tmp/paperclip-screenshot.png";
|
||||
|
||||
if (!rawUrl) {
|
||||
console.error("Usage: node scripts/screenshot.cjs <url-or-path> [output.png]");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// --- Auth ----------------------------------------------------------------
|
||||
function loadBoardToken() {
|
||||
const authPath = path.resolve(os.homedir(), ".paperclip/auth.json");
|
||||
try {
|
||||
const auth = JSON.parse(fs.readFileSync(authPath, "utf-8"));
|
||||
const creds = auth.credentials || {};
|
||||
const entry = Object.values(creds)[0];
|
||||
if (entry && entry.token && entry.apiBase) return { token: entry.token, apiBase: entry.apiBase };
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const cred = loadBoardToken();
|
||||
if (!cred) {
|
||||
console.error("No board token found in ~/.paperclip/auth.json");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Resolve URL — if it starts with / treat as path relative to apiBase
|
||||
const url = rawUrl.startsWith("http") ? rawUrl : `${cred.apiBase}${rawUrl}`;
|
||||
|
||||
// Validate URL before launching browser
|
||||
const origin = new URL(url).origin;
|
||||
|
||||
// --- Screenshot ----------------------------------------------------------
|
||||
(async () => {
|
||||
const { chromium } = require("playwright");
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width, height },
|
||||
});
|
||||
|
||||
const page = await context.newPage();
|
||||
// Scope the auth header to the Paperclip origin only
|
||||
await page.route(`${origin}/**`, async (route) => {
|
||||
await route.continue({
|
||||
headers: { ...route.request().headers(), Authorization: `Bearer ${cred.token}` },
|
||||
});
|
||||
});
|
||||
await page.goto(url, { waitUntil: "networkidle", timeout: 20000 });
|
||||
await page.waitForTimeout(waitMs);
|
||||
await page.screenshot({ path: outPath, fullPage: false });
|
||||
console.log(`Saved: ${outPath}`);
|
||||
} catch (err) {
|
||||
console.error(`Screenshot failed: ${err.message}`);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env node
|
||||
// Tiny dependency-free static file server for the built Storybook
|
||||
// (ui/storybook-static). Used by the visual snapshot suite's webServer so we
|
||||
// don't add an http-server dependency.
|
||||
import { createServer } from "node:http";
|
||||
import { createReadStream, existsSync, statSync } from "node:fs";
|
||||
import { extname, join, normalize, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = resolve(
|
||||
fileURLToPath(new URL(".", import.meta.url)),
|
||||
"..",
|
||||
"ui",
|
||||
"storybook-static",
|
||||
);
|
||||
const portArgIndex = process.argv.indexOf("--port");
|
||||
const explicitPort =
|
||||
portArgIndex >= 0 &&
|
||||
process.argv[portArgIndex + 1] &&
|
||||
!process.argv[portArgIndex + 1].startsWith("--")
|
||||
? process.argv[portArgIndex + 1]
|
||||
: null;
|
||||
const portSource = explicitPort ?? process.env.PORT ?? 6106;
|
||||
const port = Number(portSource);
|
||||
|
||||
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
||||
console.error(`Invalid Storybook static server port: ${portSource}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!existsSync(join(root, "index.html"))) {
|
||||
console.error(`No built Storybook at ${root}. Run \`pnpm build-storybook\` first.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const MIME = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".mjs": "text/javascript; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".svg": "image/svg+xml",
|
||||
".ico": "image/x-icon",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
".ttf": "font/ttf",
|
||||
".map": "application/json; charset=utf-8",
|
||||
".txt": "text/plain; charset=utf-8",
|
||||
".webm": "video/webm",
|
||||
".mp4": "video/mp4",
|
||||
};
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
const url = new URL(req.url ?? "/", `http://localhost:${port}`);
|
||||
let filePath = normalize(join(root, decodeURIComponent(url.pathname)));
|
||||
if (!filePath.startsWith(root)) {
|
||||
res.writeHead(403).end("forbidden");
|
||||
return;
|
||||
}
|
||||
if (existsSync(filePath) && statSync(filePath).isDirectory()) {
|
||||
filePath = join(filePath, "index.html");
|
||||
}
|
||||
if (!existsSync(filePath)) {
|
||||
res.writeHead(404).end("not found");
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, {
|
||||
"content-type": MIME[extname(filePath)] ?? "application/octet-stream",
|
||||
"cache-control": "no-store",
|
||||
});
|
||||
createReadStream(filePath).pipe(res);
|
||||
});
|
||||
|
||||
server.listen(port, () => {
|
||||
console.log(`storybook-static served at http://localhost:${port}`);
|
||||
});
|
||||
Executable
+1007
File diff suppressed because it is too large.
Load diff
Executable
+450
@@ -0,0 +1,450 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
log() {
|
||||
echo "[hermes-gateway-join] $*"
|
||||
}
|
||||
|
||||
warn() {
|
||||
echo "[hermes-gateway-join] WARN: $*" >&2
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo "[hermes-gateway-join] ERROR: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
require_cmd() {
|
||||
local cmd="$1"
|
||||
command -v "$cmd" >/dev/null 2>&1 || fail "missing required command: ${cmd}"
|
||||
}
|
||||
|
||||
require_cmd curl
|
||||
require_cmd jq
|
||||
|
||||
PAPERCLIP_API_URL="${PAPERCLIP_API_URL:-http://localhost:3100}"
|
||||
API_BASE="${PAPERCLIP_API_URL%/}/api"
|
||||
COMPANY_ID="${COMPANY_ID:-${PAPERCLIP_COMPANY_ID:-}}"
|
||||
COMPANY_SELECTOR="${COMPANY_SELECTOR:-}"
|
||||
|
||||
HERMES_AGENT_NAME="${HERMES_AGENT_NAME:-Hermes Gateway Smoke Agent}"
|
||||
HERMES_GATEWAY_API_BASE_URL="${HERMES_GATEWAY_API_BASE_URL:-http://127.0.0.1:${HERMES_GATEWAY_PORT:-8642}}"
|
||||
HERMES_GATEWAY_PROBE_URL="${HERMES_GATEWAY_PROBE_URL:-$HERMES_GATEWAY_API_BASE_URL}"
|
||||
HERMES_GATEWAY_API_KEY="${HERMES_GATEWAY_API_KEY:-${API_SERVER_KEY:-}}"
|
||||
HERMES_GATEWAY_ALLOW_INSECURE_HTTP="${HERMES_GATEWAY_ALLOW_INSECURE_HTTP:-0}"
|
||||
HERMES_GATEWAY_SESSION_KEY_STRATEGY="${HERMES_GATEWAY_SESSION_KEY_STRATEGY:-issue}"
|
||||
HERMES_GATEWAY_TIMEOUT_SEC="${HERMES_GATEWAY_TIMEOUT_SEC:-180}"
|
||||
PAPERCLIP_API_URL_FOR_HERMES="${PAPERCLIP_API_URL_FOR_HERMES:-}"
|
||||
GATEWAY_PROBE_TIMEOUT_SEC="${GATEWAY_PROBE_TIMEOUT_SEC:-4}"
|
||||
HERMES_JOIN_OUTPUT_FILE="${HERMES_JOIN_OUTPUT_FILE:-}"
|
||||
|
||||
print_usage() {
|
||||
cat <<'EOF'
|
||||
Hermes gateway join smoke
|
||||
|
||||
Creates a Hermes gateway agent from an agent-only Paperclip invite, approves the
|
||||
join request, claims the one-time Paperclip API key, and verifies the stored
|
||||
adapter config without printing raw secrets.
|
||||
|
||||
Required:
|
||||
PAPERCLIP_API_URL=http://127.0.0.1:3100
|
||||
PAPERCLIP_AUTH_HEADER='Bearer <board-token>' # or PAPERCLIP_COOKIE
|
||||
HERMES_GATEWAY_API_KEY=<API_SERVER_KEY>
|
||||
|
||||
Common flags:
|
||||
COMPANY_ID=<uuid> or COMPANY_SELECTOR=<prefix|name|uuid>
|
||||
HERMES_GATEWAY_API_BASE_URL=http://127.0.0.1:8642
|
||||
HERMES_GATEWAY_PROBE_URL=http://127.0.0.1:8642
|
||||
PAPERCLIP_API_URL_FOR_HERMES=http://host.docker.internal:3100
|
||||
HERMES_GATEWAY_ALLOW_INSECURE_HTTP=1 # dev-only non-loopback HTTP
|
||||
HERMES_GATEWAY_SESSION_KEY_STRATEGY=issue|agent|run|none
|
||||
HERMES_JOIN_OUTPUT_FILE=/secure/path/join-output.json
|
||||
|
||||
Notes:
|
||||
HERMES_GATEWAY_API_BASE_URL is stored on the Paperclip adapter and must be
|
||||
reachable by the Paperclip server. HERMES_GATEWAY_PROBE_URL is only used by
|
||||
this operator shell to preflight /health, which is useful when Paperclip talks
|
||||
to the gateway over a Docker network name but the operator probes localhost.
|
||||
|
||||
Raw API keys are redacted from logs. HERMES_JOIN_OUTPUT_FILE contains the
|
||||
claimed Paperclip agent API key and is written chmod 600.
|
||||
|
||||
See doc/HERMES_GATEWAY_SMOKE.md for Docker Desktop, Linux, same-network,
|
||||
LAN/private-network, and reverse-proxy/TLS examples.
|
||||
EOF
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
-h|--help)
|
||||
print_usage
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
AUTH_HEADERS=()
|
||||
if [[ -n "${PAPERCLIP_AUTH_HEADER:-}" ]]; then
|
||||
AUTH_HEADERS+=(-H "Authorization: ${PAPERCLIP_AUTH_HEADER}")
|
||||
elif [[ -n "${PAPERCLIP_API_KEY:-}" ]]; then
|
||||
AUTH_HEADERS+=(-H "Authorization: Bearer ${PAPERCLIP_API_KEY}")
|
||||
fi
|
||||
if [[ -n "${PAPERCLIP_COOKIE:-}" ]]; then
|
||||
AUTH_HEADERS+=(-H "Cookie: ${PAPERCLIP_COOKIE}")
|
||||
fi
|
||||
|
||||
RESPONSE_CODE=""
|
||||
RESPONSE_BODY=""
|
||||
CLAIM_SECRET=""
|
||||
AGENT_API_KEY=""
|
||||
|
||||
hash_prefix() {
|
||||
local value="$1"
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
printf "%s" "$value" | sha256sum | awk '{print substr($1,1,12)}'
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
printf "%s" "$value" | shasum -a 256 | awk '{print substr($1,1,12)}'
|
||||
else
|
||||
printf "unavailable"
|
||||
fi
|
||||
}
|
||||
|
||||
redact_text() {
|
||||
local text="$1"
|
||||
local secret
|
||||
for secret in "${HERMES_GATEWAY_API_KEY:-}" "${CLAIM_SECRET:-}" "${AGENT_API_KEY:-}" "${PAPERCLIP_AUTH_HEADER:-}" "${PAPERCLIP_COOKIE:-}" "${PAPERCLIP_API_KEY:-}"; do
|
||||
if [[ -n "$secret" ]]; then
|
||||
text="${text//$secret/[redacted len=${#secret}]}"
|
||||
fi
|
||||
done
|
||||
printf "%s" "$text"
|
||||
}
|
||||
|
||||
print_response_error() {
|
||||
redact_text "$RESPONSE_BODY" >&2
|
||||
echo >&2
|
||||
}
|
||||
|
||||
api_request() {
|
||||
local method="$1"
|
||||
local path="$2"
|
||||
local data="${3-}"
|
||||
local tmp
|
||||
tmp="$(mktemp)"
|
||||
|
||||
local url
|
||||
if [[ "$path" == http://* || "$path" == https://* ]]; then
|
||||
url="$path"
|
||||
elif [[ "$path" == /api/* ]]; then
|
||||
url="${PAPERCLIP_API_URL%/}${path}"
|
||||
else
|
||||
url="${API_BASE}${path}"
|
||||
fi
|
||||
|
||||
if [[ -n "$data" ]]; then
|
||||
RESPONSE_CODE="$(curl -sS -o "$tmp" -w "%{http_code}" -X "$method" "${AUTH_HEADERS[@]}" -H "Content-Type: application/json" "$url" --data "$data")"
|
||||
else
|
||||
RESPONSE_CODE="$(curl -sS -o "$tmp" -w "%{http_code}" -X "$method" "${AUTH_HEADERS[@]}" "$url")"
|
||||
fi
|
||||
RESPONSE_BODY="$(cat "$tmp")"
|
||||
rm -f "$tmp"
|
||||
}
|
||||
|
||||
assert_status() {
|
||||
local expected="$1"
|
||||
if [[ "$RESPONSE_CODE" != "$expected" ]]; then
|
||||
print_response_error
|
||||
fail "expected HTTP ${expected}, got HTTP ${RESPONSE_CODE}"
|
||||
fi
|
||||
}
|
||||
|
||||
assert_json_has_string() {
|
||||
local jq_expr="$1"
|
||||
local value
|
||||
value="$(jq -r "$jq_expr // empty" <<<"$RESPONSE_BODY")"
|
||||
if [[ -z "$value" ]]; then
|
||||
print_response_error
|
||||
fail "expected JSON string at ${jq_expr}"
|
||||
fi
|
||||
echo "$value"
|
||||
}
|
||||
|
||||
fail_board_auth_required() {
|
||||
local operation="$1"
|
||||
print_response_error
|
||||
cat >&2 <<EOF
|
||||
[hermes-gateway-join] ERROR: ${operation} requires board/operator auth.
|
||||
|
||||
Provide one of:
|
||||
PAPERCLIP_AUTH_HEADER="Bearer <board-token>"
|
||||
PAPERCLIP_COOKIE="<board-session-cookie>"
|
||||
|
||||
Current auth context appears insufficient (HTTP ${RESPONSE_CODE}).
|
||||
EOF
|
||||
exit 1
|
||||
}
|
||||
|
||||
is_remote_plain_http() {
|
||||
local url="$1"
|
||||
[[ "$url" == http://* ]] || return 1
|
||||
! is_loopback_http_host "$(url_host "$url")"
|
||||
}
|
||||
|
||||
url_host() {
|
||||
local url="$1"
|
||||
local rest host_port host
|
||||
rest="${url#http://}"
|
||||
rest="${rest#https://}"
|
||||
if [[ "$rest" == \[*\]* ]]; then
|
||||
host="${rest#\[}"
|
||||
host="${host%%\]*}"
|
||||
else
|
||||
host_port="${rest%%/*}"
|
||||
host="${host_port%%:*}"
|
||||
fi
|
||||
printf "%s" "$host"
|
||||
}
|
||||
|
||||
is_loopback_http_host() {
|
||||
local host
|
||||
host="$(printf "%s" "$1" | tr '[:upper:]' '[:lower:]')"
|
||||
case "$host" in
|
||||
localhost|0.0.0.0|::1|0:0:0:0:0:0:0:1) return 0 ;;
|
||||
esac
|
||||
[[ "$host" =~ ^127\.([0-9]{1,3}\.){2}[0-9]{1,3}$ ]]
|
||||
}
|
||||
|
||||
strip_trailing_slash() {
|
||||
local value="$1"
|
||||
while [[ "$value" == */ && "$value" != "http://" && "$value" != "https://" ]]; do
|
||||
value="${value%/}"
|
||||
done
|
||||
printf "%s" "$value"
|
||||
}
|
||||
|
||||
resolve_company_id() {
|
||||
if [[ -n "$COMPANY_ID" ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
log "resolving company id"
|
||||
api_request "GET" "/companies"
|
||||
if [[ "$RESPONSE_CODE" == "401" || "$RESPONSE_CODE" == "403" ]]; then
|
||||
fail_board_auth_required "Company resolution"
|
||||
fi
|
||||
assert_status "200"
|
||||
|
||||
if [[ -n "$COMPANY_SELECTOR" ]]; then
|
||||
COMPANY_ID="$(jq -r --arg selector "$COMPANY_SELECTOR" '
|
||||
map(select(
|
||||
(.id == $selector)
|
||||
or ((.issuePrefix // "") == $selector)
|
||||
or ((.name // "") == $selector)
|
||||
)) | .[0].id // empty
|
||||
' <<<"$RESPONSE_BODY")"
|
||||
[[ -n "$COMPANY_ID" ]] || fail "no company matched COMPANY_SELECTOR=${COMPANY_SELECTOR}"
|
||||
else
|
||||
COMPANY_ID="$(jq -r '.[0].id // empty' <<<"$RESPONSE_BODY")"
|
||||
[[ -n "$COMPANY_ID" ]] || fail "no companies found; create one before running smoke test"
|
||||
fi
|
||||
}
|
||||
|
||||
assert_onboarding_contains() {
|
||||
local body="$1"
|
||||
local needle="$2"
|
||||
if ! grep -Fq "$needle" <<<"$body"; then
|
||||
echo "$body" >&2
|
||||
fail "onboarding response missing expected text: ${needle}"
|
||||
fi
|
||||
}
|
||||
|
||||
probe_hermes_gateway() {
|
||||
[[ -n "$HERMES_GATEWAY_API_BASE_URL" ]] || fail "HERMES_GATEWAY_API_BASE_URL is required"
|
||||
[[ -n "$HERMES_GATEWAY_PROBE_URL" ]] || fail "HERMES_GATEWAY_PROBE_URL is required"
|
||||
[[ -n "$HERMES_GATEWAY_API_KEY" ]] || fail "HERMES_GATEWAY_API_KEY or API_SERVER_KEY is required before any Paperclip state is mutated"
|
||||
|
||||
if is_remote_plain_http "$HERMES_GATEWAY_API_BASE_URL" && [[ "$HERMES_GATEWAY_ALLOW_INSECURE_HTTP" != "1" ]]; then
|
||||
fail "HERMES_GATEWAY_API_BASE_URL uses non-loopback http. Set HERMES_GATEWAY_ALLOW_INSECURE_HTTP=1 for local-only unsafe HTTP, or use HTTPS."
|
||||
fi
|
||||
|
||||
local health_url="${HERMES_GATEWAY_PROBE_URL%/}/health"
|
||||
log "probing Hermes gateway health at ${health_url} with apiKey sha256=$(hash_prefix "$HERMES_GATEWAY_API_KEY") len=${#HERMES_GATEWAY_API_KEY}"
|
||||
if [[ "$HERMES_GATEWAY_PROBE_URL" != "$HERMES_GATEWAY_API_BASE_URL" ]]; then
|
||||
log "Paperclip will store Hermes gateway URL ${HERMES_GATEWAY_API_BASE_URL}"
|
||||
fi
|
||||
local code
|
||||
code="$(curl -sS -o /dev/null -w "%{http_code}" --max-time "$GATEWAY_PROBE_TIMEOUT_SEC" -H "Authorization: Bearer ${HERMES_GATEWAY_API_KEY}" "$health_url" || true)"
|
||||
if [[ "$code" != "200" ]]; then
|
||||
fail "Hermes gateway health probe failed before mutating Paperclip state: ${health_url} returned HTTP ${code}. Start Hermes with API_SERVER_ENABLED=true API_SERVER_KEY=<key> hermes gateway run --replace --accept-hooks, or set HERMES_GATEWAY_API_BASE_URL/HERMES_GATEWAY_API_KEY."
|
||||
fi
|
||||
}
|
||||
|
||||
log "checking Paperclip health"
|
||||
api_request "GET" "/health"
|
||||
assert_status "200"
|
||||
log "deployment mode=$(jq -r '.deploymentMode // "unknown"' <<<"$RESPONSE_BODY") exposure=$(jq -r '.deploymentExposure // "unknown"' <<<"$RESPONSE_BODY")"
|
||||
|
||||
resolve_company_id
|
||||
probe_hermes_gateway
|
||||
|
||||
log "creating agent-only invite for company ${COMPANY_ID}"
|
||||
INVITE_PAYLOAD="$(jq -nc '{allowedJoinTypes:"agent"}')"
|
||||
api_request "POST" "/companies/${COMPANY_ID}/invites" "$INVITE_PAYLOAD"
|
||||
if [[ "$RESPONSE_CODE" == "401" || "$RESPONSE_CODE" == "403" ]]; then
|
||||
fail_board_auth_required "Invite creation"
|
||||
fi
|
||||
assert_status "201"
|
||||
INVITE_TOKEN="$(assert_json_has_string '.token')"
|
||||
INVITE_ID="$(assert_json_has_string '.id')"
|
||||
log "created invite ${INVITE_ID}"
|
||||
|
||||
log "verifying onboarding JSON and text endpoints"
|
||||
api_request "GET" "/invites/${INVITE_TOKEN}/onboarding"
|
||||
assert_status "200"
|
||||
ONBOARDING_JSON="$RESPONSE_BODY"
|
||||
ONBOARDING_TEXT_PATH="$(jq -r '.invite.onboardingTextPath // empty' <<<"$ONBOARDING_JSON")"
|
||||
[[ -n "$ONBOARDING_TEXT_PATH" ]] || fail "onboarding manifest missing invite.onboardingTextPath"
|
||||
assert_onboarding_contains "$ONBOARDING_JSON" "hermes_gateway"
|
||||
assert_onboarding_contains "$ONBOARDING_JSON" "API_SERVER_ENABLED=true"
|
||||
assert_onboarding_contains "$ONBOARDING_JSON" "API_SERVER_KEY"
|
||||
assert_onboarding_contains "$ONBOARDING_JSON" "agentDefaultsPayload"
|
||||
|
||||
api_request "GET" "/invites/${INVITE_TOKEN}/onboarding.txt"
|
||||
assert_status "200"
|
||||
ONBOARDING_TEXT="$RESPONSE_BODY"
|
||||
assert_onboarding_contains "$ONBOARDING_TEXT" 'adapterType: "hermes_gateway"'
|
||||
assert_onboarding_contains "$ONBOARDING_TEXT" "API_SERVER_ENABLED=true"
|
||||
assert_onboarding_contains "$ONBOARDING_TEXT" "API_SERVER_KEY"
|
||||
assert_onboarding_contains "$ONBOARDING_TEXT" "hermes gateway run --replace --accept-hooks"
|
||||
assert_onboarding_contains "$ONBOARDING_TEXT" "agentDefaultsPayload.apiBaseUrl"
|
||||
|
||||
JOIN_PAYLOAD="$(jq -nc \
|
||||
--arg name "$HERMES_AGENT_NAME" \
|
||||
--arg apiBaseUrl "$HERMES_GATEWAY_API_BASE_URL" \
|
||||
--arg apiKey "$HERMES_GATEWAY_API_KEY" \
|
||||
--arg paperclipApiUrl "$PAPERCLIP_API_URL_FOR_HERMES" \
|
||||
--arg sessionKeyStrategy "$HERMES_GATEWAY_SESSION_KEY_STRATEGY" \
|
||||
--argjson timeoutSec "$HERMES_GATEWAY_TIMEOUT_SEC" \
|
||||
--argjson allowInsecure "$(if [[ "$HERMES_GATEWAY_ALLOW_INSECURE_HTTP" == "1" ]]; then echo true; else echo false; fi)" \
|
||||
'{
|
||||
requestType: "agent",
|
||||
agentName: $name,
|
||||
adapterType: "hermes_gateway",
|
||||
capabilities: "Hermes gateway Docker smoke harness",
|
||||
agentDefaultsPayload: {
|
||||
apiBaseUrl: $apiBaseUrl,
|
||||
apiKey: $apiKey,
|
||||
sessionKeyStrategy: $sessionKeyStrategy,
|
||||
timeoutSec: $timeoutSec
|
||||
}
|
||||
}
|
||||
| if $paperclipApiUrl != "" then .agentDefaultsPayload.paperclipApiUrl = $paperclipApiUrl else . end
|
||||
| if $allowInsecure then .agentDefaultsPayload.dangerouslyAllowInsecureRemoteHttp = true else . end')"
|
||||
|
||||
log "submitting Hermes gateway agent join request"
|
||||
api_request "POST" "/invites/${INVITE_TOKEN}/accept" "$JOIN_PAYLOAD"
|
||||
if [[ "$RESPONSE_CODE" != "202" ]]; then
|
||||
print_response_error
|
||||
fi
|
||||
assert_status "202"
|
||||
JOIN_REQUEST_ID="$(assert_json_has_string '.id')"
|
||||
CLAIM_SECRET="$(assert_json_has_string '.claimSecret')"
|
||||
CLAIM_API_PATH="$(assert_json_has_string '.claimApiKeyPath')"
|
||||
DIAGNOSTICS_JSON="$(jq -c '.diagnostics // []' <<<"$RESPONSE_BODY")"
|
||||
if [[ "$DIAGNOSTICS_JSON" != "[]" ]]; then
|
||||
log "join diagnostics: $(redact_text "$DIAGNOSTICS_JSON")"
|
||||
fi
|
||||
|
||||
if is_remote_plain_http "$HERMES_GATEWAY_API_BASE_URL"; then
|
||||
if ! jq -e '[.diagnostics[]? | select(.code == "hermes_gateway_plain_http_remote_unsafe_allowed")] | length > 0' <<<"$RESPONSE_BODY" >/dev/null; then
|
||||
fail "expected hermes_gateway_plain_http_remote_unsafe_allowed diagnostic for non-loopback HTTP join"
|
||||
fi
|
||||
fi
|
||||
|
||||
log "approving join request ${JOIN_REQUEST_ID}"
|
||||
api_request "POST" "/companies/${COMPANY_ID}/join-requests/${JOIN_REQUEST_ID}/approve" "{}"
|
||||
if [[ "$RESPONSE_CODE" == "401" || "$RESPONSE_CODE" == "403" ]]; then
|
||||
fail_board_auth_required "Join approval"
|
||||
fi
|
||||
assert_status "200"
|
||||
CREATED_AGENT_ID="$(assert_json_has_string '.createdAgentId')"
|
||||
|
||||
log "verifying invalid claim secret is rejected"
|
||||
api_request "POST" "/join-requests/${JOIN_REQUEST_ID}/claim-api-key" '{"claimSecret":"invalid-smoke-secret-value"}'
|
||||
if [[ "$RESPONSE_CODE" == "201" ]]; then
|
||||
fail "invalid claim secret unexpectedly succeeded"
|
||||
fi
|
||||
|
||||
log "claiming API key with one-time claim secret"
|
||||
CLAIM_PAYLOAD="$(jq -nc --arg secret "$CLAIM_SECRET" '{claimSecret:$secret}')"
|
||||
api_request "POST" "$CLAIM_API_PATH" "$CLAIM_PAYLOAD"
|
||||
assert_status "201"
|
||||
AGENT_API_KEY="$(assert_json_has_string '.token')"
|
||||
KEY_ID="$(assert_json_has_string '.keyId')"
|
||||
|
||||
log "verifying replay claim is rejected"
|
||||
api_request "POST" "$CLAIM_API_PATH" "$CLAIM_PAYLOAD"
|
||||
if [[ "$RESPONSE_CODE" == "201" ]]; then
|
||||
fail "claim secret replay unexpectedly succeeded"
|
||||
fi
|
||||
|
||||
log "verifying stored Hermes gateway agent config"
|
||||
api_request "GET" "/agents/${CREATED_AGENT_ID}"
|
||||
assert_status "200"
|
||||
|
||||
AGENT_ADAPTER_TYPE="$(jq -r '.adapterType // empty' <<<"$RESPONSE_BODY")"
|
||||
[[ "$AGENT_ADAPTER_TYPE" == "hermes_gateway" ]] || fail "expected adapterType=hermes_gateway, got ${AGENT_ADAPTER_TYPE}"
|
||||
|
||||
STORED_API_BASE_URL="$(jq -r '.adapterConfig.apiBaseUrl // empty' <<<"$RESPONSE_BODY")"
|
||||
[[ -n "$STORED_API_BASE_URL" ]] || fail "stored adapterConfig.apiBaseUrl is missing"
|
||||
if [[ "$(strip_trailing_slash "$STORED_API_BASE_URL")" != "$(strip_trailing_slash "$HERMES_GATEWAY_API_BASE_URL")" ]]; then
|
||||
fail "stored apiBaseUrl mismatch: expected $(strip_trailing_slash "$HERMES_GATEWAY_API_BASE_URL"), got $(strip_trailing_slash "$STORED_API_BASE_URL")"
|
||||
fi
|
||||
|
||||
if jq -e --arg raw "$HERMES_GATEWAY_API_KEY" '.adapterConfig.apiKey == $raw' <<<"$RESPONSE_BODY" >/dev/null; then
|
||||
fail "stored adapterConfig.apiKey leaked the raw Hermes API key"
|
||||
fi
|
||||
if ! jq -e '(.adapterConfig.apiKey.type // "") == "secret_ref"' <<<"$RESPONSE_BODY" >/dev/null; then
|
||||
warn "stored adapterConfig.apiKey is not a visible secret_ref; response shape may redact it entirely"
|
||||
fi
|
||||
|
||||
STORED_SESSION_STRATEGY="$(jq -r '.adapterConfig.sessionKeyStrategy // empty' <<<"$RESPONSE_BODY")"
|
||||
[[ "$STORED_SESSION_STRATEGY" == "$HERMES_GATEWAY_SESSION_KEY_STRATEGY" ]] || fail "stored sessionKeyStrategy mismatch: expected ${HERMES_GATEWAY_SESSION_KEY_STRATEGY}, got ${STORED_SESSION_STRATEGY:-<empty>}"
|
||||
|
||||
if [[ -n "$PAPERCLIP_API_URL_FOR_HERMES" ]]; then
|
||||
STORED_PAPERCLIP_API_URL="$(jq -r '.adapterConfig.paperclipApiUrl // empty' <<<"$RESPONSE_BODY")"
|
||||
[[ "$STORED_PAPERCLIP_API_URL" == "$PAPERCLIP_API_URL_FOR_HERMES" || "$(strip_trailing_slash "$STORED_PAPERCLIP_API_URL")" == "$(strip_trailing_slash "$PAPERCLIP_API_URL_FOR_HERMES")" ]] \
|
||||
|| fail "stored paperclipApiUrl mismatch"
|
||||
fi
|
||||
|
||||
log "success"
|
||||
log "companyId=${COMPANY_ID}"
|
||||
log "inviteId=${INVITE_ID}"
|
||||
log "joinRequestId=${JOIN_REQUEST_ID}"
|
||||
log "agentId=${CREATED_AGENT_ID}"
|
||||
log "keyId=${KEY_ID}"
|
||||
log "hermesGatewayApiKeySha256=$(hash_prefix "$HERMES_GATEWAY_API_KEY") len=${#HERMES_GATEWAY_API_KEY}"
|
||||
log "agentApiKeySha256=$(hash_prefix "$AGENT_API_KEY") len=${#AGENT_API_KEY}"
|
||||
|
||||
if [[ -n "$HERMES_JOIN_OUTPUT_FILE" ]]; then
|
||||
mkdir -p "$(dirname "$HERMES_JOIN_OUTPUT_FILE")"
|
||||
jq -nc \
|
||||
--arg companyId "$COMPANY_ID" \
|
||||
--arg inviteId "$INVITE_ID" \
|
||||
--arg joinRequestId "$JOIN_REQUEST_ID" \
|
||||
--arg agentId "$CREATED_AGENT_ID" \
|
||||
--arg keyId "$KEY_ID" \
|
||||
--arg agentApiKey "$AGENT_API_KEY" \
|
||||
--arg hermesGatewayApiKeySha256 "$(hash_prefix "$HERMES_GATEWAY_API_KEY")" \
|
||||
--arg agentApiKeySha256 "$(hash_prefix "$AGENT_API_KEY")" \
|
||||
'{
|
||||
companyId: $companyId,
|
||||
inviteId: $inviteId,
|
||||
joinRequestId: $joinRequestId,
|
||||
agentId: $agentId,
|
||||
keyId: $keyId,
|
||||
agentApiKey: $agentApiKey,
|
||||
hermesGatewayApiKeySha256: $hermesGatewayApiKeySha256,
|
||||
agentApiKeySha256: $agentApiKeySha256
|
||||
}' > "$HERMES_JOIN_OUTPUT_FILE"
|
||||
chmod 600 "$HERMES_JOIN_OUTPUT_FILE"
|
||||
log "wrote join metadata to ${HERMES_JOIN_OUTPUT_FILE} (contains secret material; chmod 600)"
|
||||
fi
|
||||
@@ -0,0 +1,155 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import test from "node:test";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||
const joinScript = path.join(repoRoot, "scripts", "smoke", "hermes-gateway-join.sh");
|
||||
const e2eScript = path.join(repoRoot, "scripts", "smoke", "hermes-gateway-e2e.sh");
|
||||
const entrypointScript = path.join(repoRoot, "docker", "hermes-gateway-smoke", "entrypoint.sh");
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
return spawnSync(command, args, {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
function assertSuccess(result, label) {
|
||||
assert.equal(
|
||||
result.status,
|
||||
0,
|
||||
`${label} failed\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`,
|
||||
);
|
||||
}
|
||||
|
||||
function extractFunction(scriptText, name) {
|
||||
const lines = scriptText.split("\n");
|
||||
const start = lines.findIndex((line) => line.trim() === `${name}() {`);
|
||||
assert.notEqual(start, -1, `missing function ${name}`);
|
||||
|
||||
const collected = [];
|
||||
for (let index = start; index < lines.length; index += 1) {
|
||||
collected.push(lines[index]);
|
||||
if (index > start && lines[index].trim() === "}") {
|
||||
return collected.join("\n");
|
||||
}
|
||||
}
|
||||
assert.fail(`unterminated function ${name}`);
|
||||
}
|
||||
|
||||
function runBashFunctions(scriptPath, functionNames, body) {
|
||||
const scriptText = fs.readFileSync(scriptPath, "utf8");
|
||||
const functions = functionNames.map((name) => extractFunction(scriptText, name)).join("\n\n");
|
||||
return run("bash", ["-c", `set -euo pipefail\n${functions}\n${body}`]);
|
||||
}
|
||||
|
||||
test("Hermes gateway smoke shell scripts pass bash syntax validation", () => {
|
||||
const result = run("bash", ["-n", joinScript, e2eScript, entrypointScript]);
|
||||
assertSuccess(result, "bash -n");
|
||||
});
|
||||
|
||||
test("Hermes gateway smoke help documents operator safety flags", () => {
|
||||
for (const script of [joinScript, e2eScript]) {
|
||||
const result = run("bash", [script, "--help"]);
|
||||
assertSuccess(result, `${path.basename(script)} --help`);
|
||||
assert.match(result.stdout, /HERMES_GATEWAY_API_BASE_URL/);
|
||||
assert.match(result.stdout, /HERMES_GATEWAY_PROBE_URL/);
|
||||
assert.match(result.stdout, /HERMES_GATEWAY_ALLOW_INSECURE_HTTP/);
|
||||
assert.match(result.stdout, /redact|redacted|Raw .*keys are redacted/i);
|
||||
}
|
||||
|
||||
const e2eHelp = run("bash", [e2eScript, "--help"]).stdout;
|
||||
assert.match(e2eHelp, /HERMES_SMOKE_KEEP/);
|
||||
assert.match(e2eHelp, /HERMES_SMOKE_NETWORK/);
|
||||
assert.match(e2eHelp, /HERMES_SMOKE_MODEL_DEFAULT/);
|
||||
assert.match(e2eHelp, /Docker/);
|
||||
});
|
||||
|
||||
test("E2E helper can seed a minimal Hermes model config without secrets", () => {
|
||||
const result = runBashFunctions(
|
||||
e2eScript,
|
||||
["log", "fail", "yaml_single_quote", "write_hermes_model_config"],
|
||||
`
|
||||
tmp="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
HERMES_SMOKE_STATE_DIR="$tmp"
|
||||
HERMES_SMOKE_MODEL_PROVIDER="openrouter"
|
||||
HERMES_SMOKE_MODEL_DEFAULT="z-ai/glm-5.2"
|
||||
HERMES_SMOKE_MODEL_BASE_URL="https://openrouter.ai/api/v1"
|
||||
mkdir -p "$HERMES_SMOKE_STATE_DIR/hermes-home"
|
||||
write_hermes_model_config
|
||||
config="$HERMES_SMOKE_STATE_DIR/hermes-home/config.yaml"
|
||||
grep -Fq "default: 'z-ai/glm-5.2'" "$config"
|
||||
grep -Fq "provider: 'openrouter'" "$config"
|
||||
grep -Fq "base_url: 'https://openrouter.ai/api/v1'" "$config"
|
||||
grep -Fq "command_allowlist:" "$config"
|
||||
grep -Fq -- "- execute_code" "$config"
|
||||
! grep -Eiq "api[_-]?key|token|secret" "$config"
|
||||
`,
|
||||
);
|
||||
assertSuccess(result, "write_hermes_model_config");
|
||||
});
|
||||
|
||||
test("join helper redacts known secrets without exposing raw key material", () => {
|
||||
const result = runBashFunctions(
|
||||
joinScript,
|
||||
["redact_text"],
|
||||
`
|
||||
HERMES_GATEWAY_API_KEY="gateway-secret"
|
||||
CLAIM_SECRET="claim-secret"
|
||||
AGENT_API_KEY="agent-secret"
|
||||
PAPERCLIP_API_KEY="paperclip-secret"
|
||||
PAPERCLIP_AUTH_HEADER="Bearer board-secret"
|
||||
PAPERCLIP_COOKIE="session=board-cookie"
|
||||
output="$(redact_text "gateway-secret claim-secret agent-secret paperclip-secret Bearer board-secret session=board-cookie")"
|
||||
[[ "$output" != *"gateway-secret"* ]]
|
||||
[[ "$output" != *"claim-secret"* ]]
|
||||
[[ "$output" != *"agent-secret"* ]]
|
||||
[[ "$output" != *"paperclip-secret"* ]]
|
||||
[[ "$output" != *"board-secret"* ]]
|
||||
[[ "$output" != *"board-cookie"* ]]
|
||||
[[ "$output" == *"[redacted len=14]"* ]]
|
||||
`,
|
||||
);
|
||||
assertSuccess(result, "redact_text");
|
||||
});
|
||||
|
||||
test("URL helpers distinguish loopback HTTP from unsafe remote HTTP", () => {
|
||||
for (const script of [joinScript, e2eScript]) {
|
||||
const result = runBashFunctions(
|
||||
script,
|
||||
["url_host", "is_loopback_http_host", "is_remote_plain_http"],
|
||||
`
|
||||
is_remote_plain_http "http://192.168.1.20:8642"
|
||||
is_remote_plain_http "http://hermes-gateway.local:8642"
|
||||
is_remote_plain_http "http://127.example.com:8642"
|
||||
is_remote_plain_http "http://localhost.evil:8642"
|
||||
! is_remote_plain_http "https://192.168.1.20:8642"
|
||||
! is_remote_plain_http "http://127.0.0.1:8642"
|
||||
! is_remote_plain_http "http://127.44.55.66:8642"
|
||||
! is_remote_plain_http "http://localhost:8642"
|
||||
! is_remote_plain_http "http://[::1]:8642"
|
||||
[[ "$(url_host "http://[::1]:8642/health")" == "::1" ]]
|
||||
[[ "$(url_host "http://127.example.com:8642/health")" == "127.example.com" ]]
|
||||
`,
|
||||
);
|
||||
assertSuccess(result, `${path.basename(script)} URL helpers`);
|
||||
}
|
||||
});
|
||||
|
||||
test("join helper normalizes trailing slashes for URL comparisons", () => {
|
||||
const result = runBashFunctions(
|
||||
joinScript,
|
||||
["strip_trailing_slash"],
|
||||
`
|
||||
[[ "$(strip_trailing_slash "http://127.0.0.1:8642///")" == "http://127.0.0.1:8642" ]]
|
||||
[[ "$(strip_trailing_slash "https://gateway.example.com/")" == "https://gateway.example.com" ]]
|
||||
[[ "$(strip_trailing_slash "https://gateway.example.com/path/")" == "https://gateway.example.com/path" ]]
|
||||
`,
|
||||
);
|
||||
assertSuccess(result, "strip_trailing_slash");
|
||||
});
|
||||
Executable
+329
@@ -0,0 +1,329 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
log() {
|
||||
echo "[openclaw-docker-ui] $*"
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo "[openclaw-docker-ui] ERROR: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
require_cmd() {
|
||||
local cmd="$1"
|
||||
command -v "$cmd" >/dev/null 2>&1 || fail "missing required command: $cmd"
|
||||
}
|
||||
|
||||
reset_openclaw_state_dir() {
|
||||
local state_dir="$1"
|
||||
local resolved_state_dir resolved_home
|
||||
|
||||
[[ -n "$state_dir" ]] || fail "OPENCLAW_CONFIG_DIR must not be empty when resetting state"
|
||||
mkdir -p "$state_dir"
|
||||
|
||||
resolved_state_dir="$(cd "$state_dir" && pwd -P)"
|
||||
resolved_home="$(cd "$HOME" && pwd -P)"
|
||||
case "$resolved_state_dir" in
|
||||
"/"|"$resolved_home")
|
||||
fail "refusing to reset unsafe OPENCLAW_CONFIG_DIR: $resolved_state_dir"
|
||||
;;
|
||||
esac
|
||||
|
||||
log "resetting OpenClaw state under $resolved_state_dir"
|
||||
rm -rf \
|
||||
"$resolved_state_dir/agents" \
|
||||
"$resolved_state_dir/canvas" \
|
||||
"$resolved_state_dir/cron" \
|
||||
"$resolved_state_dir/credentials" \
|
||||
"$resolved_state_dir/devices" \
|
||||
"$resolved_state_dir/identity" \
|
||||
"$resolved_state_dir/logs" \
|
||||
"$resolved_state_dir/memory" \
|
||||
"$resolved_state_dir/skills" \
|
||||
"$resolved_state_dir/workspace"
|
||||
rm -f \
|
||||
"$resolved_state_dir/openclaw.json" \
|
||||
"$resolved_state_dir/openclaw.json.bak" \
|
||||
"$resolved_state_dir/update-check.json"
|
||||
}
|
||||
|
||||
require_cmd docker
|
||||
require_cmd git
|
||||
require_cmd curl
|
||||
require_cmd openssl
|
||||
require_cmd grep
|
||||
|
||||
OPENCLAW_REPO_URL="${OPENCLAW_REPO_URL:-https://github.com/openclaw/openclaw.git}"
|
||||
OPENCLAW_DOCKER_DIR="${OPENCLAW_DOCKER_DIR:-/tmp/openclaw-docker}"
|
||||
OPENCLAW_REPO_REF="${OPENCLAW_REPO_REF:-v2026.3.2}"
|
||||
OPENCLAW_IMAGE="${OPENCLAW_IMAGE:-openclaw:local}"
|
||||
OPENCLAW_TMP_DIR="${OPENCLAW_TMP_DIR:-${TMPDIR:-/tmp}}"
|
||||
OPENCLAW_TMP_DIR="${OPENCLAW_TMP_DIR%/}"
|
||||
OPENCLAW_TMP_DIR="${OPENCLAW_TMP_DIR:-/tmp}"
|
||||
OPENCLAW_CONFIG_DIR="${OPENCLAW_CONFIG_DIR:-$OPENCLAW_TMP_DIR/openclaw-paperclip-smoke}"
|
||||
OPENCLAW_WORKSPACE_DIR="${OPENCLAW_WORKSPACE_DIR:-$OPENCLAW_CONFIG_DIR/workspace}"
|
||||
OPENCLAW_GATEWAY_PORT="${OPENCLAW_GATEWAY_PORT:-18789}"
|
||||
OPENCLAW_BRIDGE_PORT="${OPENCLAW_BRIDGE_PORT:-18790}"
|
||||
OPENCLAW_GATEWAY_BIND="${OPENCLAW_GATEWAY_BIND:-lan}"
|
||||
OPENCLAW_GATEWAY_TOKEN="${OPENCLAW_GATEWAY_TOKEN:-$(openssl rand -hex 32)}"
|
||||
OPENCLAW_BUILD="${OPENCLAW_BUILD:-1}"
|
||||
OPENCLAW_WAIT_SECONDS="${OPENCLAW_WAIT_SECONDS:-45}"
|
||||
OPENCLAW_OPEN_BROWSER="${OPENCLAW_OPEN_BROWSER:-0}"
|
||||
OPENCLAW_SECRETS_FILE="${OPENCLAW_SECRETS_FILE:-$HOME/.secrets}"
|
||||
# Keep default one-command UX: local smoke run should not require manual pairing.
|
||||
OPENCLAW_DISABLE_DEVICE_AUTH="${OPENCLAW_DISABLE_DEVICE_AUTH:-1}"
|
||||
OPENCLAW_MODEL_PRIMARY="${OPENCLAW_MODEL_PRIMARY:-openai/gpt-5.2}"
|
||||
OPENCLAW_MODEL_FALLBACK="${OPENCLAW_MODEL_FALLBACK:-openai/gpt-5.2-chat-latest}"
|
||||
OPENCLAW_RESET_STATE="${OPENCLAW_RESET_STATE:-1}"
|
||||
PAPERCLIP_HOST_PORT="${PAPERCLIP_HOST_PORT:-3100}"
|
||||
PAPERCLIP_HOST_FROM_CONTAINER="${PAPERCLIP_HOST_FROM_CONTAINER:-host.docker.internal}"
|
||||
|
||||
case "$OPENCLAW_DISABLE_DEVICE_AUTH" in
|
||||
1|true|TRUE|True|yes|YES|Yes)
|
||||
OPENCLAW_DISABLE_DEVICE_AUTH_JSON="true"
|
||||
;;
|
||||
0|false|FALSE|False|no|NO|No)
|
||||
OPENCLAW_DISABLE_DEVICE_AUTH_JSON="false"
|
||||
;;
|
||||
*)
|
||||
fail "OPENCLAW_DISABLE_DEVICE_AUTH must be one of: 1,0,true,false,yes,no"
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ -z "${OPENAI_API_KEY:-}" && -f "$OPENCLAW_SECRETS_FILE" ]]; then
|
||||
set +u
|
||||
# shellcheck source=/dev/null
|
||||
source "$OPENCLAW_SECRETS_FILE"
|
||||
set -u
|
||||
fi
|
||||
|
||||
[[ -n "${OPENAI_API_KEY:-}" ]] || fail "OPENAI_API_KEY is required (set env var or include it in $OPENCLAW_SECRETS_FILE)"
|
||||
|
||||
log "preparing OpenClaw repo at $OPENCLAW_DOCKER_DIR"
|
||||
if [[ -d "$OPENCLAW_DOCKER_DIR/.git" ]]; then
|
||||
git -C "$OPENCLAW_DOCKER_DIR" fetch --quiet --tags origin || true
|
||||
else
|
||||
rm -rf "$OPENCLAW_DOCKER_DIR"
|
||||
git clone "$OPENCLAW_REPO_URL" "$OPENCLAW_DOCKER_DIR"
|
||||
git -C "$OPENCLAW_DOCKER_DIR" fetch --quiet --tags origin || true
|
||||
fi
|
||||
|
||||
resolved_openclaw_ref=""
|
||||
if git -C "$OPENCLAW_DOCKER_DIR" rev-parse --verify --quiet "origin/$OPENCLAW_REPO_REF" >/dev/null; then
|
||||
resolved_openclaw_ref="origin/$OPENCLAW_REPO_REF"
|
||||
elif git -C "$OPENCLAW_DOCKER_DIR" rev-parse --verify --quiet "$OPENCLAW_REPO_REF" >/dev/null; then
|
||||
resolved_openclaw_ref="$OPENCLAW_REPO_REF"
|
||||
fi
|
||||
[[ -n "$resolved_openclaw_ref" ]] || fail "unable to resolve OPENCLAW_REPO_REF=$OPENCLAW_REPO_REF in $OPENCLAW_DOCKER_DIR"
|
||||
git -C "$OPENCLAW_DOCKER_DIR" checkout --quiet "$resolved_openclaw_ref"
|
||||
log "using OpenClaw ref $resolved_openclaw_ref ($(git -C "$OPENCLAW_DOCKER_DIR" rev-parse --short HEAD))"
|
||||
|
||||
if [[ "$OPENCLAW_BUILD" == "1" ]]; then
|
||||
log "building Docker image $OPENCLAW_IMAGE"
|
||||
docker build -t "$OPENCLAW_IMAGE" -f "$OPENCLAW_DOCKER_DIR/Dockerfile" "$OPENCLAW_DOCKER_DIR"
|
||||
fi
|
||||
|
||||
log "writing OpenClaw config under $OPENCLAW_CONFIG_DIR"
|
||||
if [[ "$OPENCLAW_RESET_STATE" == "1" ]]; then
|
||||
# Ensure deterministic smoke behavior across reruns by restarting with a clean state dir.
|
||||
OPENCLAW_CONFIG_DIR="$OPENCLAW_CONFIG_DIR" \
|
||||
OPENCLAW_WORKSPACE_DIR="$OPENCLAW_WORKSPACE_DIR" \
|
||||
OPENCLAW_GATEWAY_PORT="$OPENCLAW_GATEWAY_PORT" \
|
||||
OPENCLAW_BRIDGE_PORT="$OPENCLAW_BRIDGE_PORT" \
|
||||
OPENCLAW_GATEWAY_BIND="$OPENCLAW_GATEWAY_BIND" \
|
||||
OPENCLAW_GATEWAY_TOKEN="$OPENCLAW_GATEWAY_TOKEN" \
|
||||
OPENCLAW_IMAGE="$OPENCLAW_IMAGE" \
|
||||
OPENAI_API_KEY="$OPENAI_API_KEY" \
|
||||
docker compose -f "$OPENCLAW_DOCKER_DIR/docker-compose.yml" down --remove-orphans >/dev/null 2>&1 || true
|
||||
reset_openclaw_state_dir "$OPENCLAW_CONFIG_DIR"
|
||||
fi
|
||||
mkdir -p "$OPENCLAW_WORKSPACE_DIR" "$OPENCLAW_CONFIG_DIR/identity" "$OPENCLAW_CONFIG_DIR/credentials"
|
||||
chmod 700 "$OPENCLAW_CONFIG_DIR" "$OPENCLAW_CONFIG_DIR/credentials"
|
||||
|
||||
cat > "$OPENCLAW_CONFIG_DIR/openclaw.json" <<EOF
|
||||
{
|
||||
"gateway": {
|
||||
"mode": "local",
|
||||
"port": ${OPENCLAW_GATEWAY_PORT},
|
||||
"bind": "${OPENCLAW_GATEWAY_BIND}",
|
||||
"auth": {
|
||||
"mode": "token",
|
||||
"token": "${OPENCLAW_GATEWAY_TOKEN}"
|
||||
},
|
||||
"controlUi": {
|
||||
"enabled": true,
|
||||
"dangerouslyDisableDeviceAuth": ${OPENCLAW_DISABLE_DEVICE_AUTH_JSON},
|
||||
"allowedOrigins": [
|
||||
"http://127.0.0.1:${OPENCLAW_GATEWAY_PORT}",
|
||||
"http://localhost:${OPENCLAW_GATEWAY_PORT}"
|
||||
]
|
||||
}
|
||||
},
|
||||
"env": {
|
||||
"OPENAI_API_KEY": "${OPENAI_API_KEY}"
|
||||
},
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": {
|
||||
"primary": "${OPENCLAW_MODEL_PRIMARY}",
|
||||
"fallbacks": [
|
||||
"${OPENCLAW_MODEL_FALLBACK}"
|
||||
]
|
||||
},
|
||||
"workspace": "/home/node/.openclaw/workspace"
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
chmod 600 "$OPENCLAW_CONFIG_DIR/openclaw.json"
|
||||
|
||||
cat > "$OPENCLAW_DOCKER_DIR/.env" <<EOF
|
||||
OPENCLAW_CONFIG_DIR=$OPENCLAW_CONFIG_DIR
|
||||
OPENCLAW_WORKSPACE_DIR=$OPENCLAW_WORKSPACE_DIR
|
||||
OPENCLAW_GATEWAY_PORT=$OPENCLAW_GATEWAY_PORT
|
||||
OPENCLAW_BRIDGE_PORT=$OPENCLAW_BRIDGE_PORT
|
||||
OPENCLAW_GATEWAY_BIND=$OPENCLAW_GATEWAY_BIND
|
||||
OPENCLAW_GATEWAY_TOKEN=$OPENCLAW_GATEWAY_TOKEN
|
||||
OPENCLAW_IMAGE=$OPENCLAW_IMAGE
|
||||
OPENAI_API_KEY=$OPENAI_API_KEY
|
||||
EOF
|
||||
|
||||
COMPOSE_OVERRIDE="${OPENCLAW_DOCKER_DIR}/.paperclip-openclaw.override.yml"
|
||||
cat > "$COMPOSE_OVERRIDE" <<EOF
|
||||
services:
|
||||
openclaw-gateway:
|
||||
tmpfs:
|
||||
- /tmp:exec,size=512M
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
openclaw-cli:
|
||||
tmpfs:
|
||||
- /tmp:exec,size=512M
|
||||
EOF
|
||||
|
||||
compose() {
|
||||
docker compose \
|
||||
-f "$OPENCLAW_DOCKER_DIR/docker-compose.yml" \
|
||||
-f "$COMPOSE_OVERRIDE" \
|
||||
"$@"
|
||||
}
|
||||
|
||||
detect_paperclip_base_url() {
|
||||
local bridge_gateway candidate health_url
|
||||
bridge_gateway="$(docker network inspect openclaw-docker_default --format '{{(index .IPAM.Config 0).Gateway}}' 2>/dev/null || true)"
|
||||
for candidate in "$PAPERCLIP_HOST_FROM_CONTAINER" "$bridge_gateway"; do
|
||||
[[ -n "$candidate" ]] || continue
|
||||
health_url="http://${candidate}:${PAPERCLIP_HOST_PORT}/api/health"
|
||||
if compose exec -T openclaw-gateway node -e "fetch('${health_url}').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" >/dev/null 2>&1; then
|
||||
echo "http://${candidate}:${PAPERCLIP_HOST_PORT}"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
log "starting OpenClaw gateway container"
|
||||
compose up -d openclaw-gateway
|
||||
|
||||
log "waiting for gateway health on http://127.0.0.1:${OPENCLAW_GATEWAY_PORT}/"
|
||||
READY="0"
|
||||
for _ in $(seq 1 "$OPENCLAW_WAIT_SECONDS"); do
|
||||
code="$(curl -sS -o /dev/null -w "%{http_code}" "http://127.0.0.1:${OPENCLAW_GATEWAY_PORT}/" || true)"
|
||||
if [[ "$code" == "200" ]]; then
|
||||
READY="1"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
if [[ "$READY" != "1" ]]; then
|
||||
compose logs --tail=100 openclaw-gateway || true
|
||||
fail "gateway did not become healthy in ${OPENCLAW_WAIT_SECONDS}s"
|
||||
fi
|
||||
|
||||
paperclip_base_url="$(detect_paperclip_base_url || true)"
|
||||
dashboard_output="$(compose run --rm openclaw-cli dashboard --no-open)"
|
||||
dashboard_url="$(grep -Eo 'https?://[^[:space:]]+#token=[^[:space:]]+' <<<"$dashboard_output" | head -n1 || true)"
|
||||
if [[ -z "$dashboard_url" ]]; then
|
||||
dashboard_url="http://127.0.0.1:${OPENCLAW_GATEWAY_PORT}/#token=${OPENCLAW_GATEWAY_TOKEN}"
|
||||
fi
|
||||
|
||||
cat <<EOF
|
||||
|
||||
OpenClaw gateway is running.
|
||||
|
||||
Dashboard URL:
|
||||
$dashboard_url
|
||||
EOF
|
||||
|
||||
if [[ "$OPENCLAW_DISABLE_DEVICE_AUTH_JSON" == "true" ]]; then
|
||||
cat <<EOF
|
||||
Pairing:
|
||||
Device pairing is disabled by default for this local smoke run.
|
||||
No extra env vars are required for the default path.
|
||||
(Security tradeoff: enable pairing with OPENCLAW_DISABLE_DEVICE_AUTH=0.)
|
||||
Model:
|
||||
${OPENCLAW_MODEL_PRIMARY} (fallback: ${OPENCLAW_MODEL_FALLBACK})
|
||||
State:
|
||||
OPENCLAW_RESET_STATE=$OPENCLAW_RESET_STATE
|
||||
Paperclip URL for OpenClaw container:
|
||||
EOF
|
||||
if [[ -n "$paperclip_base_url" ]]; then
|
||||
cat <<EOF
|
||||
$paperclip_base_url
|
||||
(Use this base URL for invite/onboarding links from inside OpenClaw Docker.)
|
||||
EOF
|
||||
else
|
||||
cat <<EOF
|
||||
Auto-detect failed. Try: http://host.docker.internal:${PAPERCLIP_HOST_PORT}
|
||||
(Do not use http://127.0.0.1:${PAPERCLIP_HOST_PORT} inside the container.)
|
||||
If Paperclip rejects the host, run on host machine:
|
||||
pnpm paperclipai allowed-hostname host.docker.internal
|
||||
Then restart Paperclip and re-run this script.
|
||||
EOF
|
||||
fi
|
||||
cat <<EOF
|
||||
|
||||
Useful commands:
|
||||
docker compose -f "$OPENCLAW_DOCKER_DIR/docker-compose.yml" -f "$COMPOSE_OVERRIDE" logs -f openclaw-gateway
|
||||
docker compose -f "$OPENCLAW_DOCKER_DIR/docker-compose.yml" -f "$COMPOSE_OVERRIDE" down
|
||||
EOF
|
||||
else
|
||||
cat <<EOF
|
||||
Pairing:
|
||||
Device pairing is enabled.
|
||||
If UI shows "pairing required", run:
|
||||
docker compose -f "$OPENCLAW_DOCKER_DIR/docker-compose.yml" -f "$COMPOSE_OVERRIDE" run --rm openclaw-cli devices list
|
||||
docker compose -f "$OPENCLAW_DOCKER_DIR/docker-compose.yml" -f "$COMPOSE_OVERRIDE" run --rm openclaw-cli devices approve --latest
|
||||
Model:
|
||||
${OPENCLAW_MODEL_PRIMARY} (fallback: ${OPENCLAW_MODEL_FALLBACK})
|
||||
State:
|
||||
OPENCLAW_RESET_STATE=$OPENCLAW_RESET_STATE
|
||||
Paperclip URL for OpenClaw container:
|
||||
EOF
|
||||
if [[ -n "$paperclip_base_url" ]]; then
|
||||
cat <<EOF
|
||||
$paperclip_base_url
|
||||
(Use this base URL for invite/onboarding links from inside OpenClaw Docker.)
|
||||
EOF
|
||||
else
|
||||
cat <<EOF
|
||||
Auto-detect failed. Try: http://host.docker.internal:${PAPERCLIP_HOST_PORT}
|
||||
(Do not use http://127.0.0.1:${PAPERCLIP_HOST_PORT} inside the container.)
|
||||
If Paperclip rejects the host, run on host machine:
|
||||
pnpm paperclipai allowed-hostname host.docker.internal
|
||||
Then restart Paperclip and re-run this script.
|
||||
EOF
|
||||
fi
|
||||
cat <<EOF
|
||||
|
||||
Useful commands:
|
||||
docker compose -f "$OPENCLAW_DOCKER_DIR/docker-compose.yml" -f "$COMPOSE_OVERRIDE" logs -f openclaw-gateway
|
||||
docker compose -f "$OPENCLAW_DOCKER_DIR/docker-compose.yml" -f "$COMPOSE_OVERRIDE" down
|
||||
EOF
|
||||
fi
|
||||
|
||||
if [[ "$OPENCLAW_OPEN_BROWSER" == "1" ]] && command -v open >/dev/null 2>&1; then
|
||||
log "opening dashboard in browser"
|
||||
open "$dashboard_url"
|
||||
fi
|
||||
Executable
+954
@@ -0,0 +1,954 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
log() {
|
||||
echo "[openclaw-gateway-e2e] $*"
|
||||
}
|
||||
|
||||
warn() {
|
||||
echo "[openclaw-gateway-e2e] WARN: $*" >&2
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo "[openclaw-gateway-e2e] ERROR: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
require_cmd() {
|
||||
local cmd="$1"
|
||||
command -v "$cmd" >/dev/null 2>&1 || fail "missing required command: $cmd"
|
||||
}
|
||||
|
||||
require_cmd curl
|
||||
require_cmd jq
|
||||
require_cmd docker
|
||||
require_cmd node
|
||||
require_cmd shasum
|
||||
|
||||
PAPERCLIP_API_URL="${PAPERCLIP_API_URL:-http://127.0.0.1:3100}"
|
||||
API_BASE="${PAPERCLIP_API_URL%/}/api"
|
||||
|
||||
COMPANY_SELECTOR="${COMPANY_SELECTOR:-CLA}"
|
||||
OPENCLAW_AGENT_NAME="${OPENCLAW_AGENT_NAME:-OpenClaw Gateway Smoke Agent}"
|
||||
OPENCLAW_GATEWAY_URL="${OPENCLAW_GATEWAY_URL:-ws://127.0.0.1:18789}"
|
||||
OPENCLAW_GATEWAY_TOKEN="${OPENCLAW_GATEWAY_TOKEN:-}"
|
||||
OPENCLAW_TMP_DIR="${OPENCLAW_TMP_DIR:-${TMPDIR:-/tmp}}"
|
||||
OPENCLAW_TMP_DIR="${OPENCLAW_TMP_DIR%/}"
|
||||
OPENCLAW_TMP_DIR="${OPENCLAW_TMP_DIR:-/tmp}"
|
||||
OPENCLAW_CONFIG_DIR="${OPENCLAW_CONFIG_DIR:-${OPENCLAW_TMP_DIR}/openclaw-paperclip-smoke}"
|
||||
OPENCLAW_WORKSPACE_DIR="${OPENCLAW_WORKSPACE_DIR:-${OPENCLAW_CONFIG_DIR}/workspace}"
|
||||
OPENCLAW_CONTAINER_NAME="${OPENCLAW_CONTAINER_NAME:-openclaw-docker-openclaw-gateway-1}"
|
||||
OPENCLAW_IMAGE="${OPENCLAW_IMAGE:-openclaw:local}"
|
||||
OPENCLAW_DOCKER_DIR="${OPENCLAW_DOCKER_DIR:-/tmp/openclaw-docker}"
|
||||
OPENCLAW_RESET_DOCKER="${OPENCLAW_RESET_DOCKER:-1}"
|
||||
OPENCLAW_BUILD="${OPENCLAW_BUILD:-1}"
|
||||
OPENCLAW_WAIT_SECONDS="${OPENCLAW_WAIT_SECONDS:-60}"
|
||||
OPENCLAW_RESET_STATE="${OPENCLAW_RESET_STATE:-1}"
|
||||
|
||||
PAPERCLIP_API_URL_FOR_OPENCLAW="${PAPERCLIP_API_URL_FOR_OPENCLAW:-http://host.docker.internal:3100}"
|
||||
CASE_TIMEOUT_SEC="${CASE_TIMEOUT_SEC:-420}"
|
||||
RUN_TIMEOUT_SEC="${RUN_TIMEOUT_SEC:-300}"
|
||||
STRICT_CASES="${STRICT_CASES:-1}"
|
||||
AUTO_INSTALL_SKILL="${AUTO_INSTALL_SKILL:-1}"
|
||||
OPENCLAW_DIAG_DIR="${OPENCLAW_DIAG_DIR:-/tmp/openclaw-gateway-e2e-diag-$(date +%Y%m%d-%H%M%S)}"
|
||||
OPENCLAW_ADAPTER_TIMEOUT_SEC="${OPENCLAW_ADAPTER_TIMEOUT_SEC:-120}"
|
||||
OPENCLAW_ADAPTER_WAIT_TIMEOUT_MS="${OPENCLAW_ADAPTER_WAIT_TIMEOUT_MS:-120000}"
|
||||
PAIRING_AUTO_APPROVE="${PAIRING_AUTO_APPROVE:-1}"
|
||||
PAYLOAD_TEMPLATE_MESSAGE_APPEND="${PAYLOAD_TEMPLATE_MESSAGE_APPEND:-}"
|
||||
|
||||
AUTH_HEADERS=()
|
||||
if [[ -n "${PAPERCLIP_AUTH_HEADER:-}" ]]; then
|
||||
AUTH_HEADERS+=( -H "Authorization: ${PAPERCLIP_AUTH_HEADER}" )
|
||||
fi
|
||||
if [[ -n "${PAPERCLIP_COOKIE:-}" ]]; then
|
||||
AUTH_HEADERS+=( -H "Cookie: ${PAPERCLIP_COOKIE}" )
|
||||
PAPERCLIP_BROWSER_ORIGIN="${PAPERCLIP_BROWSER_ORIGIN:-${PAPERCLIP_API_URL%/}}"
|
||||
AUTH_HEADERS+=( -H "Origin: ${PAPERCLIP_BROWSER_ORIGIN}" -H "Referer: ${PAPERCLIP_BROWSER_ORIGIN}/" )
|
||||
fi
|
||||
|
||||
RESPONSE_CODE=""
|
||||
RESPONSE_BODY=""
|
||||
COMPANY_ID=""
|
||||
AGENT_ID=""
|
||||
AGENT_API_KEY=""
|
||||
JOIN_REQUEST_ID=""
|
||||
INVITE_ID=""
|
||||
RUN_ID=""
|
||||
|
||||
CASE_A_ISSUE_ID=""
|
||||
CASE_B_ISSUE_ID=""
|
||||
CASE_C_ISSUE_ID=""
|
||||
CASE_C_CREATED_ISSUE_ID=""
|
||||
|
||||
api_request() {
|
||||
local method="$1"
|
||||
local path="$2"
|
||||
local data="${3-}"
|
||||
local tmp
|
||||
tmp="$(mktemp)"
|
||||
|
||||
local url
|
||||
if [[ "$path" == http://* || "$path" == https://* ]]; then
|
||||
url="$path"
|
||||
elif [[ "$path" == /api/* ]]; then
|
||||
url="${PAPERCLIP_API_URL%/}${path}"
|
||||
else
|
||||
url="${API_BASE}${path}"
|
||||
fi
|
||||
|
||||
if [[ -n "$data" ]]; then
|
||||
if (( ${#AUTH_HEADERS[@]} > 0 )); then
|
||||
RESPONSE_CODE="$(curl -sS -o "$tmp" -w "%{http_code}" -X "$method" "${AUTH_HEADERS[@]}" -H "Content-Type: application/json" "$url" --data "$data")"
|
||||
else
|
||||
RESPONSE_CODE="$(curl -sS -o "$tmp" -w "%{http_code}" -X "$method" -H "Content-Type: application/json" "$url" --data "$data")"
|
||||
fi
|
||||
else
|
||||
if (( ${#AUTH_HEADERS[@]} > 0 )); then
|
||||
RESPONSE_CODE="$(curl -sS -o "$tmp" -w "%{http_code}" -X "$method" "${AUTH_HEADERS[@]}" "$url")"
|
||||
else
|
||||
RESPONSE_CODE="$(curl -sS -o "$tmp" -w "%{http_code}" -X "$method" "$url")"
|
||||
fi
|
||||
fi
|
||||
|
||||
RESPONSE_BODY="$(cat "$tmp")"
|
||||
rm -f "$tmp"
|
||||
}
|
||||
|
||||
capture_run_diagnostics() {
|
||||
local run_id="$1"
|
||||
local label="${2:-run}"
|
||||
[[ -n "$run_id" ]] || return 0
|
||||
|
||||
mkdir -p "$OPENCLAW_DIAG_DIR"
|
||||
|
||||
api_request "GET" "/heartbeat-runs/${run_id}/events?limit=1000"
|
||||
if [[ "$RESPONSE_CODE" == "200" ]]; then
|
||||
printf "%s\n" "$RESPONSE_BODY" > "${OPENCLAW_DIAG_DIR}/${label}-${run_id}-events.json"
|
||||
else
|
||||
warn "could not fetch events for run ${run_id} (HTTP ${RESPONSE_CODE})"
|
||||
fi
|
||||
|
||||
api_request "GET" "/heartbeat-runs/${run_id}/log?limitBytes=524288"
|
||||
if [[ "$RESPONSE_CODE" == "200" ]]; then
|
||||
printf "%s\n" "$RESPONSE_BODY" > "${OPENCLAW_DIAG_DIR}/${label}-${run_id}-log.json"
|
||||
jq -r '.content // ""' <<<"$RESPONSE_BODY" > "${OPENCLAW_DIAG_DIR}/${label}-${run_id}-log.txt" 2>/dev/null || true
|
||||
else
|
||||
warn "could not fetch log for run ${run_id} (HTTP ${RESPONSE_CODE})"
|
||||
fi
|
||||
}
|
||||
|
||||
capture_issue_diagnostics() {
|
||||
local issue_id="$1"
|
||||
local label="${2:-issue}"
|
||||
[[ -n "$issue_id" ]] || return 0
|
||||
mkdir -p "$OPENCLAW_DIAG_DIR"
|
||||
|
||||
api_request "GET" "/issues/${issue_id}"
|
||||
if [[ "$RESPONSE_CODE" == "200" ]]; then
|
||||
printf "%s\n" "$RESPONSE_BODY" > "${OPENCLAW_DIAG_DIR}/${label}-${issue_id}.json"
|
||||
fi
|
||||
|
||||
api_request "GET" "/issues/${issue_id}/comments"
|
||||
if [[ "$RESPONSE_CODE" == "200" ]]; then
|
||||
printf "%s\n" "$RESPONSE_BODY" > "${OPENCLAW_DIAG_DIR}/${label}-${issue_id}-comments.json"
|
||||
fi
|
||||
}
|
||||
|
||||
capture_openclaw_container_logs() {
|
||||
mkdir -p "$OPENCLAW_DIAG_DIR"
|
||||
local container
|
||||
container="$(detect_openclaw_container || true)"
|
||||
if [[ -z "$container" ]]; then
|
||||
warn "could not detect OpenClaw container for diagnostics"
|
||||
return 0
|
||||
fi
|
||||
docker logs --tail=1200 "$container" > "${OPENCLAW_DIAG_DIR}/openclaw-container.log" 2>&1 || true
|
||||
}
|
||||
|
||||
assert_status() {
|
||||
local expected="$1"
|
||||
if [[ "$RESPONSE_CODE" != "$expected" ]]; then
|
||||
echo "$RESPONSE_BODY" >&2
|
||||
fail "expected HTTP ${expected}, got ${RESPONSE_CODE}"
|
||||
fi
|
||||
}
|
||||
|
||||
require_board_auth() {
|
||||
if [[ ${#AUTH_HEADERS[@]} -eq 0 ]]; then
|
||||
fail "board auth required. Set PAPERCLIP_COOKIE or PAPERCLIP_AUTH_HEADER."
|
||||
fi
|
||||
api_request "GET" "/companies"
|
||||
if [[ "$RESPONSE_CODE" != "200" ]]; then
|
||||
echo "$RESPONSE_BODY" >&2
|
||||
fail "board auth invalid for /api/companies (HTTP ${RESPONSE_CODE})"
|
||||
fi
|
||||
}
|
||||
|
||||
maybe_cleanup_openclaw_docker() {
|
||||
if [[ "$OPENCLAW_RESET_DOCKER" != "1" ]]; then
|
||||
log "OPENCLAW_RESET_DOCKER=${OPENCLAW_RESET_DOCKER}; skipping docker cleanup"
|
||||
return
|
||||
fi
|
||||
|
||||
log "cleaning OpenClaw docker state"
|
||||
if [[ -d "$OPENCLAW_DOCKER_DIR" ]]; then
|
||||
docker compose -f "$OPENCLAW_DOCKER_DIR/docker-compose.yml" down --remove-orphans >/dev/null 2>&1 || true
|
||||
fi
|
||||
if docker ps -a --format '{{.Names}}' | grep -qx "$OPENCLAW_CONTAINER_NAME"; then
|
||||
docker rm -f "$OPENCLAW_CONTAINER_NAME" >/dev/null 2>&1 || true
|
||||
fi
|
||||
docker image rm "$OPENCLAW_IMAGE" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
start_openclaw_docker() {
|
||||
log "starting clean OpenClaw docker"
|
||||
OPENCLAW_CONFIG_DIR="$OPENCLAW_CONFIG_DIR" OPENCLAW_WORKSPACE_DIR="$OPENCLAW_WORKSPACE_DIR" \
|
||||
OPENCLAW_RESET_STATE="$OPENCLAW_RESET_STATE" OPENCLAW_BUILD="$OPENCLAW_BUILD" OPENCLAW_WAIT_SECONDS="$OPENCLAW_WAIT_SECONDS" \
|
||||
./scripts/smoke/openclaw-docker-ui.sh
|
||||
}
|
||||
|
||||
wait_http_ready() {
|
||||
local url="$1"
|
||||
local timeout_sec="$2"
|
||||
local started_at now code
|
||||
started_at="$(date +%s)"
|
||||
while true; do
|
||||
code="$(curl -sS -o /dev/null -w "%{http_code}" "$url" || true)"
|
||||
if [[ "$code" == "200" ]]; then
|
||||
return 0
|
||||
fi
|
||||
now="$(date +%s)"
|
||||
if (( now - started_at >= timeout_sec )); then
|
||||
return 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
}
|
||||
|
||||
detect_openclaw_container() {
|
||||
if docker ps --format '{{.Names}}' | grep -qx "$OPENCLAW_CONTAINER_NAME"; then
|
||||
echo "$OPENCLAW_CONTAINER_NAME"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local detected
|
||||
detected="$(docker ps --format '{{.Names}}' | grep 'openclaw-gateway' | head -n1 || true)"
|
||||
if [[ -n "$detected" ]]; then
|
||||
echo "$detected"
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
detect_gateway_token() {
|
||||
if [[ -n "$OPENCLAW_GATEWAY_TOKEN" ]]; then
|
||||
echo "$OPENCLAW_GATEWAY_TOKEN"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local config_path
|
||||
config_path="${OPENCLAW_CONFIG_DIR%/}/openclaw.json"
|
||||
if [[ -f "$config_path" ]]; then
|
||||
local token
|
||||
token="$(jq -r '.gateway.auth.token // empty' "$config_path")"
|
||||
if [[ -n "$token" ]]; then
|
||||
echo "$token"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
local container
|
||||
container="$(detect_openclaw_container || true)"
|
||||
if [[ -n "$container" ]]; then
|
||||
local token_from_container
|
||||
token_from_container="$(docker exec "$container" sh -lc "node -e 'const fs=require(\"fs\");const c=JSON.parse(fs.readFileSync(\"/home/node/.openclaw/openclaw.json\",\"utf8\"));process.stdout.write(c.gateway?.auth?.token||\"\");'" 2>/dev/null || true)"
|
||||
if [[ -n "$token_from_container" ]]; then
|
||||
echo "$token_from_container"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
hash_prefix() {
|
||||
local value="$1"
|
||||
printf "%s" "$value" | shasum -a 256 | awk '{print $1}' | cut -c1-12
|
||||
}
|
||||
|
||||
probe_gateway_ws() {
|
||||
local url="$1"
|
||||
local token="$2"
|
||||
|
||||
node - "$url" "$token" <<'NODE'
|
||||
const WebSocket = require("ws");
|
||||
const url = process.argv[2];
|
||||
const token = process.argv[3];
|
||||
|
||||
const ws = new WebSocket(url, { headers: { Authorization: `Bearer ${token}` } });
|
||||
const timeout = setTimeout(() => {
|
||||
console.error("gateway probe timed out");
|
||||
process.exit(2);
|
||||
}, 8000);
|
||||
|
||||
ws.on("message", (raw) => {
|
||||
try {
|
||||
const message = JSON.parse(String(raw));
|
||||
if (message?.type === "event" && message?.event === "connect.challenge") {
|
||||
clearTimeout(timeout);
|
||||
ws.close();
|
||||
process.exit(0);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("error", (err) => {
|
||||
clearTimeout(timeout);
|
||||
console.error(err?.message || String(err));
|
||||
process.exit(1);
|
||||
});
|
||||
NODE
|
||||
}
|
||||
|
||||
resolve_company_id() {
|
||||
api_request "GET" "/companies"
|
||||
assert_status "200"
|
||||
|
||||
local selector
|
||||
selector="$(printf "%s" "$COMPANY_SELECTOR" | tr '[:lower:]' '[:upper:]')"
|
||||
|
||||
COMPANY_ID="$(jq -r --arg sel "$selector" '
|
||||
map(select(
|
||||
((.id // "") | ascii_upcase) == $sel or
|
||||
((.name // "") | ascii_upcase) == $sel or
|
||||
((.issuePrefix // "") | ascii_upcase) == $sel
|
||||
))
|
||||
| .[0].id // empty
|
||||
' <<<"$RESPONSE_BODY")"
|
||||
|
||||
if [[ -z "$COMPANY_ID" ]]; then
|
||||
local available
|
||||
available="$(jq -r '.[] | "- id=\(.id) issuePrefix=\(.issuePrefix // "") name=\(.name // "")"' <<<"$RESPONSE_BODY")"
|
||||
echo "$available" >&2
|
||||
fail "could not find company for selector '${COMPANY_SELECTOR}'"
|
||||
fi
|
||||
|
||||
log "resolved company ${COMPANY_ID} from selector ${COMPANY_SELECTOR}"
|
||||
}
|
||||
|
||||
cleanup_openclaw_agents() {
|
||||
api_request "GET" "/companies/${COMPANY_ID}/agents"
|
||||
assert_status "200"
|
||||
|
||||
local ids
|
||||
ids="$(jq -r '.[] | select((.adapterType == "openclaw" or .adapterType == "openclaw_gateway")) | .id' <<<"$RESPONSE_BODY")"
|
||||
if [[ -z "$ids" ]]; then
|
||||
log "no prior OpenClaw agents to cleanup"
|
||||
return
|
||||
fi
|
||||
|
||||
while IFS= read -r id; do
|
||||
[[ -n "$id" ]] || continue
|
||||
log "terminating prior OpenClaw agent ${id}"
|
||||
api_request "POST" "/agents/${id}/terminate" "{}"
|
||||
if [[ "$RESPONSE_CODE" != "200" && "$RESPONSE_CODE" != "404" ]]; then
|
||||
warn "terminate ${id} returned HTTP ${RESPONSE_CODE}"
|
||||
fi
|
||||
|
||||
api_request "DELETE" "/agents/${id}"
|
||||
if [[ "$RESPONSE_CODE" != "200" && "$RESPONSE_CODE" != "404" ]]; then
|
||||
warn "delete ${id} returned HTTP ${RESPONSE_CODE}"
|
||||
fi
|
||||
done <<<"$ids"
|
||||
}
|
||||
|
||||
cleanup_pending_join_requests() {
|
||||
api_request "GET" "/companies/${COMPANY_ID}/join-requests?status=pending_approval"
|
||||
if [[ "$RESPONSE_CODE" != "200" ]]; then
|
||||
warn "join-request cleanup skipped (HTTP ${RESPONSE_CODE})"
|
||||
return
|
||||
fi
|
||||
|
||||
local ids
|
||||
ids="$(jq -r '.[] | select((.adapterType == "openclaw" or .adapterType == "openclaw_gateway")) | .id' <<<"$RESPONSE_BODY")"
|
||||
if [[ -z "$ids" ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
while IFS= read -r request_id; do
|
||||
[[ -n "$request_id" ]] || continue
|
||||
log "rejecting stale pending join request ${request_id}"
|
||||
api_request "POST" "/companies/${COMPANY_ID}/join-requests/${request_id}/reject" "{}"
|
||||
if [[ "$RESPONSE_CODE" != "200" && "$RESPONSE_CODE" != "404" && "$RESPONSE_CODE" != "409" ]]; then
|
||||
warn "reject ${request_id} returned HTTP ${RESPONSE_CODE}"
|
||||
fi
|
||||
done <<<"$ids"
|
||||
}
|
||||
|
||||
create_and_approve_gateway_join() {
|
||||
local gateway_token="$1"
|
||||
|
||||
local invite_payload
|
||||
invite_payload="$(jq -nc '{allowedJoinTypes:"agent"}')"
|
||||
api_request "POST" "/companies/${COMPANY_ID}/invites" "$invite_payload"
|
||||
assert_status "201"
|
||||
|
||||
local invite_token
|
||||
invite_token="$(jq -r '.token // empty' <<<"$RESPONSE_BODY")"
|
||||
INVITE_ID="$(jq -r '.id // empty' <<<"$RESPONSE_BODY")"
|
||||
[[ -n "$invite_token" && -n "$INVITE_ID" ]] || fail "invite creation missing token/id"
|
||||
|
||||
local join_payload
|
||||
join_payload="$(jq -nc \
|
||||
--arg name "$OPENCLAW_AGENT_NAME" \
|
||||
--arg url "$OPENCLAW_GATEWAY_URL" \
|
||||
--arg token "$gateway_token" \
|
||||
--arg paperclipApiUrl "$PAPERCLIP_API_URL_FOR_OPENCLAW" \
|
||||
--argjson timeoutSec "$OPENCLAW_ADAPTER_TIMEOUT_SEC" \
|
||||
--argjson waitTimeoutMs "$OPENCLAW_ADAPTER_WAIT_TIMEOUT_MS" \
|
||||
'{
|
||||
requestType: "agent",
|
||||
agentName: $name,
|
||||
adapterType: "openclaw_gateway",
|
||||
capabilities: "OpenClaw gateway smoke harness",
|
||||
agentDefaultsPayload: {
|
||||
url: $url,
|
||||
headers: { "x-openclaw-token": $token },
|
||||
role: "operator",
|
||||
scopes: ["operator.admin"],
|
||||
sessionKeyStrategy: "fixed",
|
||||
sessionKey: "paperclip",
|
||||
timeoutSec: $timeoutSec,
|
||||
waitTimeoutMs: $waitTimeoutMs,
|
||||
paperclipApiUrl: $paperclipApiUrl
|
||||
}
|
||||
}')"
|
||||
|
||||
api_request "POST" "/invites/${invite_token}/accept" "$join_payload"
|
||||
assert_status "202"
|
||||
|
||||
JOIN_REQUEST_ID="$(jq -r '.id // empty' <<<"$RESPONSE_BODY")"
|
||||
local claim_secret
|
||||
claim_secret="$(jq -r '.claimSecret // empty' <<<"$RESPONSE_BODY")"
|
||||
local claim_path
|
||||
claim_path="$(jq -r '.claimApiKeyPath // empty' <<<"$RESPONSE_BODY")"
|
||||
[[ -n "$JOIN_REQUEST_ID" && -n "$claim_secret" && -n "$claim_path" ]] || fail "join accept missing claim metadata"
|
||||
|
||||
log "approving join request ${JOIN_REQUEST_ID}"
|
||||
api_request "POST" "/companies/${COMPANY_ID}/join-requests/${JOIN_REQUEST_ID}/approve" "{}"
|
||||
assert_status "200"
|
||||
|
||||
AGENT_ID="$(jq -r '.createdAgentId // empty' <<<"$RESPONSE_BODY")"
|
||||
[[ -n "$AGENT_ID" ]] || fail "join approval missing createdAgentId"
|
||||
|
||||
log "claiming one-time agent API key"
|
||||
local claim_payload
|
||||
claim_payload="$(jq -nc --arg secret "$claim_secret" '{claimSecret:$secret}')"
|
||||
api_request "POST" "$claim_path" "$claim_payload"
|
||||
assert_status "201"
|
||||
|
||||
AGENT_API_KEY="$(jq -r '.token // empty' <<<"$RESPONSE_BODY")"
|
||||
[[ -n "$AGENT_API_KEY" ]] || fail "claim response missing token"
|
||||
|
||||
persist_claimed_key_artifacts "$RESPONSE_BODY"
|
||||
inject_agent_api_key_payload_template
|
||||
}
|
||||
|
||||
persist_claimed_key_artifacts() {
|
||||
local claim_json="$1"
|
||||
local workspace_dir="${OPENCLAW_CONFIG_DIR%/}/workspace"
|
||||
local skill_dir="${OPENCLAW_CONFIG_DIR%/}/skills/paperclip"
|
||||
local claimed_file="${workspace_dir}/paperclip-claimed-api-key.json"
|
||||
local claimed_raw_file="${workspace_dir}/paperclip-claimed-api-key.raw.json"
|
||||
|
||||
mkdir -p "$workspace_dir" "$skill_dir"
|
||||
local token
|
||||
token="$(jq -r '.token // .apiKey // empty' <<<"$claim_json")"
|
||||
[[ -n "$token" ]] || fail "claim response missing token/apiKey"
|
||||
|
||||
printf "%s\n" "$claim_json" > "$claimed_raw_file"
|
||||
chmod 600 "$claimed_raw_file"
|
||||
|
||||
jq -nc --arg token "$token" '{ token: $token, apiKey: $token }' > "$claimed_file"
|
||||
# Keep this readable for OpenClaw runtime users across sandbox/container contexts.
|
||||
chmod 644 "$claimed_file"
|
||||
|
||||
local container
|
||||
container="$(detect_openclaw_container || true)"
|
||||
if [[ -n "$container" ]]; then
|
||||
docker exec "$container" sh -lc "mkdir -p /home/node/.openclaw/workspace" >/dev/null 2>&1 || true
|
||||
docker cp "$claimed_file" "${container}:/home/node/.openclaw/workspace/paperclip-claimed-api-key.json" >/dev/null 2>&1 || true
|
||||
docker exec "$container" sh -lc "chmod 644 /home/node/.openclaw/workspace/paperclip-claimed-api-key.json" >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
if [[ "$AUTO_INSTALL_SKILL" == "1" ]]; then
|
||||
api_request "GET" "/skills/paperclip"
|
||||
assert_status "200"
|
||||
{
|
||||
echo "PAPERCLIP_API_URL=${PAPERCLIP_API_URL_FOR_OPENCLAW}"
|
||||
echo
|
||||
printf "%s\n" "$RESPONSE_BODY"
|
||||
} > "${skill_dir}/SKILL.md"
|
||||
chmod 600 "${skill_dir}/SKILL.md"
|
||||
fi
|
||||
|
||||
log "wrote claimed key artifacts to ${claimed_file} and ${claimed_raw_file}"
|
||||
}
|
||||
|
||||
inject_agent_api_key_payload_template() {
|
||||
[[ -n "$AGENT_ID" ]] || fail "inject_agent_api_key_payload_template requires AGENT_ID"
|
||||
[[ -n "$AGENT_API_KEY" ]] || fail "inject_agent_api_key_payload_template requires AGENT_API_KEY"
|
||||
|
||||
api_request "GET" "/agents/${AGENT_ID}"
|
||||
assert_status "200"
|
||||
|
||||
local base_message
|
||||
base_message="Set PAPERCLIP_API_KEY=${AGENT_API_KEY} in your run context before running Paperclip heartbeat steps."
|
||||
if [[ -n "$PAYLOAD_TEMPLATE_MESSAGE_APPEND" ]]; then
|
||||
base_message="${base_message}\n\n${PAYLOAD_TEMPLATE_MESSAGE_APPEND}"
|
||||
fi
|
||||
|
||||
local patch_payload
|
||||
patch_payload="$(jq -c --arg message "$base_message" '
|
||||
{adapterConfig: ((.adapterConfig // {}) + {
|
||||
payloadTemplate: (((.adapterConfig // {}).payloadTemplate // {}) + {
|
||||
message: $message
|
||||
})
|
||||
})}
|
||||
' <<<"$RESPONSE_BODY")"
|
||||
|
||||
api_request "PATCH" "/agents/${AGENT_ID}" "$patch_payload"
|
||||
assert_status "200"
|
||||
}
|
||||
|
||||
validate_joined_gateway_agent() {
|
||||
local expected_gateway_token="$1"
|
||||
|
||||
api_request "GET" "/agents/${AGENT_ID}"
|
||||
assert_status "200"
|
||||
|
||||
local adapter_type gateway_url configured_token disable_device_auth device_key_len
|
||||
adapter_type="$(jq -r '.adapterType // empty' <<<"$RESPONSE_BODY")"
|
||||
gateway_url="$(jq -r '.adapterConfig.url // empty' <<<"$RESPONSE_BODY")"
|
||||
configured_token="$(jq -r '.adapterConfig.headers["x-openclaw-token"] // .adapterConfig.headers["x-openclaw-auth"] // empty' <<<"$RESPONSE_BODY")"
|
||||
disable_device_auth="$(jq -r 'if .adapterConfig.disableDeviceAuth == true then "true" else "false" end' <<<"$RESPONSE_BODY")"
|
||||
device_key_len="$(jq -r '(.adapterConfig.devicePrivateKeyPem // "" | length)' <<<"$RESPONSE_BODY")"
|
||||
|
||||
[[ "$adapter_type" == "openclaw_gateway" ]] || fail "joined agent adapterType is '${adapter_type}', expected 'openclaw_gateway'"
|
||||
[[ "$gateway_url" =~ ^wss?:// ]] || fail "joined agent gateway url is invalid: '${gateway_url}'"
|
||||
[[ -n "$configured_token" ]] || fail "joined agent missing adapterConfig.headers.x-openclaw-token"
|
||||
if (( ${#configured_token} < 16 )); then
|
||||
fail "joined agent gateway token looks too short (${#configured_token} chars)"
|
||||
fi
|
||||
|
||||
local expected_hash configured_hash
|
||||
expected_hash="$(hash_prefix "$expected_gateway_token")"
|
||||
configured_hash="$(hash_prefix "$configured_token")"
|
||||
if [[ "$expected_hash" != "$configured_hash" ]]; then
|
||||
fail "joined agent gateway token hash mismatch (expected ${expected_hash}, got ${configured_hash})"
|
||||
fi
|
||||
|
||||
[[ "$disable_device_auth" == "false" ]] || fail "joined agent has disableDeviceAuth=true; smoke requires device auth enabled with persistent key"
|
||||
if (( device_key_len < 32 )); then
|
||||
fail "joined agent missing persistent devicePrivateKeyPem (length=${device_key_len})"
|
||||
fi
|
||||
|
||||
log "validated joined gateway agent config (token sha256 prefix ${configured_hash})"
|
||||
}
|
||||
|
||||
run_log_contains_pairing_required() {
|
||||
local run_id="$1"
|
||||
api_request "GET" "/heartbeat-runs/${run_id}/log?limitBytes=262144"
|
||||
if [[ "$RESPONSE_CODE" != "200" ]]; then
|
||||
return 1
|
||||
fi
|
||||
local content
|
||||
content="$(jq -r '.content // ""' <<<"$RESPONSE_BODY")"
|
||||
grep -qi "pairing required" <<<"$content"
|
||||
}
|
||||
|
||||
approve_latest_pairing_request() {
|
||||
local gateway_token="$1"
|
||||
local container
|
||||
container="$(detect_openclaw_container || true)"
|
||||
[[ -n "$container" ]] || return 1
|
||||
|
||||
log "approving latest gateway pairing request in ${container}"
|
||||
local output
|
||||
if output="$(docker exec \
|
||||
-e OPENCLAW_GATEWAY_URL="$OPENCLAW_GATEWAY_URL" \
|
||||
-e OPENCLAW_GATEWAY_TOKEN="$gateway_token" \
|
||||
"$container" \
|
||||
sh -lc 'openclaw devices approve --latest --json --url "$OPENCLAW_GATEWAY_URL" --token "$OPENCLAW_GATEWAY_TOKEN"' 2>&1)"; then
|
||||
log "pairing approval response: $(printf "%s" "$output" | tr '\n' ' ' | cut -c1-400)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
warn "pairing auto-approve failed: $(printf "%s" "$output" | tr '\n' ' ' | cut -c1-400)"
|
||||
return 1
|
||||
}
|
||||
|
||||
trigger_wakeup() {
|
||||
local reason="$1"
|
||||
local issue_id="${2:-}"
|
||||
|
||||
local payload
|
||||
if [[ -n "$issue_id" ]]; then
|
||||
payload="$(jq -nc --arg issueId "$issue_id" --arg reason "$reason" '{source:"on_demand",triggerDetail:"manual",reason:$reason,payload:{issueId:$issueId,taskId:$issueId}}')"
|
||||
else
|
||||
payload="$(jq -nc --arg reason "$reason" '{source:"on_demand",triggerDetail:"manual",reason:$reason}')"
|
||||
fi
|
||||
|
||||
api_request "POST" "/agents/${AGENT_ID}/wakeup" "$payload"
|
||||
if [[ "$RESPONSE_CODE" != "202" ]]; then
|
||||
echo "$RESPONSE_BODY" >&2
|
||||
fail "wakeup failed (HTTP ${RESPONSE_CODE})"
|
||||
fi
|
||||
|
||||
RUN_ID="$(jq -r '.id // empty' <<<"$RESPONSE_BODY")"
|
||||
if [[ -z "$RUN_ID" ]]; then
|
||||
warn "wakeup response did not include run id; body: ${RESPONSE_BODY}"
|
||||
fi
|
||||
}
|
||||
|
||||
get_run_status() {
|
||||
local run_id="$1"
|
||||
api_request "GET" "/companies/${COMPANY_ID}/heartbeat-runs?agentId=${AGENT_ID}&limit=200"
|
||||
if [[ "$RESPONSE_CODE" != "200" ]]; then
|
||||
echo ""
|
||||
return 0
|
||||
fi
|
||||
jq -r --arg runId "$run_id" '.[] | select(.id == $runId) | .status' <<<"$RESPONSE_BODY" | head -n1
|
||||
}
|
||||
|
||||
wait_for_run_terminal() {
|
||||
local run_id="$1"
|
||||
local timeout_sec="$2"
|
||||
local started now status
|
||||
|
||||
[[ -n "$run_id" ]] || fail "wait_for_run_terminal requires run id"
|
||||
started="$(date +%s)"
|
||||
|
||||
while true; do
|
||||
status="$(get_run_status "$run_id")"
|
||||
if [[ "$status" == "succeeded" || "$status" == "failed" || "$status" == "timed_out" || "$status" == "cancelled" ]]; then
|
||||
if [[ "$status" != "succeeded" ]]; then
|
||||
capture_run_diagnostics "$run_id" "run-nonsuccess"
|
||||
capture_openclaw_container_logs
|
||||
fi
|
||||
echo "$status"
|
||||
return 0
|
||||
fi
|
||||
|
||||
now="$(date +%s)"
|
||||
if (( now - started >= timeout_sec )); then
|
||||
capture_run_diagnostics "$run_id" "run-timeout"
|
||||
capture_openclaw_container_logs
|
||||
echo "timeout"
|
||||
return 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
}
|
||||
|
||||
get_issue_status() {
|
||||
local issue_id="$1"
|
||||
api_request "GET" "/issues/${issue_id}"
|
||||
if [[ "$RESPONSE_CODE" != "200" ]]; then
|
||||
echo ""
|
||||
return 0
|
||||
fi
|
||||
jq -r '.status // empty' <<<"$RESPONSE_BODY"
|
||||
}
|
||||
|
||||
wait_for_issue_terminal() {
|
||||
local issue_id="$1"
|
||||
local timeout_sec="$2"
|
||||
local started now status
|
||||
started="$(date +%s)"
|
||||
|
||||
while true; do
|
||||
status="$(get_issue_status "$issue_id")"
|
||||
if [[ "$status" == "done" || "$status" == "blocked" || "$status" == "cancelled" ]]; then
|
||||
echo "$status"
|
||||
return 0
|
||||
fi
|
||||
|
||||
now="$(date +%s)"
|
||||
if (( now - started >= timeout_sec )); then
|
||||
echo "timeout"
|
||||
return 0
|
||||
fi
|
||||
sleep 3
|
||||
done
|
||||
}
|
||||
|
||||
issue_comments_contain() {
|
||||
local issue_id="$1"
|
||||
local marker="$2"
|
||||
api_request "GET" "/issues/${issue_id}/comments"
|
||||
if [[ "$RESPONSE_CODE" != "200" ]]; then
|
||||
echo "false"
|
||||
return 0
|
||||
fi
|
||||
jq -r --arg marker "$marker" '[.[] | (.body // "") | contains($marker)] | any' <<<"$RESPONSE_BODY"
|
||||
}
|
||||
|
||||
create_issue_for_case() {
|
||||
local title="$1"
|
||||
local description="$2"
|
||||
local priority="${3:-high}"
|
||||
|
||||
local payload
|
||||
payload="$(jq -nc \
|
||||
--arg title "$title" \
|
||||
--arg description "$description" \
|
||||
--arg assignee "$AGENT_ID" \
|
||||
--arg priority "$priority" \
|
||||
'{title:$title,description:$description,status:"todo",priority:$priority,assigneeAgentId:$assignee}')"
|
||||
|
||||
api_request "POST" "/companies/${COMPANY_ID}/issues" "$payload"
|
||||
assert_status "201"
|
||||
|
||||
local issue_id issue_identifier
|
||||
issue_id="$(jq -r '.id // empty' <<<"$RESPONSE_BODY")"
|
||||
issue_identifier="$(jq -r '.identifier // empty' <<<"$RESPONSE_BODY")"
|
||||
[[ -n "$issue_id" ]] || fail "issue create missing id"
|
||||
|
||||
echo "${issue_id}|${issue_identifier}"
|
||||
}
|
||||
|
||||
patch_agent_session_strategy_run() {
|
||||
api_request "GET" "/agents/${AGENT_ID}"
|
||||
assert_status "200"
|
||||
|
||||
local patch_payload
|
||||
patch_payload="$(jq -c '{adapterConfig: ((.adapterConfig // {}) + {sessionKeyStrategy:"run"})}' <<<"$RESPONSE_BODY")"
|
||||
api_request "PATCH" "/agents/${AGENT_ID}" "$patch_payload"
|
||||
assert_status "200"
|
||||
}
|
||||
|
||||
find_issue_by_query() {
|
||||
local query="$1"
|
||||
local encoded_query
|
||||
encoded_query="$(jq -rn --arg q "$query" '$q|@uri')"
|
||||
api_request "GET" "/companies/${COMPANY_ID}/issues?q=${encoded_query}"
|
||||
if [[ "$RESPONSE_CODE" != "200" ]]; then
|
||||
echo ""
|
||||
return 0
|
||||
fi
|
||||
jq -r '.[] | .id' <<<"$RESPONSE_BODY" | head -n1
|
||||
}
|
||||
|
||||
run_case_a() {
|
||||
local marker="OPENCLAW_CASE_A_OK_$(date +%s)"
|
||||
local description
|
||||
description="Case A validation.\n\n1) Read this issue.\n2) Post a comment containing exactly: ${marker}\n3) Mark this issue done."
|
||||
|
||||
local created
|
||||
created="$(create_issue_for_case "[OpenClaw Gateway Smoke] Case A" "$description")"
|
||||
CASE_A_ISSUE_ID="${created%%|*}"
|
||||
local case_identifier="${created##*|}"
|
||||
|
||||
log "case A issue ${CASE_A_ISSUE_ID} (${case_identifier})"
|
||||
trigger_wakeup "openclaw_gateway_smoke_case_a" "$CASE_A_ISSUE_ID"
|
||||
|
||||
local run_status issue_status marker_found
|
||||
if [[ -n "$RUN_ID" ]]; then
|
||||
run_status="$(wait_for_run_terminal "$RUN_ID" "$RUN_TIMEOUT_SEC")"
|
||||
log "case A run ${RUN_ID} status=${run_status}"
|
||||
else
|
||||
run_status="unknown"
|
||||
fi
|
||||
|
||||
issue_status="$(wait_for_issue_terminal "$CASE_A_ISSUE_ID" "$CASE_TIMEOUT_SEC")"
|
||||
marker_found="$(issue_comments_contain "$CASE_A_ISSUE_ID" "$marker")"
|
||||
log "case A issue_status=${issue_status} marker_found=${marker_found}"
|
||||
|
||||
if [[ "$issue_status" != "done" || "$marker_found" != "true" ]]; then
|
||||
capture_issue_diagnostics "$CASE_A_ISSUE_ID" "case-a"
|
||||
if [[ -n "$RUN_ID" ]]; then
|
||||
capture_run_diagnostics "$RUN_ID" "case-a"
|
||||
fi
|
||||
capture_openclaw_container_logs
|
||||
fi
|
||||
|
||||
if [[ "$STRICT_CASES" == "1" ]]; then
|
||||
[[ "$run_status" == "succeeded" ]] || fail "case A run did not succeed"
|
||||
[[ "$issue_status" == "done" ]] || fail "case A issue did not reach done"
|
||||
[[ "$marker_found" == "true" ]] || fail "case A marker not found in comments"
|
||||
fi
|
||||
}
|
||||
|
||||
run_case_b() {
|
||||
local marker="OPENCLAW_CASE_B_OK_$(date +%s)"
|
||||
local message_text="${marker}"
|
||||
local description
|
||||
description="Case B validation.\n\nUse the message tool to send this exact text to the user's main chat session in webchat:\n${message_text}\n\nAfter sending, post a Paperclip issue comment containing exactly: ${marker}\nThen mark this issue done."
|
||||
|
||||
local created
|
||||
created="$(create_issue_for_case "[OpenClaw Gateway Smoke] Case B" "$description")"
|
||||
CASE_B_ISSUE_ID="${created%%|*}"
|
||||
local case_identifier="${created##*|}"
|
||||
|
||||
log "case B issue ${CASE_B_ISSUE_ID} (${case_identifier})"
|
||||
trigger_wakeup "openclaw_gateway_smoke_case_b" "$CASE_B_ISSUE_ID"
|
||||
|
||||
local run_status issue_status marker_found
|
||||
if [[ -n "$RUN_ID" ]]; then
|
||||
run_status="$(wait_for_run_terminal "$RUN_ID" "$RUN_TIMEOUT_SEC")"
|
||||
log "case B run ${RUN_ID} status=${run_status}"
|
||||
else
|
||||
run_status="unknown"
|
||||
fi
|
||||
|
||||
issue_status="$(wait_for_issue_terminal "$CASE_B_ISSUE_ID" "$CASE_TIMEOUT_SEC")"
|
||||
marker_found="$(issue_comments_contain "$CASE_B_ISSUE_ID" "$marker")"
|
||||
log "case B issue_status=${issue_status} marker_found=${marker_found}"
|
||||
|
||||
if [[ "$issue_status" != "done" || "$marker_found" != "true" ]]; then
|
||||
capture_issue_diagnostics "$CASE_B_ISSUE_ID" "case-b"
|
||||
if [[ -n "$RUN_ID" ]]; then
|
||||
capture_run_diagnostics "$RUN_ID" "case-b"
|
||||
fi
|
||||
capture_openclaw_container_logs
|
||||
fi
|
||||
|
||||
warn "case B requires manual UX confirmation in OpenClaw main webchat: message '${message_text}' appears in main chat"
|
||||
|
||||
if [[ "$STRICT_CASES" == "1" ]]; then
|
||||
[[ "$run_status" == "succeeded" ]] || fail "case B run did not succeed"
|
||||
[[ "$issue_status" == "done" ]] || fail "case B issue did not reach done"
|
||||
[[ "$marker_found" == "true" ]] || fail "case B marker not found in comments"
|
||||
fi
|
||||
}
|
||||
|
||||
run_case_c() {
|
||||
patch_agent_session_strategy_run
|
||||
|
||||
local marker="OPENCLAW_CASE_C_CREATED_$(date +%s)"
|
||||
local ack_marker="OPENCLAW_CASE_C_ACK_$(date +%s)"
|
||||
local original_issue_reference="the original case issue you are currently reading"
|
||||
local description
|
||||
description="Case C validation.\n\nTreat this run as a fresh/new session.\nCreate a NEW Paperclip issue in this same company with title exactly:\n${marker}\nUse description: 'created by case C smoke'.\n\nThen post a comment on ${original_issue_reference} containing exactly: ${ack_marker}\nDo NOT post the ACK comment on the newly created issue.\nThen mark the original case issue done."
|
||||
|
||||
local created
|
||||
created="$(create_issue_for_case "[OpenClaw Gateway Smoke] Case C" "$description")"
|
||||
CASE_C_ISSUE_ID="${created%%|*}"
|
||||
local case_identifier="${created##*|}"
|
||||
|
||||
log "case C issue ${CASE_C_ISSUE_ID} (${case_identifier})"
|
||||
trigger_wakeup "openclaw_gateway_smoke_case_c" "$CASE_C_ISSUE_ID"
|
||||
|
||||
local run_status issue_status marker_found created_issue
|
||||
if [[ -n "$RUN_ID" ]]; then
|
||||
run_status="$(wait_for_run_terminal "$RUN_ID" "$RUN_TIMEOUT_SEC")"
|
||||
log "case C run ${RUN_ID} status=${run_status}"
|
||||
else
|
||||
run_status="unknown"
|
||||
fi
|
||||
|
||||
issue_status="$(wait_for_issue_terminal "$CASE_C_ISSUE_ID" "$CASE_TIMEOUT_SEC")"
|
||||
marker_found="$(issue_comments_contain "$CASE_C_ISSUE_ID" "$ack_marker")"
|
||||
created_issue="$(find_issue_by_query "$marker")"
|
||||
if [[ "$created_issue" == "$CASE_C_ISSUE_ID" ]]; then
|
||||
created_issue=""
|
||||
fi
|
||||
CASE_C_CREATED_ISSUE_ID="$created_issue"
|
||||
log "case C issue_status=${issue_status} marker_found=${marker_found} created_issue_id=${CASE_C_CREATED_ISSUE_ID:-none}"
|
||||
|
||||
if [[ "$issue_status" != "done" || "$marker_found" != "true" || -z "$CASE_C_CREATED_ISSUE_ID" ]]; then
|
||||
capture_issue_diagnostics "$CASE_C_ISSUE_ID" "case-c"
|
||||
if [[ -n "$CASE_C_CREATED_ISSUE_ID" ]]; then
|
||||
capture_issue_diagnostics "$CASE_C_CREATED_ISSUE_ID" "case-c-created"
|
||||
fi
|
||||
if [[ -n "$RUN_ID" ]]; then
|
||||
capture_run_diagnostics "$RUN_ID" "case-c"
|
||||
fi
|
||||
capture_openclaw_container_logs
|
||||
fi
|
||||
|
||||
if [[ "$STRICT_CASES" == "1" ]]; then
|
||||
[[ "$run_status" == "succeeded" ]] || fail "case C run did not succeed"
|
||||
[[ "$issue_status" == "done" ]] || fail "case C issue did not reach done"
|
||||
[[ "$marker_found" == "true" ]] || fail "case C ack marker not found in comments"
|
||||
[[ -n "$CASE_C_CREATED_ISSUE_ID" ]] || fail "case C did not create the expected new issue"
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
log "starting OpenClaw gateway E2E smoke"
|
||||
mkdir -p "$OPENCLAW_DIAG_DIR"
|
||||
log "diagnostics dir: ${OPENCLAW_DIAG_DIR}"
|
||||
|
||||
wait_http_ready "${PAPERCLIP_API_URL%/}/api/health" 15 || fail "Paperclip API health endpoint not reachable"
|
||||
api_request "GET" "/health"
|
||||
assert_status "200"
|
||||
log "paperclip health deploymentMode=$(jq -r '.deploymentMode // "unknown"' <<<"$RESPONSE_BODY") exposure=$(jq -r '.deploymentExposure // "unknown"' <<<"$RESPONSE_BODY")"
|
||||
|
||||
require_board_auth
|
||||
resolve_company_id
|
||||
cleanup_openclaw_agents
|
||||
cleanup_pending_join_requests
|
||||
|
||||
maybe_cleanup_openclaw_docker
|
||||
start_openclaw_docker
|
||||
wait_http_ready "http://127.0.0.1:18789/" "$OPENCLAW_WAIT_SECONDS" || fail "OpenClaw HTTP health not reachable"
|
||||
|
||||
local gateway_token
|
||||
gateway_token="$(detect_gateway_token || true)"
|
||||
[[ -n "$gateway_token" ]] || fail "could not resolve OpenClaw gateway token"
|
||||
log "resolved gateway token (sha256 prefix $(hash_prefix "$gateway_token"))"
|
||||
|
||||
log "probing gateway websocket challenge at ${OPENCLAW_GATEWAY_URL}"
|
||||
probe_gateway_ws "$OPENCLAW_GATEWAY_URL" "$gateway_token"
|
||||
|
||||
create_and_approve_gateway_join "$gateway_token"
|
||||
log "joined/approved agent ${AGENT_ID} invite=${INVITE_ID} joinRequest=${JOIN_REQUEST_ID}"
|
||||
validate_joined_gateway_agent "$gateway_token"
|
||||
|
||||
local connect_status="unknown"
|
||||
local connect_attempt
|
||||
for connect_attempt in 1 2; do
|
||||
trigger_wakeup "openclaw_gateway_smoke_connectivity_attempt_${connect_attempt}"
|
||||
if [[ -z "$RUN_ID" ]]; then
|
||||
connect_status="unknown"
|
||||
break
|
||||
fi
|
||||
connect_status="$(wait_for_run_terminal "$RUN_ID" "$RUN_TIMEOUT_SEC")"
|
||||
if [[ "$connect_status" == "succeeded" ]]; then
|
||||
log "connectivity wake run ${RUN_ID} succeeded (attempt=${connect_attempt})"
|
||||
break
|
||||
fi
|
||||
|
||||
if [[ "$PAIRING_AUTO_APPROVE" == "1" && "$connect_attempt" -eq 1 ]] && run_log_contains_pairing_required "$RUN_ID"; then
|
||||
log "connectivity run hit pairing gate; attempting one-time pairing approval"
|
||||
approve_latest_pairing_request "$gateway_token" || fail "pairing approval failed after pairing-required run ${RUN_ID}"
|
||||
sleep 2
|
||||
continue
|
||||
fi
|
||||
|
||||
fail "connectivity wake run failed: ${connect_status} (attempt=${connect_attempt}, runId=${RUN_ID})"
|
||||
done
|
||||
[[ "$connect_status" == "succeeded" ]] || fail "connectivity wake run did not succeed after retries"
|
||||
|
||||
run_case_a
|
||||
run_case_b
|
||||
run_case_c
|
||||
|
||||
log "success"
|
||||
log "companyId=${COMPANY_ID}"
|
||||
log "agentId=${AGENT_ID}"
|
||||
log "inviteId=${INVITE_ID}"
|
||||
log "joinRequestId=${JOIN_REQUEST_ID}"
|
||||
log "caseA_issueId=${CASE_A_ISSUE_ID}"
|
||||
log "caseB_issueId=${CASE_B_ISSUE_ID}"
|
||||
log "caseC_issueId=${CASE_C_ISSUE_ID}"
|
||||
log "caseC_createdIssueId=${CASE_C_CREATED_ISSUE_ID:-none}"
|
||||
log "agentApiKeyPrefix=${AGENT_API_KEY:0:12}..."
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Executable
+295
@@ -0,0 +1,295 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if ! command -v curl >/dev/null 2>&1; then
|
||||
echo "curl is required" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! command -v jq >/dev/null 2>&1; then
|
||||
echo "jq is required" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PAPERCLIP_API_URL="${PAPERCLIP_API_URL:-http://localhost:3100}"
|
||||
API_BASE="${PAPERCLIP_API_URL%/}/api"
|
||||
COMPANY_ID="${COMPANY_ID:-${PAPERCLIP_COMPANY_ID:-}}"
|
||||
OPENCLAW_AGENT_NAME="${OPENCLAW_AGENT_NAME:-OpenClaw Smoke Agent}"
|
||||
OPENCLAW_WEBHOOK_URL="${OPENCLAW_WEBHOOK_URL:-}"
|
||||
OPENCLAW_WEBHOOK_AUTH="${OPENCLAW_WEBHOOK_AUTH:-Bearer openclaw-smoke-secret}"
|
||||
USE_DOCKER_RECEIVER="${USE_DOCKER_RECEIVER:-1}"
|
||||
SMOKE_IMAGE="${SMOKE_IMAGE:-paperclip-openclaw-smoke:local}"
|
||||
SMOKE_CONTAINER_NAME="${SMOKE_CONTAINER_NAME:-paperclip-openclaw-smoke}"
|
||||
SMOKE_PORT="${SMOKE_PORT:-19091}"
|
||||
SMOKE_TIMEOUT_SEC="${SMOKE_TIMEOUT_SEC:-45}"
|
||||
|
||||
AUTH_HEADERS=()
|
||||
if [[ -n "${PAPERCLIP_AUTH_HEADER:-}" ]]; then
|
||||
AUTH_HEADERS+=(-H "Authorization: ${PAPERCLIP_AUTH_HEADER}")
|
||||
fi
|
||||
if [[ -n "${PAPERCLIP_COOKIE:-}" ]]; then
|
||||
AUTH_HEADERS+=(-H "Cookie: ${PAPERCLIP_COOKIE}")
|
||||
fi
|
||||
|
||||
STARTED_CONTAINER=0
|
||||
RESPONSE_CODE=""
|
||||
RESPONSE_BODY=""
|
||||
|
||||
log() {
|
||||
echo "[openclaw-smoke] $*"
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo "[openclaw-smoke] ERROR: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
fail_board_auth_required() {
|
||||
local operation="$1"
|
||||
echo "$RESPONSE_BODY" >&2
|
||||
cat >&2 <<EOF
|
||||
[openclaw-smoke] ERROR: ${operation} requires board/operator auth.
|
||||
|
||||
Provide one of:
|
||||
PAPERCLIP_AUTH_HEADER=\"Bearer <board-token>\"
|
||||
PAPERCLIP_COOKIE=\"<board-session-cookie>\"
|
||||
|
||||
Current auth context appears insufficient (HTTP ${RESPONSE_CODE}).
|
||||
EOF
|
||||
exit 1
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
if [[ "$STARTED_CONTAINER" == "1" ]]; then
|
||||
docker rm -f "$SMOKE_CONTAINER_NAME" >/dev/null 2>&1 || true
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
api_request() {
|
||||
local method="$1"
|
||||
local path="$2"
|
||||
local data="${3-}"
|
||||
local tmp
|
||||
tmp="$(mktemp)"
|
||||
local url
|
||||
if [[ "$path" == http://* || "$path" == https://* ]]; then
|
||||
url="$path"
|
||||
elif [[ "$path" == /api/* ]]; then
|
||||
url="${PAPERCLIP_API_URL%/}${path}"
|
||||
else
|
||||
url="${API_BASE}${path}"
|
||||
fi
|
||||
|
||||
if [[ -n "$data" ]]; then
|
||||
RESPONSE_CODE="$(curl -sS -o "$tmp" -w "%{http_code}" -X "$method" "${AUTH_HEADERS[@]}" -H "Content-Type: application/json" "$url" --data "$data")"
|
||||
else
|
||||
RESPONSE_CODE="$(curl -sS -o "$tmp" -w "%{http_code}" -X "$method" "${AUTH_HEADERS[@]}" "$url")"
|
||||
fi
|
||||
RESPONSE_BODY="$(cat "$tmp")"
|
||||
rm -f "$tmp"
|
||||
}
|
||||
|
||||
assert_status() {
|
||||
local expected="$1"
|
||||
if [[ "$RESPONSE_CODE" != "$expected" ]]; then
|
||||
echo "$RESPONSE_BODY" >&2
|
||||
fail "expected HTTP $expected, got HTTP $RESPONSE_CODE"
|
||||
fi
|
||||
}
|
||||
|
||||
assert_json_has_string() {
|
||||
local jq_expr="$1"
|
||||
local value
|
||||
value="$(jq -r "$jq_expr // empty" <<<"$RESPONSE_BODY")"
|
||||
if [[ -z "$value" ]]; then
|
||||
echo "$RESPONSE_BODY" >&2
|
||||
fail "expected JSON string at: $jq_expr"
|
||||
fi
|
||||
echo "$value"
|
||||
}
|
||||
|
||||
if [[ "$USE_DOCKER_RECEIVER" == "1" && -z "$OPENCLAW_WEBHOOK_URL" ]]; then
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
fail "docker is required when USE_DOCKER_RECEIVER=1"
|
||||
fi
|
||||
log "building dockerized OpenClaw webhook receiver image"
|
||||
docker build -t "$SMOKE_IMAGE" -f docker/openclaw-smoke/Dockerfile docker/openclaw-smoke >/dev/null
|
||||
docker rm -f "$SMOKE_CONTAINER_NAME" >/dev/null 2>&1 || true
|
||||
|
||||
log "starting dockerized OpenClaw webhook receiver"
|
||||
docker run -d \
|
||||
--name "$SMOKE_CONTAINER_NAME" \
|
||||
-p "${SMOKE_PORT}:8787" \
|
||||
-e "OPENCLAW_SMOKE_AUTH=${OPENCLAW_WEBHOOK_AUTH}" \
|
||||
"$SMOKE_IMAGE" >/dev/null
|
||||
STARTED_CONTAINER=1
|
||||
OPENCLAW_WEBHOOK_URL="http://127.0.0.1:${SMOKE_PORT}/webhook"
|
||||
|
||||
for _ in $(seq 1 30); do
|
||||
code="$(curl -sS -o /dev/null -w "%{http_code}" "http://127.0.0.1:${SMOKE_PORT}/health" || true)"
|
||||
if [[ "$code" == "200" ]]; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
code="$(curl -sS -o /dev/null -w "%{http_code}" "http://127.0.0.1:${SMOKE_PORT}/health" || true)"
|
||||
if [[ "$code" != "200" ]]; then
|
||||
fail "webhook receiver failed health check on port ${SMOKE_PORT}"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -z "$OPENCLAW_WEBHOOK_URL" ]]; then
|
||||
fail "OPENCLAW_WEBHOOK_URL must be set when USE_DOCKER_RECEIVER=0"
|
||||
fi
|
||||
|
||||
log "checking Paperclip health"
|
||||
api_request "GET" "/health"
|
||||
assert_status "200"
|
||||
DEPLOYMENT_MODE="$(jq -r '.deploymentMode // "unknown"' <<<"$RESPONSE_BODY")"
|
||||
DEPLOYMENT_EXPOSURE="$(jq -r '.deploymentExposure // "unknown"' <<<"$RESPONSE_BODY")"
|
||||
log "deployment mode=${DEPLOYMENT_MODE} exposure=${DEPLOYMENT_EXPOSURE}"
|
||||
|
||||
if [[ -z "$COMPANY_ID" ]]; then
|
||||
log "resolving company id"
|
||||
api_request "GET" "/companies"
|
||||
assert_status "200"
|
||||
COMPANY_ID="$(jq -r '.[0].id // empty' <<<"$RESPONSE_BODY")"
|
||||
if [[ -z "$COMPANY_ID" ]]; then
|
||||
fail "no companies found; create one before running smoke test"
|
||||
fi
|
||||
fi
|
||||
|
||||
log "creating agent-only invite for company ${COMPANY_ID}"
|
||||
INVITE_PAYLOAD="$(jq -nc '{allowedJoinTypes:"agent"}')"
|
||||
api_request "POST" "/companies/${COMPANY_ID}/invites" "$INVITE_PAYLOAD"
|
||||
if [[ "$RESPONSE_CODE" == "401" || "$RESPONSE_CODE" == "403" ]]; then
|
||||
fail_board_auth_required "Invite creation"
|
||||
fi
|
||||
assert_status "201"
|
||||
INVITE_TOKEN="$(assert_json_has_string '.token')"
|
||||
INVITE_ID="$(assert_json_has_string '.id')"
|
||||
log "created invite ${INVITE_ID}"
|
||||
|
||||
log "verifying onboarding JSON and text endpoints"
|
||||
api_request "GET" "/invites/${INVITE_TOKEN}/onboarding"
|
||||
assert_status "200"
|
||||
ONBOARDING_TEXT_PATH="$(jq -r '.invite.onboardingTextPath // empty' <<<"$RESPONSE_BODY")"
|
||||
if [[ -z "$ONBOARDING_TEXT_PATH" ]]; then
|
||||
fail "onboarding manifest missing invite.onboardingTextPath"
|
||||
fi
|
||||
api_request "GET" "/invites/${INVITE_TOKEN}/onboarding.txt"
|
||||
assert_status "200"
|
||||
if ! grep -q "Paperclip OpenClaw Gateway Onboarding" <<<"$RESPONSE_BODY"; then
|
||||
fail "onboarding.txt response missing expected header"
|
||||
fi
|
||||
|
||||
OPENCLAW_GATEWAY_URL="${OPENCLAW_GATEWAY_URL:-ws://127.0.0.1:18789}"
|
||||
OPENCLAW_GATEWAY_TOKEN="${OPENCLAW_GATEWAY_TOKEN:-${OPENCLAW_WEBHOOK_AUTH#Bearer }}"
|
||||
if [[ -z "$OPENCLAW_GATEWAY_TOKEN" ]]; then
|
||||
fail "OPENCLAW_GATEWAY_TOKEN (or OPENCLAW_WEBHOOK_AUTH) is required for gateway join"
|
||||
fi
|
||||
|
||||
log "submitting OpenClaw gateway agent join request"
|
||||
JOIN_PAYLOAD="$(jq -nc \
|
||||
--arg name "$OPENCLAW_AGENT_NAME" \
|
||||
--arg url "$OPENCLAW_GATEWAY_URL" \
|
||||
--arg token "$OPENCLAW_GATEWAY_TOKEN" \
|
||||
'{
|
||||
requestType: "agent",
|
||||
agentName: $name,
|
||||
adapterType: "openclaw_gateway",
|
||||
capabilities: "Automated OpenClaw gateway smoke harness",
|
||||
agentDefaultsPayload: {
|
||||
url: $url,
|
||||
headers: { "x-openclaw-token": $token },
|
||||
sessionKeyStrategy: "issue",
|
||||
waitTimeoutMs: 120000
|
||||
}
|
||||
}')"
|
||||
api_request "POST" "/invites/${INVITE_TOKEN}/accept" "$JOIN_PAYLOAD"
|
||||
assert_status "202"
|
||||
JOIN_REQUEST_ID="$(assert_json_has_string '.id')"
|
||||
CLAIM_SECRET="$(assert_json_has_string '.claimSecret')"
|
||||
CLAIM_API_PATH="$(assert_json_has_string '.claimApiKeyPath')"
|
||||
DIAGNOSTICS_JSON="$(jq -c '.diagnostics // []' <<<"$RESPONSE_BODY")"
|
||||
if [[ "$DIAGNOSTICS_JSON" != "[]" ]]; then
|
||||
log "join diagnostics: ${DIAGNOSTICS_JSON}"
|
||||
fi
|
||||
|
||||
log "approving join request ${JOIN_REQUEST_ID}"
|
||||
api_request "POST" "/companies/${COMPANY_ID}/join-requests/${JOIN_REQUEST_ID}/approve" "{}"
|
||||
if [[ "$RESPONSE_CODE" == "401" || "$RESPONSE_CODE" == "403" ]]; then
|
||||
fail_board_auth_required "Join approval"
|
||||
fi
|
||||
assert_status "200"
|
||||
CREATED_AGENT_ID="$(assert_json_has_string '.createdAgentId')"
|
||||
|
||||
log "verifying invalid claim secret is rejected"
|
||||
api_request "POST" "/join-requests/${JOIN_REQUEST_ID}/claim-api-key" '{"claimSecret":"invalid-smoke-secret-value"}'
|
||||
if [[ "$RESPONSE_CODE" == "201" ]]; then
|
||||
fail "invalid claim secret unexpectedly succeeded"
|
||||
fi
|
||||
|
||||
log "claiming API key with one-time claim secret"
|
||||
CLAIM_PAYLOAD="$(jq -nc --arg secret "$CLAIM_SECRET" '{claimSecret:$secret}')"
|
||||
api_request "POST" "$CLAIM_API_PATH" "$CLAIM_PAYLOAD"
|
||||
assert_status "201"
|
||||
AGENT_API_KEY="$(assert_json_has_string '.token')"
|
||||
KEY_ID="$(assert_json_has_string '.keyId')"
|
||||
|
||||
log "verifying replay claim is rejected"
|
||||
api_request "POST" "$CLAIM_API_PATH" "$CLAIM_PAYLOAD"
|
||||
if [[ "$RESPONSE_CODE" == "201" ]]; then
|
||||
fail "claim secret replay unexpectedly succeeded"
|
||||
fi
|
||||
|
||||
if [[ "$USE_DOCKER_RECEIVER" == "1" && "$STARTED_CONTAINER" == "1" ]]; then
|
||||
curl -sS -X POST "http://127.0.0.1:${SMOKE_PORT}/reset" >/dev/null
|
||||
fi
|
||||
|
||||
log "triggering wakeup for newly created OpenClaw agent"
|
||||
WAKE_PAYLOAD='{"source":"on_demand","triggerDetail":"manual","reason":"openclaw_smoke"}'
|
||||
api_request "POST" "/agents/${CREATED_AGENT_ID}/wakeup" "$WAKE_PAYLOAD"
|
||||
if [[ "$RESPONSE_CODE" == "401" || "$RESPONSE_CODE" == "403" ]]; then
|
||||
fail_board_auth_required "Agent wakeup"
|
||||
fi
|
||||
assert_status "202"
|
||||
RUN_ID="$(jq -r '.id // empty' <<<"$RESPONSE_BODY")"
|
||||
if [[ -z "$RUN_ID" ]]; then
|
||||
log "wakeup response: ${RESPONSE_BODY}"
|
||||
fi
|
||||
|
||||
log "waiting for webhook callback"
|
||||
FOUND_EVENT="0"
|
||||
LAST_EVENTS='{"count":0,"events":[]}'
|
||||
for _ in $(seq 1 "$SMOKE_TIMEOUT_SEC"); do
|
||||
if [[ "$USE_DOCKER_RECEIVER" == "1" && "$STARTED_CONTAINER" == "1" ]]; then
|
||||
LAST_EVENTS="$(curl -sS "http://127.0.0.1:${SMOKE_PORT}/events")"
|
||||
else
|
||||
break
|
||||
fi
|
||||
MATCH_COUNT="$(jq -r --arg agentId "$CREATED_AGENT_ID" '[.events[] | select(((.body.paperclip.agentId // "") == $agentId))] | length' <<<"$LAST_EVENTS")"
|
||||
if [[ "$MATCH_COUNT" -gt 0 ]]; then
|
||||
FOUND_EVENT="1"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if [[ "$USE_DOCKER_RECEIVER" == "1" && "$STARTED_CONTAINER" == "1" && "$FOUND_EVENT" != "1" ]]; then
|
||||
echo "$LAST_EVENTS" | jq '.' >&2
|
||||
fail "did not observe OpenClaw webhook callback within ${SMOKE_TIMEOUT_SEC}s"
|
||||
fi
|
||||
|
||||
log "success"
|
||||
log "companyId=${COMPANY_ID}"
|
||||
log "inviteId=${INVITE_ID}"
|
||||
log "joinRequestId=${JOIN_REQUEST_ID}"
|
||||
log "agentId=${CREATED_AGENT_ID}"
|
||||
log "keyId=${KEY_ID}"
|
||||
if [[ -n "$RUN_ID" ]]; then
|
||||
log "runId=${RUN_ID}"
|
||||
fi
|
||||
if [[ -n "$AGENT_API_KEY" ]]; then
|
||||
log "agentApiKeyPrefix=${AGENT_API_KEY:0:12}..."
|
||||
fi
|
||||
Executable
+146
@@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
log() {
|
||||
echo "[openclaw-sse-standalone] $*"
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo "[openclaw-sse-standalone] ERROR: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
require_cmd() {
|
||||
local cmd="$1"
|
||||
command -v "$cmd" >/dev/null 2>&1 || fail "missing required command: $cmd"
|
||||
}
|
||||
|
||||
require_cmd curl
|
||||
require_cmd jq
|
||||
require_cmd grep
|
||||
|
||||
OPENCLAW_URL="${OPENCLAW_URL:-}"
|
||||
OPENCLAW_METHOD="${OPENCLAW_METHOD:-POST}"
|
||||
OPENCLAW_AUTH_HEADER="${OPENCLAW_AUTH_HEADER:-}"
|
||||
OPENCLAW_TIMEOUT_SEC="${OPENCLAW_TIMEOUT_SEC:-180}"
|
||||
OPENCLAW_MODEL="${OPENCLAW_MODEL:-openclaw}"
|
||||
OPENCLAW_USER="${OPENCLAW_USER:-paperclip-smoke}"
|
||||
|
||||
PAPERCLIP_RUN_ID="${PAPERCLIP_RUN_ID:-smoke-run-$(date +%s)}"
|
||||
PAPERCLIP_AGENT_ID="${PAPERCLIP_AGENT_ID:-openclaw-smoke-agent}"
|
||||
PAPERCLIP_COMPANY_ID="${PAPERCLIP_COMPANY_ID:-openclaw-smoke-company}"
|
||||
PAPERCLIP_API_URL="${PAPERCLIP_API_URL:-http://localhost:3100}"
|
||||
PAPERCLIP_TASK_ID="${PAPERCLIP_TASK_ID:-openclaw-smoke-task}"
|
||||
PAPERCLIP_WAKE_REASON="${PAPERCLIP_WAKE_REASON:-openclaw_smoke_test}"
|
||||
PAPERCLIP_WAKE_COMMENT_ID="${PAPERCLIP_WAKE_COMMENT_ID:-}"
|
||||
PAPERCLIP_APPROVAL_ID="${PAPERCLIP_APPROVAL_ID:-}"
|
||||
PAPERCLIP_APPROVAL_STATUS="${PAPERCLIP_APPROVAL_STATUS:-}"
|
||||
PAPERCLIP_LINKED_ISSUE_IDS="${PAPERCLIP_LINKED_ISSUE_IDS:-}"
|
||||
OPENCLAW_TEXT_PREFIX="${OPENCLAW_TEXT_PREFIX:-Standalone OpenClaw SSE smoke test.}"
|
||||
|
||||
[[ -n "$OPENCLAW_URL" ]] || fail "OPENCLAW_URL is required"
|
||||
|
||||
read -r -d '' TEXT_BODY <<EOF || true
|
||||
${OPENCLAW_TEXT_PREFIX}
|
||||
|
||||
PAPERCLIP_RUN_ID=${PAPERCLIP_RUN_ID}
|
||||
PAPERCLIP_AGENT_ID=${PAPERCLIP_AGENT_ID}
|
||||
PAPERCLIP_COMPANY_ID=${PAPERCLIP_COMPANY_ID}
|
||||
PAPERCLIP_API_URL=${PAPERCLIP_API_URL}
|
||||
PAPERCLIP_TASK_ID=${PAPERCLIP_TASK_ID}
|
||||
PAPERCLIP_WAKE_REASON=${PAPERCLIP_WAKE_REASON}
|
||||
PAPERCLIP_WAKE_COMMENT_ID=${PAPERCLIP_WAKE_COMMENT_ID}
|
||||
PAPERCLIP_APPROVAL_ID=${PAPERCLIP_APPROVAL_ID}
|
||||
PAPERCLIP_APPROVAL_STATUS=${PAPERCLIP_APPROVAL_STATUS}
|
||||
PAPERCLIP_LINKED_ISSUE_IDS=${PAPERCLIP_LINKED_ISSUE_IDS}
|
||||
|
||||
Run your Paperclip heartbeat procedure now.
|
||||
EOF
|
||||
|
||||
PAYLOAD="$(jq -nc \
|
||||
--arg text "$TEXT_BODY" \
|
||||
--arg model "$OPENCLAW_MODEL" \
|
||||
--arg user "$OPENCLAW_USER" \
|
||||
--arg runId "$PAPERCLIP_RUN_ID" \
|
||||
--arg agentId "$PAPERCLIP_AGENT_ID" \
|
||||
--arg companyId "$PAPERCLIP_COMPANY_ID" \
|
||||
--arg apiUrl "$PAPERCLIP_API_URL" \
|
||||
--arg taskId "$PAPERCLIP_TASK_ID" \
|
||||
--arg wakeReason "$PAPERCLIP_WAKE_REASON" \
|
||||
--arg wakeCommentId "$PAPERCLIP_WAKE_COMMENT_ID" \
|
||||
--arg approvalId "$PAPERCLIP_APPROVAL_ID" \
|
||||
--arg approvalStatus "$PAPERCLIP_APPROVAL_STATUS" \
|
||||
--arg linkedIssueIds "$PAPERCLIP_LINKED_ISSUE_IDS" \
|
||||
'{
|
||||
model: $model,
|
||||
user: $user,
|
||||
input: $text,
|
||||
stream: true,
|
||||
metadata: {
|
||||
PAPERCLIP_RUN_ID: $runId,
|
||||
PAPERCLIP_AGENT_ID: $agentId,
|
||||
PAPERCLIP_COMPANY_ID: $companyId,
|
||||
PAPERCLIP_API_URL: $apiUrl,
|
||||
PAPERCLIP_TASK_ID: $taskId,
|
||||
PAPERCLIP_WAKE_REASON: $wakeReason,
|
||||
PAPERCLIP_WAKE_COMMENT_ID: $wakeCommentId,
|
||||
PAPERCLIP_APPROVAL_ID: $approvalId,
|
||||
PAPERCLIP_APPROVAL_STATUS: $approvalStatus,
|
||||
PAPERCLIP_LINKED_ISSUE_IDS: $linkedIssueIds,
|
||||
paperclip_session_key: ("paperclip:run:" + $runId)
|
||||
}
|
||||
}')"
|
||||
|
||||
headers_file="$(mktemp)"
|
||||
body_file="$(mktemp)"
|
||||
cleanup() {
|
||||
rm -f "$headers_file" "$body_file"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
args=(
|
||||
-sS
|
||||
-N
|
||||
--max-time "$OPENCLAW_TIMEOUT_SEC"
|
||||
-X "$OPENCLAW_METHOD"
|
||||
-H "content-type: application/json"
|
||||
-H "accept: text/event-stream"
|
||||
-H "x-openclaw-session-key: paperclip:run:${PAPERCLIP_RUN_ID}"
|
||||
-D "$headers_file"
|
||||
-o "$body_file"
|
||||
--data "$PAYLOAD"
|
||||
"$OPENCLAW_URL"
|
||||
)
|
||||
|
||||
if [[ -n "$OPENCLAW_AUTH_HEADER" ]]; then
|
||||
args=(-H "Authorization: $OPENCLAW_AUTH_HEADER" "${args[@]}")
|
||||
fi
|
||||
|
||||
log "posting SSE wake payload to ${OPENCLAW_URL}"
|
||||
http_code="$(curl "${args[@]}" -w "%{http_code}")"
|
||||
log "http status: ${http_code}"
|
||||
|
||||
if [[ ! "$http_code" =~ ^2 ]]; then
|
||||
tail -n 80 "$body_file" >&2 || true
|
||||
fail "non-success HTTP status: ${http_code}"
|
||||
fi
|
||||
|
||||
if ! grep -Eqi '^content-type:.*text/event-stream' "$headers_file"; then
|
||||
tail -n 40 "$body_file" >&2 || true
|
||||
fail "response content-type was not text/event-stream"
|
||||
fi
|
||||
|
||||
if grep -Eqi 'event:\s*(error|failed|cancel)|"status":"(failed|cancelled|error)"|"type":"[^"]*(failed|cancelled|error)"' "$body_file"; then
|
||||
tail -n 120 "$body_file" >&2 || true
|
||||
fail "stream reported a failure event"
|
||||
fi
|
||||
|
||||
if ! grep -Eqi 'event:\s*(done|completed|response\.completed)|\[DONE\]|"status":"(completed|succeeded|done)"|"type":"response\.completed"' "$body_file"; then
|
||||
tail -n 120 "$body_file" >&2 || true
|
||||
fail "stream ended without a terminal completion marker"
|
||||
fi
|
||||
|
||||
event_count="$(grep -Ec '^event:' "$body_file" || true)"
|
||||
log "stream completed successfully (events=${event_count})"
|
||||
echo
|
||||
tail -n 40 "$body_file"
|
||||
Executable
+424
@@ -0,0 +1,424 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if ! command -v jq >/dev/null 2>&1; then
|
||||
echo "jq is required for the pipeline tutorial smoke." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
: "${PAPERCLIP_API_URL:?Set PAPERCLIP_API_URL for the target dev instance.}"
|
||||
: "${PAPERCLIP_API_KEY:?Set PAPERCLIP_API_KEY for the target dev instance.}"
|
||||
: "${PAPERCLIP_COMPANY_ID:?Set PAPERCLIP_COMPANY_ID for the target dev company.}"
|
||||
|
||||
read -r -a PC_CMD <<< "${PAPERCLIPAI_CMD:-pnpm --silent paperclipai}"
|
||||
RUN_KEY="${PIPELINE_SMOKE_KEY:-$(date +%Y%m%d%H%M%S)}"
|
||||
RELEASE_PIPELINE="release-coverage-${RUN_KEY}"
|
||||
FEATURE_PIPELINE="feature-content-${RUN_KEY}"
|
||||
CONTENT_PIPELINE="content-production-${RUN_KEY}"
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$TMP_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
pc_json() {
|
||||
"${PC_CMD[@]}" "$@" --json -C "$PAPERCLIP_COMPANY_ID"
|
||||
}
|
||||
|
||||
api_json() {
|
||||
local method="$1"
|
||||
local path="$2"
|
||||
local body="${3:-}"
|
||||
if [[ -n "$body" ]]; then
|
||||
curl -sS -X "$method" \
|
||||
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data "$body" \
|
||||
"${PAPERCLIP_API_URL%/}$path"
|
||||
else
|
||||
curl -sS -X "$method" \
|
||||
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
"${PAPERCLIP_API_URL%/}$path"
|
||||
fi
|
||||
}
|
||||
|
||||
pick_agent_id() {
|
||||
if [[ -n "${DRAFTING_AGENT_ID:-}" ]]; then
|
||||
echo "$DRAFTING_AGENT_ID"
|
||||
return
|
||||
fi
|
||||
local company_agent_id
|
||||
company_agent_id="$(pc_json agent list 2>/dev/null | jq -r 'map(select(.status != "terminated"))[0].id // empty')"
|
||||
if [[ -n "$company_agent_id" ]]; then
|
||||
echo "$company_agent_id"
|
||||
return
|
||||
fi
|
||||
echo ""
|
||||
}
|
||||
|
||||
require_json() {
|
||||
local json="$1"
|
||||
local filter="$2"
|
||||
local message="$3"
|
||||
if ! jq -e "$filter" >/dev/null <<<"$json"; then
|
||||
echo "$message" >&2
|
||||
echo "$json" | jq . >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
case_id() {
|
||||
local json="$1"
|
||||
local key="$2"
|
||||
jq -r --arg key "$key" '.[] | select(.case.caseKey == $key) | .case.id' <<<"$json"
|
||||
}
|
||||
|
||||
case_version() {
|
||||
pc_json pipelines case get "$1" | jq -r '.case.version'
|
||||
}
|
||||
|
||||
case_stage() {
|
||||
pc_json pipelines case get "$1" | jq -r '.stage.key'
|
||||
}
|
||||
|
||||
cat >"$TMP_DIR/release-stages.json" <<'JSON'
|
||||
[
|
||||
{
|
||||
"key": "intake",
|
||||
"name": "Intake",
|
||||
"kind": "open",
|
||||
"position": 100,
|
||||
"config": { "autoAdvanceOnChildrenTerminal": "covered" }
|
||||
},
|
||||
{ "key": "covered", "name": "Covered", "kind": "done", "position": 900 },
|
||||
{ "key": "cancelled", "name": "Cancelled", "kind": "cancelled", "position": 1000 }
|
||||
]
|
||||
JSON
|
||||
|
||||
cat >"$TMP_DIR/feature-stages.json" <<'JSON'
|
||||
[
|
||||
{ "key": "suggesting", "name": "Suggesting", "kind": "open", "position": 100 },
|
||||
{
|
||||
"key": "suggestion_review",
|
||||
"name": "Suggestion Review",
|
||||
"kind": "review",
|
||||
"position": 200,
|
||||
"config": {
|
||||
"approveToStageKey": "producing",
|
||||
"rejectToStageKey": "cancelled",
|
||||
"requestChangesToStageKey": "suggesting",
|
||||
"requireRejectReason": true,
|
||||
"reviewerKind": "human"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "producing",
|
||||
"name": "Producing",
|
||||
"kind": "working",
|
||||
"position": 300,
|
||||
"config": { "autoAdvanceOnChildrenTerminal": "covered" }
|
||||
},
|
||||
{ "key": "covered", "name": "Covered", "kind": "done", "position": 900 },
|
||||
{ "key": "cancelled", "name": "Cancelled", "kind": "cancelled", "position": 1000 }
|
||||
]
|
||||
JSON
|
||||
|
||||
cat >"$TMP_DIR/content-stages.json" <<'JSON'
|
||||
[
|
||||
{
|
||||
"key": "drafting",
|
||||
"name": "Drafting",
|
||||
"kind": "working",
|
||||
"position": 100,
|
||||
"config": { "autonomy": "suggest" }
|
||||
},
|
||||
{
|
||||
"key": "assets",
|
||||
"name": "Assets",
|
||||
"kind": "working",
|
||||
"position": 200
|
||||
},
|
||||
{
|
||||
"key": "assembly",
|
||||
"name": "Assembly",
|
||||
"kind": "working",
|
||||
"position": 300,
|
||||
"config": { "autoAdvanceOnChildrenTerminal": "final_review" }
|
||||
},
|
||||
{
|
||||
"key": "final_review",
|
||||
"name": "Final Review",
|
||||
"kind": "review",
|
||||
"position": 400,
|
||||
"config": {
|
||||
"approveToStageKey": "publishing",
|
||||
"rejectToStageKey": "dropped",
|
||||
"requestChangesToStageKey": "drafting",
|
||||
"requireRejectReason": true,
|
||||
"reviewerKind": "human"
|
||||
}
|
||||
},
|
||||
{ "key": "publishing", "name": "Publishing", "kind": "working", "position": 500 },
|
||||
{ "key": "published", "name": "Published", "kind": "done", "position": 900 },
|
||||
{ "key": "dropped", "name": "Dropped", "kind": "cancelled", "position": 1000 }
|
||||
]
|
||||
JSON
|
||||
|
||||
cat >"$TMP_DIR/release-transitions.json" <<'JSON'
|
||||
{
|
||||
"enforceTransitions": true,
|
||||
"transitions": [
|
||||
{ "fromStageKey": "intake", "toStageKey": "covered", "label": "all features terminal" },
|
||||
{ "fromStageKey": "intake", "toStageKey": "cancelled", "label": "cancel release coverage" }
|
||||
]
|
||||
}
|
||||
JSON
|
||||
|
||||
cat >"$TMP_DIR/content-guidance.md" <<'MD'
|
||||
# Content Production guidance
|
||||
|
||||
Final Review has three exits:
|
||||
|
||||
- approve to Publishing when the pinned revisions are ready to ship
|
||||
- request changes back to Drafting when the same work issue should continue
|
||||
- drop to Dropped when the content should not ship
|
||||
|
||||
Convention: asset cases store `briefedFromVersion` in `fields` so assembly review can compare a pinned brief against the current upstream case `version`.
|
||||
MD
|
||||
|
||||
agent_id="$(pick_agent_id)"
|
||||
routine_payload="$(jq -cn --arg agentId "$agent_id" '{
|
||||
title: "Pipeline tutorial drafting routine",
|
||||
description: "Template convention: draft the content case from the Pipeline Case Context, keep typed work references in case fields, and suggest Drafting -> Assets when ready.",
|
||||
priority: "medium",
|
||||
status: "active",
|
||||
concurrencyPolicy: "always_enqueue",
|
||||
catchUpPolicy: "skip_missed"
|
||||
} + (if $agentId != "" then { assigneeAgentId: $agentId } else {} end)')"
|
||||
routine="$(pc_json routine create --payload-json "$routine_payload")"
|
||||
routine_id="$(jq -r '.id' <<<"$routine")"
|
||||
|
||||
release_pipeline="$(pc_json pipelines create --key "$RELEASE_PIPELINE" --name "Smoke Release Coverage $RUN_KEY" --stages-file "$TMP_DIR/release-stages.json")"
|
||||
feature_pipeline="$(pc_json pipelines create --key "$FEATURE_PIPELINE" --name "Smoke Feature Content $RUN_KEY" --stages-file "$TMP_DIR/feature-stages.json")"
|
||||
content_pipeline="$(pc_json pipelines create --key "$CONTENT_PIPELINE" --name "Smoke Content Production $RUN_KEY" --stages-file "$TMP_DIR/content-stages.json")"
|
||||
require_json "$release_pipeline" '.id and (.stages | length == 3)' "Release Coverage pipeline creation failed."
|
||||
require_json "$feature_pipeline" '.id and (.stages | length == 5)' "Feature Content pipeline creation failed."
|
||||
require_json "$content_pipeline" '.id and (.stages | length == 7)' "Content Production pipeline creation failed."
|
||||
|
||||
pc_json pipelines set-transitions "$RELEASE_PIPELINE" --file "$TMP_DIR/release-transitions.json" >/dev/null
|
||||
pc_json pipelines guidance put "$CONTENT_PIPELINE" --file "$TMP_DIR/content-guidance.md" >/dev/null
|
||||
pc_json pipelines set-automation "$CONTENT_PIPELINE" --stage drafting --routine "$routine_id" --note "Template-versioned with the routine prompt." >/dev/null
|
||||
|
||||
release="$(pc_json pipelines ingest "$RELEASE_PIPELINE" \
|
||||
--case-key "release-${RUN_KEY}" \
|
||||
--stage intake \
|
||||
--title "Release $RUN_KEY: Pipeline primitives" \
|
||||
--summary "Rollup root for the tutorial smoke." \
|
||||
--fields-json '{"release":"v0.pipeline-smoke","templateVersionConvention":"routine-prompt"}')"
|
||||
release_case_id="$(jq -r '.case.id' <<<"$release")"
|
||||
|
||||
jq -n --arg parent "$release_case_id" '{
|
||||
items: [
|
||||
{
|
||||
caseKey: "feature-pipelines-ui",
|
||||
title: "Feature: Pipelines UI",
|
||||
summary: "Worth a content package.",
|
||||
parentCaseId: $parent,
|
||||
stageKey: "suggestion_review",
|
||||
fields: { releaseTag: "v0.pipeline-smoke", source: "release-notes" }
|
||||
},
|
||||
{
|
||||
caseKey: "feature-routine-webhooks",
|
||||
title: "Feature: Routine webhooks",
|
||||
summary: "Rejected by the gate for this release.",
|
||||
parentCaseId: $parent,
|
||||
stageKey: "suggestion_review",
|
||||
fields: { releaseTag: "v0.pipeline-smoke", source: "release-notes" }
|
||||
}
|
||||
]
|
||||
}' >"$TMP_DIR/feature-cases.json"
|
||||
|
||||
features="$(pc_json pipelines ingest-batch "$FEATURE_PIPELINE" --file "$TMP_DIR/feature-cases.json")"
|
||||
require_json "$features" 'length == 2 and all(.ok == true)' "Feature batch ingest did not create two cases."
|
||||
feature_main="$(case_id "$features" feature-pipelines-ui)"
|
||||
feature_dropped="$(case_id "$features" feature-routine-webhooks)"
|
||||
|
||||
jq -n --arg main "$feature_main" --arg dropped "$feature_dropped" '{
|
||||
items: [
|
||||
{ caseId: $main, decision: "approve", expectedVersion: 1 },
|
||||
{ caseId: $dropped, decision: "reject", reason: "Fold webhooks into the broader launch post.", expectedVersion: 1 }
|
||||
]
|
||||
}' >"$TMP_DIR/feature-review.json"
|
||||
feature_review="$(pc_json pipelines review-bulk --file "$TMP_DIR/feature-review.json")"
|
||||
require_json "$feature_review" '.results | length == 2 and all(.ok == true)' "Feature review decisions failed."
|
||||
require_json "$(pc_json pipelines case get "$feature_main")" '.stage.key == "producing" and .case.version == 2' "Approved feature should enter Producing."
|
||||
require_json "$(pc_json pipelines case get "$feature_dropped")" '.stage.key == "cancelled" and .case.terminalKind == "cancelled"' "Rejected feature should be cancelled."
|
||||
|
||||
jq -n --arg parent "$feature_main" '{
|
||||
items: [
|
||||
{
|
||||
caseKey: "blog-post",
|
||||
title: "Launch blog post",
|
||||
summary: "Draft the release narrative.",
|
||||
parentCaseId: $parent,
|
||||
stageKey: "drafting",
|
||||
fields: {
|
||||
contentType: "blog",
|
||||
typedWorkRefs: { draftPath: "workspaces/release/blog.md" },
|
||||
briefedFromVersion: null
|
||||
}
|
||||
},
|
||||
{
|
||||
caseKey: "changelog-entry",
|
||||
title: "Product changelog",
|
||||
summary: "Compact changelog entry.",
|
||||
parentCaseId: $parent,
|
||||
stageKey: "drafting",
|
||||
fields: {
|
||||
contentType: "changelog",
|
||||
typedWorkRefs: { draftPath: "workspaces/release/changelog.md" },
|
||||
briefedFromVersion: null
|
||||
}
|
||||
},
|
||||
{
|
||||
caseKey: "launch-tweet",
|
||||
title: "Launch tweet",
|
||||
summary: "Tweet after the blog is approved.",
|
||||
parentCaseId: $parent,
|
||||
stageKey: "drafting",
|
||||
blockedByCaseKeys: ["blog-post"],
|
||||
fields: {
|
||||
contentType: "social",
|
||||
typedWorkRefs: { draftPath: "workspaces/release/tweet.md" },
|
||||
briefedFromVersion: 1
|
||||
}
|
||||
}
|
||||
]
|
||||
}' >"$TMP_DIR/content-cases.json"
|
||||
|
||||
content_cases="$(pc_json pipelines ingest-batch "$CONTENT_PIPELINE" --file "$TMP_DIR/content-cases.json")"
|
||||
require_json "$content_cases" 'length == 3 and all(.ok == true)' "Content batch ingest did not create three cases."
|
||||
blog_case="$(case_id "$content_cases" blog-post)"
|
||||
changelog_case="$(case_id "$content_cases" changelog-entry)"
|
||||
tweet_case="$(case_id "$content_cases" launch-tweet)"
|
||||
|
||||
set +e
|
||||
blocked_output="$(pc_json pipelines case transition "$tweet_case" --to assets --expected-version 1 --reason "Try before upstream blog is published." 2>&1)"
|
||||
blocked_status=$?
|
||||
set -e
|
||||
if [[ "$blocked_status" -eq 0 || "$blocked_output" != *"code=blocked"* ]]; then
|
||||
echo "Expected blocked transition to fail with code=blocked." >&2
|
||||
echo "$blocked_output" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
work_issue="$(pc_json issue create \
|
||||
--title "Smoke work issue for launch tweet $RUN_KEY" \
|
||||
--description "Receives drift comments from the upstream blog case." \
|
||||
--status todo \
|
||||
--priority low)"
|
||||
work_issue_id="$(jq -r '.id' <<<"$work_issue")"
|
||||
api_json POST "/api/cases/$tweet_case/issue-links" "$(jq -cn --arg issueId "$work_issue_id" '{ issueId: $issueId, role: "work" }')" >/dev/null
|
||||
|
||||
suggestion="$(pc_json pipelines case suggest "$blog_case" --to assets --rationale "Draft is stable enough to brief asset work." --confidence 0.9)"
|
||||
suggestion_id="$(jq -r '.suggestion.id' <<<"$suggestion")"
|
||||
pc_json pipelines case resolve-suggestion "$blog_case" --suggestion "$suggestion_id" --accept --expected-version 1 >/dev/null
|
||||
require_json "$(pc_json pipelines case get "$blog_case")" '.stage.key == "assets" and .case.version == 2' "Accepted readiness suggestion should move blog to Assets."
|
||||
|
||||
pc_json pipelines case edit "$blog_case" \
|
||||
--expected-version 2 \
|
||||
--summary "Draft changed while dependent tweet work was already briefed." \
|
||||
--fields-json '{"contentType":"blog","typedWorkRefs":{"draftPath":"workspaces/release/blog.md"},"briefedFromVersion":null,"materialChange":"new-positioning"}' >/dev/null
|
||||
|
||||
comments="$(api_json GET "/api/issues/$work_issue_id/comments")"
|
||||
require_json "$comments" 'length >= 1 and (.[-1].body | contains("changed (v2"))' "Dependent work issue did not receive the upstream drift comment."
|
||||
|
||||
set +e
|
||||
conflict_output="$(pc_json pipelines case edit "$blog_case" --expected-version 2 --title "Stale edit" 2>&1)"
|
||||
conflict_status=$?
|
||||
set -e
|
||||
if [[ "$conflict_status" -eq 0 || "$conflict_output" != *"code=version_conflict"* ]]; then
|
||||
echo "Expected stale edit to fail with code=version_conflict." >&2
|
||||
echo "$conflict_output" >&2
|
||||
exit 1
|
||||
fi
|
||||
blog_version="$(case_version "$blog_case")"
|
||||
|
||||
jq -n --arg parent "$feature_main" --argjson briefVersion "$blog_version" '{
|
||||
items: [
|
||||
{
|
||||
caseKey: "blog-hero-image",
|
||||
title: "Hero image",
|
||||
parentCaseId: $parent,
|
||||
stageKey: "assets",
|
||||
fields: { assetType: "image", briefedFromVersion: $briefVersion }
|
||||
},
|
||||
{
|
||||
caseKey: "blog-social-card",
|
||||
title: "Social card",
|
||||
parentCaseId: $parent,
|
||||
stageKey: "assets",
|
||||
fields: { assetType: "image", briefedFromVersion: $briefVersion }
|
||||
}
|
||||
]
|
||||
}' >"$TMP_DIR/asset-cases.json"
|
||||
asset_cases="$(pc_json pipelines ingest-batch "$CONTENT_PIPELINE" --file "$TMP_DIR/asset-cases.json")"
|
||||
require_json "$asset_cases" 'length == 2 and all(.ok == true)' "Asset batch ingest failed."
|
||||
asset_hero="$(case_id "$asset_cases" blog-hero-image)"
|
||||
asset_card="$(case_id "$asset_cases" blog-social-card)"
|
||||
|
||||
pc_json pipelines case transition "$asset_hero" --to published --expected-version 1 --reason "Hero image done." >/dev/null
|
||||
pc_json pipelines case transition "$asset_card" --to dropped --expected-version 1 --reason "Social card not needed." >/dev/null
|
||||
blog_assets_version="$(case_version "$blog_case")"
|
||||
pc_json pipelines case transition "$blog_case" --to assembly --expected-version "$blog_assets_version" --reason "Assets complete; assemble the package." >/dev/null
|
||||
require_json "$(pc_json pipelines case get "$blog_case")" '.stage.key == "assembly"' "Blog should enter Assembly after assets are complete."
|
||||
|
||||
jq -n --arg parent "$blog_case" '{
|
||||
items: [
|
||||
{
|
||||
caseKey: "blog-assembly-package",
|
||||
title: "Assembled blog package",
|
||||
parentCaseId: $parent,
|
||||
stageKey: "assembly",
|
||||
fields: { packageType: "blog", assembledFrom: ["blog-hero-image", "blog-social-card"] }
|
||||
}
|
||||
]
|
||||
}' >"$TMP_DIR/assembly-cases.json"
|
||||
assembly_cases="$(pc_json pipelines ingest-batch "$CONTENT_PIPELINE" --file "$TMP_DIR/assembly-cases.json")"
|
||||
require_json "$assembly_cases" 'length == 1 and all(.ok == true)' "Assembly batch ingest failed."
|
||||
assembly_package="$(case_id "$assembly_cases" blog-assembly-package)"
|
||||
pc_json pipelines case transition "$assembly_package" --to published --expected-version 1 --reason "Assembly complete." >/dev/null
|
||||
require_json "$(pc_json pipelines case get "$blog_case")" '.stage.key == "final_review"' "Blog should auto-advance from Assembly to Final Review."
|
||||
|
||||
blog_review_version="$(case_version "$blog_case")"
|
||||
pc_json pipelines case review "$blog_case" --approve --expected-version "$blog_review_version" >/dev/null
|
||||
pc_json pipelines case transition "$blog_case" --to published --expected-version "$((blog_review_version + 1))" --reason "Approved package published." >/dev/null
|
||||
|
||||
pc_json pipelines case transition "$changelog_case" --to final_review --expected-version 1 --reason "Draft ready for final review." >/dev/null
|
||||
pc_json pipelines case review "$changelog_case" --request-changes --reason "Tighten the framing before publishing." --expected-version 2 >/dev/null
|
||||
require_json "$(pc_json pipelines case get "$changelog_case")" '.stage.key == "drafting" and .case.version == 3' "Request changes should return changelog to Drafting."
|
||||
pc_json pipelines case edit "$changelog_case" \
|
||||
--expected-version 3 \
|
||||
--summary "Revised changelog entry after requested changes." \
|
||||
--fields-json '{"contentType":"changelog","typedWorkRefs":{"draftPath":"workspaces/release/changelog.md"},"changeRequestAddressed":true}' >/dev/null
|
||||
pc_json pipelines case transition "$changelog_case" --to final_review --expected-version 4 --reason "Revised draft ready." >/dev/null
|
||||
pc_json pipelines case review "$changelog_case" --approve --expected-version 5 >/dev/null
|
||||
pc_json pipelines case transition "$changelog_case" --to published --expected-version 6 --reason "Published after request-changes loop." >/dev/null
|
||||
|
||||
pc_json pipelines case transition "$tweet_case" --to final_review --expected-version 1 --reason "Blog blocker is now done." >/dev/null
|
||||
pc_json pipelines case review "$tweet_case" --reject --reason "Drop this tweet; blog already covers the announcement." --expected-version 2 >/dev/null
|
||||
|
||||
require_json "$(pc_json pipelines case get "$feature_main")" '.stage.key == "covered" and .case.terminalKind == "done"' "Feature case should be covered after content children are terminal."
|
||||
require_json "$(pc_json pipelines case get "$release_case_id")" '.stage.key == "covered" and .case.terminalKind == "done"' "Release case should be covered after feature children are terminal."
|
||||
|
||||
rollup="$(pc_json pipelines case rollup "$release_case_id")"
|
||||
require_json "$rollup" '.complete == true and .done == 5 and .cancelled == 3 and .open == 0 and .total == 8' "Release rollup did not report the expected done/cancelled split."
|
||||
|
||||
events="$(pc_json pipelines case events "$changelog_case")"
|
||||
require_json "$events" '([.items[].type] | index("review_decided")) and ([.items[] | select(.type == "review_decided") | .payload.decision] | index("request_changes") and index("approve"))' "Changelog event history is missing request-changes and approval decisions."
|
||||
|
||||
release_events="$(pc_json pipelines case events "$release_case_id")"
|
||||
require_json "$release_events" '.items | map(.type) | index("children_terminal") and index("transitioned")' "Release event history is missing rollup provenance."
|
||||
|
||||
echo "Pipeline tutorial smoke passed for $RUN_KEY"
|
||||
+361
@@ -0,0 +1,361 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const repoRoot = join(fileURLToPath(new URL(".", import.meta.url)), "..", "..");
|
||||
|
||||
function parseArgs(argv) {
|
||||
const parsed = {
|
||||
keep: false,
|
||||
sourceIssueId: process.env.PAPERCLIP_TASK_ID ?? null,
|
||||
projectId: process.env.PAPERCLIP_PROJECT_ID ?? null,
|
||||
goalId: process.env.PAPERCLIP_GOAL_ID ?? null,
|
||||
runKey: null,
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === "--keep") {
|
||||
parsed.keep = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--source-issue-id") {
|
||||
parsed.sourceIssueId = argv[++index] ?? null;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--project-id") {
|
||||
parsed.projectId = argv[++index] ?? null;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--goal-id") {
|
||||
parsed.goalId = argv[++index] ?? null;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--run-key") {
|
||||
parsed.runKey = argv[++index] ?? null;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
printUsage();
|
||||
process.exit(0);
|
||||
}
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function printUsage() {
|
||||
console.log(`
|
||||
Usage:
|
||||
PAPERCLIP_API_URL=http://localhost:3100 \\
|
||||
PAPERCLIP_API_KEY=... \\
|
||||
PAPERCLIP_COMPANY_ID=... \\
|
||||
pnpm smoke:terminal-bench-loop-skill
|
||||
|
||||
Options:
|
||||
--source-issue-id <uuid> Attach smoke issues under an existing Paperclip issue.
|
||||
--project-id <uuid> Override inferred project id.
|
||||
--goal-id <uuid> Override inferred goal id.
|
||||
--run-key <string> Stable key used in smoke titles and mocked artifact paths.
|
||||
--keep Leave smoke issues in their verified blocked/in_review posture.
|
||||
`);
|
||||
}
|
||||
|
||||
function requireEnv(name) {
|
||||
const value = process.env[name];
|
||||
if (!value) {
|
||||
throw new Error(`${name} is required. Run against a local Paperclip server with an agent or board API token.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function slugify(value) {
|
||||
return value.replace(/[^a-zA-Z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80);
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function assertLocalSkillPackage() {
|
||||
const skillPath = join(repoRoot, ".agents", "skills", "terminal-bench-loop", "SKILL.md");
|
||||
const markdown = await readFile(skillPath, "utf8");
|
||||
for (const expected of [
|
||||
"name: terminal-bench-loop",
|
||||
"request_confirmation",
|
||||
"diagnosis",
|
||||
"blockedByIssueIds",
|
||||
"PAPERCLIPAI_CMD",
|
||||
"PAPERCLIP_HARBOR_RUNNER_CONFIG",
|
||||
]) {
|
||||
assert(markdown.includes(expected), `Skill smoke expected ${skillPath} to mention ${expected}`);
|
||||
}
|
||||
}
|
||||
|
||||
function createApiClient({ apiUrl, apiKey, runId }) {
|
||||
const baseUrl = apiUrl.replace(/\/+$/, "");
|
||||
|
||||
return async function api(method, path, { body, ok } = {}) {
|
||||
const expectedStatuses = ok ?? (method === "POST" || method === "PUT" ? [200, 201] : [200]);
|
||||
const headers = {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
Accept: "application/json",
|
||||
};
|
||||
if (body !== undefined) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
if (runId && method !== "GET") {
|
||||
headers["X-Paperclip-Run-Id"] = runId;
|
||||
}
|
||||
|
||||
const response = await fetch(`${baseUrl}${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
const text = await response.text();
|
||||
const data = text ? JSON.parse(text) : null;
|
||||
if (!expectedStatuses.includes(response.status)) {
|
||||
throw new Error(`${method} ${path} returned ${response.status}: ${text}`);
|
||||
}
|
||||
return data;
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const apiUrl = requireEnv("PAPERCLIP_API_URL");
|
||||
const apiKey = requireEnv("PAPERCLIP_API_KEY");
|
||||
const companyId = requireEnv("PAPERCLIP_COMPANY_ID");
|
||||
const runId = process.env.PAPERCLIP_RUN_ID ?? null;
|
||||
const api = createApiClient({ apiUrl, apiKey, runId });
|
||||
|
||||
await assertLocalSkillPackage();
|
||||
|
||||
const sourceIssue = args.sourceIssueId
|
||||
? await api("GET", `/api/issues/${args.sourceIssueId}`)
|
||||
: null;
|
||||
const projectId = args.projectId ?? sourceIssue?.projectId ?? null;
|
||||
const goalId = args.goalId ?? sourceIssue?.goalId ?? null;
|
||||
const runKey = slugify(args.runKey ?? runId ?? `local-${new Date().toISOString()}`);
|
||||
const artifactRoot = `mock://terminal-bench-loop-smoke/${runKey}`;
|
||||
const titlePrefix = `[smoke:${runKey}]`;
|
||||
const commonIssueFields = {
|
||||
...(projectId ? { projectId } : {}),
|
||||
...(goalId ? { goalId } : {}),
|
||||
priority: "low",
|
||||
};
|
||||
|
||||
const loop = await api("POST", `/api/companies/${companyId}/issues`, {
|
||||
body: {
|
||||
...commonIssueFields,
|
||||
...(sourceIssue ? { parentId: sourceIssue.id } : {}),
|
||||
title: `${titlePrefix} Terminal-Bench loop skill smoke`,
|
||||
status: "todo",
|
||||
description: [
|
||||
"Deterministic smoke for the /terminal-bench-loop skill.",
|
||||
"",
|
||||
"- Task: terminal-bench/fix-git",
|
||||
"- Iteration budget: 1",
|
||||
"- Benchmark command: mocked; no Terminal-Bench, Harbor, model, or provider process is started.",
|
||||
`- Artifact root: ${artifactRoot}`,
|
||||
].join("\n"),
|
||||
},
|
||||
});
|
||||
|
||||
const iteration = await api("POST", `/api/companies/${companyId}/issues`, {
|
||||
body: {
|
||||
...commonIssueFields,
|
||||
parentId: loop.id,
|
||||
title: `${titlePrefix} Iteration 1: terminal-bench/fix-git`,
|
||||
status: "todo",
|
||||
description: [
|
||||
"Smoke iteration child created by the deterministic terminal-bench-loop skill smoke.",
|
||||
"",
|
||||
"This issue records mocked run artifacts, diagnosis, and the pending confirmation path.",
|
||||
].join("\n"),
|
||||
},
|
||||
});
|
||||
|
||||
const runDocument = await api("PUT", `/api/issues/${iteration.id}/documents/run`, {
|
||||
body: {
|
||||
title: "Mocked benchmark run",
|
||||
format: "markdown",
|
||||
body: [
|
||||
"# Mocked benchmark run",
|
||||
"",
|
||||
"- Label: smoke / non-comparable",
|
||||
"- Terminal-Bench task: terminal-bench/fix-git",
|
||||
"- Stop reason: verifier_failed",
|
||||
`- Manifest: ${artifactRoot}/manifest.json`,
|
||||
`- Results JSONL: ${artifactRoot}/results.jsonl`,
|
||||
`- Harbor raw job folder: ${artifactRoot}/harbor/raw-job`,
|
||||
"- Dispatch config: PAPERCLIP_HARBOR_RUNNER_CONFIG=<omitted - harness/setup no-dispatch smoke>",
|
||||
"- Heartbeat-enabled agents: 0 (harness/setup no-dispatch; not a product signal)",
|
||||
"",
|
||||
"No benchmark process, Harbor job, model call, or provider call was started.",
|
||||
].join("\n"),
|
||||
changeSummary: "Record deterministic mocked benchmark artifact paths.",
|
||||
},
|
||||
});
|
||||
|
||||
const diagnosisDocument = await api("PUT", `/api/issues/${iteration.id}/documents/diagnosis`, {
|
||||
body: {
|
||||
title: "Smoke diagnosis",
|
||||
format: "markdown",
|
||||
body: [
|
||||
"# Smoke diagnosis",
|
||||
"",
|
||||
`Exact stop point: ${iteration.identifier ?? iteration.id} is waiting on a product-fix confirmation after a mocked verifier failure.`,
|
||||
"",
|
||||
"Next-action owner: board/user must accept or reject the confirmation before implementation subtasks exist.",
|
||||
"",
|
||||
"Failure taxonomy: Paperclip product gap, mocked for smoke coverage.",
|
||||
"",
|
||||
"Invariant check:",
|
||||
"",
|
||||
"- Productive work continues: acceptance wakes the assignee and would create the implementation path.",
|
||||
"- Only real blockers stop work: the loop parent is blocked by this iteration child while the confirmation is pending.",
|
||||
"- No infinite loops: iteration budget is 1 and the smoke does not start a rerun.",
|
||||
].join("\n"),
|
||||
changeSummary: "Record exact stop point and next-action owner.",
|
||||
},
|
||||
});
|
||||
|
||||
const planDocument = await api("PUT", `/api/issues/${iteration.id}/documents/plan`, {
|
||||
body: {
|
||||
title: "Smoke fix proposal",
|
||||
format: "markdown",
|
||||
body: [
|
||||
"# Smoke fix proposal",
|
||||
"",
|
||||
"Proposed product rule: a Terminal-Bench loop iteration that identifies a product gap must create a request_confirmation interaction before implementation subtasks exist.",
|
||||
"",
|
||||
`Evidence: mocked run document ${runDocument.id}; diagnosis document ${diagnosisDocument.id}.`,
|
||||
].join("\n"),
|
||||
changeSummary: "Record smoke proposal for confirmation target.",
|
||||
},
|
||||
});
|
||||
|
||||
const confirmation = await api("POST", `/api/issues/${iteration.id}/interactions`, {
|
||||
body: {
|
||||
kind: "request_confirmation",
|
||||
idempotencyKey: `confirmation:${iteration.id}:plan:${planDocument.latestRevisionId}`,
|
||||
title: "Smoke plan confirmation",
|
||||
continuationPolicy: "wake_assignee",
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Accept the mocked terminal-bench-loop product-fix proposal?",
|
||||
acceptLabel: "Accept smoke plan",
|
||||
rejectLabel: "Reject smoke plan",
|
||||
rejectRequiresReason: true,
|
||||
rejectReasonLabel: "What should change?",
|
||||
detailsMarkdown: "This deterministic smoke verifies the waiting path only; do not treat it as a real benchmark result.",
|
||||
supersedeOnUserComment: true,
|
||||
target: {
|
||||
type: "issue_document",
|
||||
issueId: iteration.id,
|
||||
documentId: planDocument.id,
|
||||
key: "plan",
|
||||
revisionId: planDocument.latestRevisionId,
|
||||
revisionNumber: planDocument.latestRevisionNumber,
|
||||
label: "Smoke fix proposal",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await api("PATCH", `/api/issues/${iteration.id}`, {
|
||||
body: {
|
||||
status: "in_review",
|
||||
comment: [
|
||||
"Smoke waiting path opened.",
|
||||
"",
|
||||
`Pending confirmation: ${confirmation.id}`,
|
||||
"Next-action owner: board/user accepts or rejects the mocked proposal.",
|
||||
].join("\n"),
|
||||
},
|
||||
});
|
||||
|
||||
await api("PATCH", `/api/issues/${loop.id}`, {
|
||||
body: {
|
||||
status: "blocked",
|
||||
blockedByIssueIds: [iteration.id],
|
||||
comment: [
|
||||
"Smoke loop parent is blocked by its iteration child while the typed confirmation is pending.",
|
||||
"",
|
||||
`Blocking iteration: ${iteration.identifier ?? iteration.id}`,
|
||||
].join("\n"),
|
||||
},
|
||||
});
|
||||
|
||||
const [verifiedLoop, verifiedIteration, verifiedRunDoc, verifiedDiagnosisDoc, interactions] = await Promise.all([
|
||||
api("GET", `/api/issues/${loop.id}`),
|
||||
api("GET", `/api/issues/${iteration.id}`),
|
||||
api("GET", `/api/issues/${iteration.id}/documents/run`),
|
||||
api("GET", `/api/issues/${iteration.id}/documents/diagnosis`),
|
||||
api("GET", `/api/issues/${iteration.id}/interactions`),
|
||||
]);
|
||||
|
||||
assert(verifiedLoop.status === "blocked", `Expected loop issue to be blocked, got ${verifiedLoop.status}`);
|
||||
assert(
|
||||
Array.isArray(verifiedLoop.blockedBy) && verifiedLoop.blockedBy.some((blocker) => blocker.id === iteration.id),
|
||||
"Expected loop issue to be blocked by the iteration child",
|
||||
);
|
||||
assert(
|
||||
verifiedIteration.status === "in_review",
|
||||
`Expected iteration issue to be in_review, got ${verifiedIteration.status}`,
|
||||
);
|
||||
assert(verifiedRunDoc.body.includes(`${artifactRoot}/results.jsonl`), "Expected run doc to include mocked results path");
|
||||
assert(verifiedRunDoc.body.includes("PAPERCLIP_HARBOR_RUNNER_CONFIG"), "Expected run doc to record dispatch config");
|
||||
assert(
|
||||
verifiedDiagnosisDoc.body.includes("Exact stop point") && verifiedDiagnosisDoc.body.includes("Next-action owner"),
|
||||
"Expected diagnosis doc to include exact stop point and next-action owner",
|
||||
);
|
||||
assert(
|
||||
interactions.some((interaction) =>
|
||||
interaction.id === confirmation.id
|
||||
&& interaction.kind === "request_confirmation"
|
||||
&& interaction.status === "pending"
|
||||
&& interaction.continuationPolicy === "wake_assignee"
|
||||
),
|
||||
"Expected a pending request_confirmation interaction with wake_assignee continuation",
|
||||
);
|
||||
|
||||
if (!args.keep) {
|
||||
await api("PATCH", `/api/issues/${loop.id}`, {
|
||||
body: {
|
||||
status: "cancelled",
|
||||
blockedByIssueIds: [],
|
||||
comment: "Smoke cleanup: verified topology and cancelled the short-lived loop parent.",
|
||||
},
|
||||
});
|
||||
await api("PATCH", `/api/issues/${iteration.id}`, {
|
||||
body: {
|
||||
status: "cancelled",
|
||||
comment: "Smoke cleanup: verified confirmation/waiting posture and cancelled the short-lived iteration child.",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
cleanup: !args.keep,
|
||||
loopIssue: { id: loop.id, identifier: loop.identifier ?? null },
|
||||
iterationIssue: { id: iteration.id, identifier: iteration.identifier ?? null },
|
||||
runDocument: runDocument.id,
|
||||
diagnosisDocument: diagnosisDocument.id,
|
||||
confirmation: confirmation.id,
|
||||
artifactRoot,
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`terminal-bench-loop skill smoke failed: ${error.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,347 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
createReadStream,
|
||||
createWriteStream,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
const repoRoot = resolve(fileURLToPath(new URL("..", import.meta.url)));
|
||||
const defaultManifestPath = join(repoRoot, "tests", "storybook-visual", "baseline-manifest.json");
|
||||
const manifestPath = resolvePath(
|
||||
process.env.STORYBOOK_VISUAL_BASELINE_MANIFEST ?? defaultManifestPath,
|
||||
);
|
||||
const defaultCacheDir = join(repoRoot, "tests", "storybook-visual", ".cache");
|
||||
const cacheDir = resolvePath(process.env.STORYBOOK_VISUAL_BASELINE_CACHE_DIR ?? defaultCacheDir);
|
||||
const defaultSnapshotDir = join(repoRoot, "tests", "storybook-visual", ".snapshots");
|
||||
const snapshotDir = resolvePath(process.env.STORYBOOK_VISUAL_SNAPSHOT_DIR ?? defaultSnapshotDir);
|
||||
|
||||
const command = process.argv[2];
|
||||
const flags = parseFlags(process.argv.slice(3));
|
||||
|
||||
try {
|
||||
if (command === "download") {
|
||||
await download();
|
||||
} else if (command === "verify") {
|
||||
await verify();
|
||||
} else if (command === "pack") {
|
||||
await pack();
|
||||
} else if (command === "upload") {
|
||||
await upload();
|
||||
} else {
|
||||
usage();
|
||||
process.exit(command ? 1 : 0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function resolvePath(path) {
|
||||
return isAbsolute(path) ? path : resolve(repoRoot, path);
|
||||
}
|
||||
|
||||
function parseFlags(args) {
|
||||
const result = new Map();
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
if (!arg.startsWith("--")) {
|
||||
throw new Error(`Unexpected argument: ${arg}`);
|
||||
}
|
||||
const equalsIndex = arg.indexOf("=");
|
||||
if (equalsIndex !== -1) {
|
||||
result.set(arg.slice(2, equalsIndex), arg.slice(equalsIndex + 1));
|
||||
continue;
|
||||
}
|
||||
const key = arg.slice(2);
|
||||
const next = args[index + 1];
|
||||
if (!next || next.startsWith("--")) {
|
||||
result.set(key, "true");
|
||||
} else {
|
||||
result.set(key, next);
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function usage() {
|
||||
console.log(`Usage: node scripts/storybook-visual-baseline.mjs <command>
|
||||
|
||||
Commands:
|
||||
download Fetch, checksum, and unpack the manifest archive into the snapshot dir.
|
||||
verify Check the unpacked snapshot count and cached archive checksum.
|
||||
pack Create a deterministic snapshots.tgz from the snapshot dir.
|
||||
upload Upload a packed archive to S3 with immutable overwrite checks.
|
||||
|
||||
Environment:
|
||||
STORYBOOK_VISUAL_BASELINE_MANIFEST Manifest path.
|
||||
STORYBOOK_VISUAL_BASELINE_CACHE_DIR Cache path.
|
||||
STORYBOOK_VISUAL_SNAPSHOT_DIR Playwright snapshot dir.
|
||||
STORYBOOK_VISUAL_S3_URI s3://bucket/key target for upload.
|
||||
STORYBOOK_VISUAL_PUBLIC_URL Public HTTPS URL to write into manifest instructions.
|
||||
`);
|
||||
}
|
||||
|
||||
function readManifest() {
|
||||
if (!existsSync(manifestPath)) {
|
||||
throw new Error(`Missing baseline manifest: ${manifestPath}`);
|
||||
}
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
||||
if (manifest.version !== 1) {
|
||||
throw new Error(`Unsupported baseline manifest version: ${manifest.version}`);
|
||||
}
|
||||
if (!Number.isInteger(manifest.snapshotCount) || manifest.snapshotCount < 0) {
|
||||
throw new Error("Manifest snapshotCount must be a non-negative integer.");
|
||||
}
|
||||
return manifest;
|
||||
}
|
||||
|
||||
function archivePathFor(manifest) {
|
||||
const hash = manifest.archive?.sha256;
|
||||
return join(cacheDir, "archives", `${hash || "unconfigured"}-snapshots.tgz`);
|
||||
}
|
||||
|
||||
async function download() {
|
||||
const manifest = readManifest();
|
||||
assertConfiguredArchive(manifest);
|
||||
mkdirSync(dirname(archivePathFor(manifest)), { recursive: true });
|
||||
const archivePath = archivePathFor(manifest);
|
||||
|
||||
if (!existsSync(archivePath) || sha256File(archivePath) !== manifest.archive.sha256) {
|
||||
await fetchArchive(manifest.archive.url, archivePath);
|
||||
}
|
||||
verifyArchiveFile(manifest, archivePath);
|
||||
rmSync(snapshotDir, { recursive: true, force: true });
|
||||
mkdirSync(snapshotDir, { recursive: true });
|
||||
run("tar", ["-xzf", archivePath, "-C", snapshotDir], "unpack baseline archive");
|
||||
verifySnapshotCount(manifest, snapshotDir);
|
||||
console.log(`Downloaded ${manifest.baselineId} to ${relative(repoRoot, snapshotDir)}`);
|
||||
}
|
||||
|
||||
async function verify() {
|
||||
const manifest = readManifest();
|
||||
assertConfiguredArchive(manifest);
|
||||
const archivePath = archivePathFor(manifest);
|
||||
if (!existsSync(archivePath)) {
|
||||
throw new Error(
|
||||
`Missing cached archive ${archivePath}. Run \`pnpm storybook-visual:baseline download\` first.`,
|
||||
);
|
||||
}
|
||||
verifyArchiveFile(manifest, archivePath);
|
||||
verifySnapshotCount(manifest, snapshotDir);
|
||||
console.log(
|
||||
`Verified ${manifest.snapshotCount} snapshots for ${manifest.baselineId} in ${relative(
|
||||
repoRoot,
|
||||
snapshotDir,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function pack() {
|
||||
const sourceDir = resolvePath(flags.get("source") ?? snapshotDir);
|
||||
if (!existsSync(sourceDir)) {
|
||||
throw new Error(`Snapshot source does not exist: ${sourceDir}`);
|
||||
}
|
||||
const count = countPngFiles(sourceDir);
|
||||
if (count === 0) {
|
||||
throw new Error(`No PNG snapshots found in ${sourceDir}`);
|
||||
}
|
||||
const out = resolvePath(
|
||||
flags.get("out") ?? join(repoRoot, "tests", "storybook-visual", "baseline-review", "snapshots.tgz"),
|
||||
);
|
||||
mkdirSync(dirname(out), { recursive: true });
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "storybook-visual-pack-"));
|
||||
const tempArchive = join(tempDir, "snapshots.tgz");
|
||||
try {
|
||||
run(
|
||||
"tar",
|
||||
[
|
||||
"--sort=name",
|
||||
"--mtime=@0",
|
||||
"--owner=0",
|
||||
"--group=0",
|
||||
"--numeric-owner",
|
||||
"--use-compress-program=gzip -n",
|
||||
"-cf",
|
||||
tempArchive,
|
||||
"-C",
|
||||
sourceDir,
|
||||
".",
|
||||
],
|
||||
"pack deterministic baseline archive",
|
||||
);
|
||||
rmSync(out, { force: true });
|
||||
run("cp", [tempArchive, out], "write packed archive");
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
const sha256 = sha256File(out);
|
||||
const byteSize = statSync(out).size;
|
||||
const publicUrl = flags.get("public-url") ?? process.env.STORYBOOK_VISUAL_PUBLIC_URL ?? "";
|
||||
const objectKey = `baselines/storybook-visual/${sha256}/snapshots.tgz`;
|
||||
console.log(`Packed ${count} PNG snapshots into ${relative(repoRoot, out)}`);
|
||||
console.log("");
|
||||
console.log("Manifest archive update:");
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
snapshotCount: count,
|
||||
archive: {
|
||||
url: publicUrl || `https://<cloudfront-host>/${objectKey}`,
|
||||
sha256,
|
||||
byteSize,
|
||||
objectKey,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function upload() {
|
||||
const archive = resolvePath(flags.get("archive") ?? join(repoRoot, "tests", "storybook-visual", "baseline-review", "snapshots.tgz"));
|
||||
const s3Uri = flags.get("s3-uri") ?? process.env.STORYBOOK_VISUAL_S3_URI;
|
||||
if (!s3Uri) {
|
||||
throw new Error("Missing --s3-uri or STORYBOOK_VISUAL_S3_URI for upload.");
|
||||
}
|
||||
if (!s3Uri.startsWith("s3://")) {
|
||||
throw new Error(`Upload target must be an s3:// URI: ${s3Uri}`);
|
||||
}
|
||||
if (!existsSync(archive)) {
|
||||
throw new Error(`Archive does not exist: ${archive}`);
|
||||
}
|
||||
const sha256 = sha256File(archive);
|
||||
const { bucket, key } = parseS3Uri(s3Uri);
|
||||
const head = spawnSync(
|
||||
"aws",
|
||||
["s3api", "head-object", "--bucket", bucket, "--key", key, "--output", "json"],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
if (head.status === 0) {
|
||||
const metadata = JSON.parse(head.stdout || "{}").Metadata ?? {};
|
||||
if (metadata.sha256 === sha256) {
|
||||
console.log(`Archive already exists at ${s3Uri} with matching sha256 ${sha256}.`);
|
||||
return;
|
||||
}
|
||||
throw new Error(`Refusing to overwrite existing S3 object with different sha256: ${s3Uri}`);
|
||||
}
|
||||
run(
|
||||
"aws",
|
||||
[
|
||||
"s3",
|
||||
"cp",
|
||||
archive,
|
||||
s3Uri,
|
||||
"--metadata",
|
||||
`sha256=${sha256}`,
|
||||
"--cache-control",
|
||||
"public, max-age=31536000, immutable",
|
||||
"--content-type",
|
||||
"application/gzip",
|
||||
],
|
||||
"upload baseline archive",
|
||||
);
|
||||
console.log(`Uploaded ${basename(archive)} to ${s3Uri}`);
|
||||
}
|
||||
|
||||
function assertConfiguredArchive(manifest) {
|
||||
const archive = manifest.archive ?? {};
|
||||
if (!archive.url || !archive.sha256 || !archive.byteSize) {
|
||||
throw new Error(
|
||||
`Baseline manifest ${relative(
|
||||
repoRoot,
|
||||
manifestPath,
|
||||
)} does not point at a published archive yet. Run \`pnpm storybook-visual:baseline pack\`, upload the immutable archive, then update the manifest archive url/sha256/byteSize/snapshotCount.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchArchive(url, destination) {
|
||||
if (url.startsWith("file://")) {
|
||||
await pipeline(createReadStream(fileURLToPath(url)), createWriteStream(destination));
|
||||
return;
|
||||
}
|
||||
if (!url.startsWith("https://") && !url.startsWith("http://")) {
|
||||
throw new Error(`Unsupported archive URL: ${url}`);
|
||||
}
|
||||
const response = await fetch(url);
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`Failed to download baseline archive: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
await pipeline(response.body, createWriteStream(destination));
|
||||
}
|
||||
|
||||
function verifyArchiveFile(manifest, archivePath) {
|
||||
const actualSha = sha256File(archivePath);
|
||||
if (actualSha !== manifest.archive.sha256) {
|
||||
throw new Error(
|
||||
`Baseline checksum mismatch: expected ${manifest.archive.sha256}, got ${actualSha}`,
|
||||
);
|
||||
}
|
||||
const actualSize = statSync(archivePath).size;
|
||||
if (actualSize !== manifest.archive.byteSize) {
|
||||
throw new Error(
|
||||
`Baseline byte size mismatch: expected ${manifest.archive.byteSize}, got ${actualSize}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function verifySnapshotCount(manifest, dir) {
|
||||
const count = countPngFiles(dir);
|
||||
if (count !== manifest.snapshotCount) {
|
||||
throw new Error(
|
||||
`Baseline snapshot count mismatch: expected ${manifest.snapshotCount}, got ${count} in ${dir}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function sha256File(path) {
|
||||
const hash = createHash("sha256");
|
||||
hash.update(readFileSync(path));
|
||||
return hash.digest("hex");
|
||||
}
|
||||
|
||||
function countPngFiles(dir) {
|
||||
if (!existsSync(dir)) return 0;
|
||||
let count = 0;
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const path = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
count += countPngFiles(path);
|
||||
} else if (entry.isFile() && entry.name.endsWith(".png")) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function parseS3Uri(uri) {
|
||||
const withoutScheme = uri.slice("s3://".length);
|
||||
const slash = withoutScheme.indexOf("/");
|
||||
if (slash === -1) throw new Error(`S3 URI must include a key: ${uri}`);
|
||||
return { bucket: withoutScheme.slice(0, slash), key: withoutScheme.slice(slash + 1) };
|
||||
}
|
||||
|
||||
function run(cmd, args, label) {
|
||||
const result = spawnSync(cmd, args, {
|
||||
cwd: repoRoot,
|
||||
stdio: "inherit",
|
||||
env: { ...process.env, COPYFILE_DISABLE: "1" },
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`Failed to ${label}.`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const CANARY_VERSION_RE = /-canary\.\d+$/;
|
||||
const EXIT_RETRIABLE_FAILURE = 1;
|
||||
const EXIT_NON_RETRIABLE_FAILURE = 2;
|
||||
|
||||
export function isCanaryVersion(version) {
|
||||
return CANARY_VERSION_RE.test(version);
|
||||
}
|
||||
|
||||
function createExitError(message, exitCode = EXIT_RETRIABLE_FAILURE) {
|
||||
return Object.assign(new Error(message), { exitCode });
|
||||
}
|
||||
|
||||
function createProblem(message, { retriable = true } = {}) {
|
||||
return { message, retriable };
|
||||
}
|
||||
|
||||
function usage() {
|
||||
process.stderr.write(
|
||||
[
|
||||
"Usage:",
|
||||
" node scripts/verify-release-registry-state.mjs --channel <canary|stable> --dist-tag <tag> --target-version <version> --package <name> [--package <name> ...] [--allow-canary-latest]",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
channel: "",
|
||||
distTag: "",
|
||||
targetVersion: "",
|
||||
allowCanaryLatest: false,
|
||||
packages: [],
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
|
||||
switch (arg) {
|
||||
case "--channel":
|
||||
options.channel = argv[index + 1] ?? "";
|
||||
index += 1;
|
||||
break;
|
||||
case "--dist-tag":
|
||||
options.distTag = argv[index + 1] ?? "";
|
||||
index += 1;
|
||||
break;
|
||||
case "--target-version":
|
||||
options.targetVersion = argv[index + 1] ?? "";
|
||||
index += 1;
|
||||
break;
|
||||
case "--package":
|
||||
options.packages.push(argv[index + 1] ?? "");
|
||||
index += 1;
|
||||
break;
|
||||
case "--allow-canary-latest":
|
||||
options.allowCanaryLatest = true;
|
||||
break;
|
||||
case "-h":
|
||||
case "--help":
|
||||
usage();
|
||||
process.exit(0);
|
||||
default:
|
||||
throw createExitError(`unexpected argument: ${arg}`, EXIT_NON_RETRIABLE_FAILURE);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.channel !== "canary" && options.channel !== "stable") {
|
||||
throw createExitError("--channel must be canary or stable", EXIT_NON_RETRIABLE_FAILURE);
|
||||
}
|
||||
|
||||
if (!options.distTag) {
|
||||
throw createExitError("--dist-tag is required", EXIT_NON_RETRIABLE_FAILURE);
|
||||
}
|
||||
|
||||
if (!options.targetVersion) {
|
||||
throw createExitError("--target-version is required", EXIT_NON_RETRIABLE_FAILURE);
|
||||
}
|
||||
|
||||
if (options.packages.length === 0 || options.packages.some((name) => !name)) {
|
||||
throw createExitError("at least one non-empty --package value is required", EXIT_NON_RETRIABLE_FAILURE);
|
||||
}
|
||||
|
||||
if (options.allowCanaryLatest && options.channel !== "canary") {
|
||||
throw createExitError("--allow-canary-latest only applies to canary releases", EXIT_NON_RETRIABLE_FAILURE);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function createRegistryUrl(packageName, version = "") {
|
||||
const registry = process.env.npm_config_registry ?? process.env.NPM_CONFIG_REGISTRY ?? "https://registry.npmjs.org/";
|
||||
const baseUrl = registry.endsWith("/") ? registry : `${registry}/`;
|
||||
const encodedPackage = encodeURIComponent(packageName);
|
||||
|
||||
if (!version) {
|
||||
return new URL(encodedPackage, baseUrl);
|
||||
}
|
||||
|
||||
return new URL(`${encodedPackage}/${encodeURIComponent(version)}`, baseUrl);
|
||||
}
|
||||
|
||||
export async function fetchRegistryJson(url, { allowMissing = false, timeoutMs = 30_000 } = {}) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
let response;
|
||||
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
accept: "application/vnd.npm.install-v1+json, application/json;q=0.9",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
throw new Error(`npm registry request timed out for ${url} after ${timeoutMs}ms`);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
if (response.status === 404 && allowMissing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`npm registry request failed for ${url}: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function fetchPackageDocument(packageName, { allowMissing = false } = {}) {
|
||||
return fetchRegistryJson(createRegistryUrl(packageName), { allowMissing });
|
||||
}
|
||||
|
||||
async function fetchPackageManifest(packageName, version, { allowMissing = false } = {}) {
|
||||
return fetchRegistryJson(createRegistryUrl(packageName, version), { allowMissing });
|
||||
}
|
||||
|
||||
export function createManifestLookupKey(packageName, version) {
|
||||
return `${packageName}@${version}`;
|
||||
}
|
||||
|
||||
function isRangeVersionSpecifier(version) {
|
||||
return /[\^~*xX><| ]/.test(version);
|
||||
}
|
||||
|
||||
function resolvePublishedManifest(packageName, version, packageDoc, packageManifestsByKey = new Map()) {
|
||||
const directManifest = packageManifestsByKey.get(createManifestLookupKey(packageName, version));
|
||||
if (directManifest) {
|
||||
return directManifest;
|
||||
}
|
||||
|
||||
if (directManifest === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return packageDoc?.versions?.[version] ?? null;
|
||||
}
|
||||
|
||||
function collectInternalDependencyProblemEntries(
|
||||
manifest,
|
||||
packageDocsByName,
|
||||
packageManifestsByKey = new Map(),
|
||||
) {
|
||||
const problems = [];
|
||||
const sections = [
|
||||
["dependencies", manifest.dependencies ?? {}],
|
||||
["optionalDependencies", manifest.optionalDependencies ?? {}],
|
||||
["peerDependencies", manifest.peerDependencies ?? {}],
|
||||
];
|
||||
|
||||
for (const [sectionName, deps] of sections) {
|
||||
for (const [dependencyName, dependencyVersion] of Object.entries(deps)) {
|
||||
if (!dependencyName.startsWith("@paperclipai/")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof dependencyVersion !== "string" || !dependencyVersion) {
|
||||
problems.push(
|
||||
createProblem(
|
||||
`${sectionName} declares ${dependencyName} with a non-string version: ${JSON.stringify(dependencyVersion)}`,
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Peer dependency ranges express compatibility, not a manifest that can be fetched directly.
|
||||
if (sectionName === "peerDependencies" && isRangeVersionSpecifier(dependencyVersion)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const dependencyManifest = resolvePublishedManifest(
|
||||
dependencyName,
|
||||
dependencyVersion,
|
||||
packageDocsByName.get(dependencyName),
|
||||
packageManifestsByKey,
|
||||
);
|
||||
const dependencyLookupKey = createManifestLookupKey(dependencyName, dependencyVersion);
|
||||
|
||||
if (!dependencyManifest) {
|
||||
const dependencyDoc = packageDocsByName.get(dependencyName);
|
||||
if (!dependencyDoc && !packageManifestsByKey.has(dependencyLookupKey)) {
|
||||
problems.push(
|
||||
createProblem(
|
||||
`${sectionName} requires ${dependencyName}@${dependencyVersion}, but npm publication metadata was not fetched for that dependency`,
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
problems.push(
|
||||
createProblem(
|
||||
`${sectionName} requires ${dependencyName}@${dependencyVersion}, but npm does not expose that version`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return problems;
|
||||
}
|
||||
|
||||
export function collectInternalDependencyProblems(
|
||||
manifest,
|
||||
packageDocsByName,
|
||||
packageManifestsByKey = new Map(),
|
||||
) {
|
||||
return collectInternalDependencyProblemEntries(
|
||||
manifest,
|
||||
packageDocsByName,
|
||||
packageManifestsByKey,
|
||||
).map((problem) => problem.message);
|
||||
}
|
||||
|
||||
function requireManifest(packageName, version, packageDoc, packageManifestsByKey, problems) {
|
||||
const manifest = resolvePublishedManifest(packageName, version, packageDoc, packageManifestsByKey);
|
||||
if (!manifest) {
|
||||
if (problems) {
|
||||
problems.push(createProblem(`${packageName}: npm registry is missing manifest data for ${version}`));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return manifest;
|
||||
}
|
||||
|
||||
export function verifyPackageRegistryProblems({
|
||||
packageName,
|
||||
packageDoc,
|
||||
packageDocsByName,
|
||||
packageManifestsByKey = new Map(),
|
||||
channel,
|
||||
distTag,
|
||||
targetVersion,
|
||||
allowCanaryLatest,
|
||||
}) {
|
||||
const problems = [];
|
||||
const distTags = packageDoc["dist-tags"] ?? {};
|
||||
const taggedVersion = distTags[distTag];
|
||||
|
||||
if (taggedVersion !== targetVersion) {
|
||||
problems.push(
|
||||
createProblem(
|
||||
`${packageName}: dist-tag ${distTag} resolves to ${taggedVersion ?? "<missing>"}, expected ${targetVersion}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const targetManifest = requireManifest(packageName, targetVersion, packageDoc, packageManifestsByKey, problems);
|
||||
if (targetManifest) {
|
||||
for (const problem of collectInternalDependencyProblemEntries(
|
||||
targetManifest,
|
||||
packageDocsByName,
|
||||
packageManifestsByKey,
|
||||
)) {
|
||||
problems.push(createProblem(`${packageName}@${targetVersion}: ${problem.message}`, problem));
|
||||
}
|
||||
}
|
||||
|
||||
if (channel === "canary") {
|
||||
const latestVersion = distTags.latest;
|
||||
|
||||
if (latestVersion && isCanaryVersion(latestVersion) && !allowCanaryLatest) {
|
||||
problems.push(
|
||||
createProblem(
|
||||
`${packageName}: latest dist-tag still resolves to canary ${latestVersion}; if that state is intentional, rerun the verification script directly with --allow-canary-latest`,
|
||||
{ retriable: false },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (latestVersion && isCanaryVersion(latestVersion)) {
|
||||
const latestManifest = requireManifest(
|
||||
packageName,
|
||||
latestVersion,
|
||||
packageDoc,
|
||||
packageManifestsByKey,
|
||||
problems,
|
||||
);
|
||||
if (latestManifest) {
|
||||
for (const problem of collectInternalDependencyProblemEntries(
|
||||
latestManifest,
|
||||
packageDocsByName,
|
||||
packageManifestsByKey,
|
||||
)) {
|
||||
problems.push(createProblem(`${packageName}@${latestVersion} via latest: ${problem.message}`, problem));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return problems;
|
||||
}
|
||||
|
||||
export function verifyPackageRegistryState(options) {
|
||||
return verifyPackageRegistryProblems(options).map((problem) => problem.message);
|
||||
}
|
||||
|
||||
function collectInternalDependencyVersions(manifest) {
|
||||
const dependencyVersions = [];
|
||||
|
||||
for (const [sectionName, deps] of [
|
||||
["dependencies", manifest.dependencies ?? {}],
|
||||
["optionalDependencies", manifest.optionalDependencies ?? {}],
|
||||
["peerDependencies", manifest.peerDependencies ?? {}],
|
||||
]) {
|
||||
for (const [dependencyName, dependencyVersion] of Object.entries(deps)) {
|
||||
if (!dependencyName.startsWith("@paperclipai/")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof dependencyVersion !== "string" || !dependencyVersion) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (sectionName === "peerDependencies" && isRangeVersionSpecifier(dependencyVersion)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
dependencyVersions.push({
|
||||
packageName: dependencyName,
|
||||
version: dependencyVersion,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return dependencyVersions;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const packageNames = [...new Set(options.packages)];
|
||||
const packageDocsByName = new Map();
|
||||
const packageManifestsByKey = new Map();
|
||||
|
||||
await Promise.all(
|
||||
packageNames.map(async (packageName) => {
|
||||
packageDocsByName.set(packageName, await fetchPackageDocument(packageName));
|
||||
}),
|
||||
);
|
||||
|
||||
const versionsToFetchByPackage = new Map();
|
||||
for (const packageName of packageNames) {
|
||||
const packageDoc = packageDocsByName.get(packageName);
|
||||
const versionsToFetch = new Set([options.targetVersion]);
|
||||
const latestVersion = packageDoc?.["dist-tags"]?.latest;
|
||||
if (latestVersion && isCanaryVersion(latestVersion)) {
|
||||
versionsToFetch.add(latestVersion);
|
||||
}
|
||||
versionsToFetchByPackage.set(packageName, versionsToFetch);
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
[...versionsToFetchByPackage.entries()].flatMap(([packageName, versionsToFetch]) =>
|
||||
[...versionsToFetch].map(async (version) => {
|
||||
packageManifestsByKey.set(
|
||||
createManifestLookupKey(packageName, version),
|
||||
await fetchPackageManifest(packageName, version, { allowMissing: true }),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const dependencyVersionsByKey = new Map();
|
||||
for (const [packageName, versionsToFetch] of versionsToFetchByPackage.entries()) {
|
||||
for (const version of versionsToFetch) {
|
||||
const manifest = resolvePublishedManifest(
|
||||
packageName,
|
||||
version,
|
||||
packageDocsByName.get(packageName),
|
||||
packageManifestsByKey,
|
||||
);
|
||||
if (!manifest) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const dependencyVersion of collectInternalDependencyVersions(manifest)) {
|
||||
dependencyVersionsByKey.set(
|
||||
createManifestLookupKey(dependencyVersion.packageName, dependencyVersion.version),
|
||||
dependencyVersion,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
[...dependencyVersionsByKey.values()].map(async ({ packageName, version }) => {
|
||||
const lookupKey = createManifestLookupKey(packageName, version);
|
||||
if (packageManifestsByKey.has(lookupKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
packageManifestsByKey.set(
|
||||
lookupKey,
|
||||
await fetchPackageManifest(packageName, version, { allowMissing: true }),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const problems = [];
|
||||
|
||||
for (const packageName of packageNames) {
|
||||
process.stdout.write(` Verifying ${packageName} on dist-tag ${options.distTag}\n`);
|
||||
const packageProblems = verifyPackageRegistryProblems({
|
||||
packageName,
|
||||
packageDoc: packageDocsByName.get(packageName),
|
||||
packageDocsByName,
|
||||
packageManifestsByKey,
|
||||
channel: options.channel,
|
||||
distTag: options.distTag,
|
||||
targetVersion: options.targetVersion,
|
||||
allowCanaryLatest: options.allowCanaryLatest,
|
||||
});
|
||||
|
||||
if (packageProblems.length === 0) {
|
||||
process.stdout.write(` ✓ dist-tag and published internal dependencies are consistent\n`);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const problem of packageProblems) {
|
||||
process.stderr.write(` ✗ ${problem.message}\n`);
|
||||
problems.push(problem);
|
||||
}
|
||||
}
|
||||
|
||||
if (problems.length > 0) {
|
||||
const exitCode = problems.some((problem) => !problem.retriable)
|
||||
? EXIT_NON_RETRIABLE_FAILURE
|
||||
: EXIT_RETRIABLE_FAILURE;
|
||||
throw createExitError(`npm registry verification failed for ${problems.length} problem(s)`, exitCode);
|
||||
}
|
||||
}
|
||||
|
||||
const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
||||
|
||||
if (isDirectRun) {
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`Error: ${error.message}\n`);
|
||||
process.exit(error.exitCode ?? EXIT_RETRIABLE_FAILURE);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
collectInternalDependencyProblems,
|
||||
createManifestLookupKey,
|
||||
fetchRegistryJson,
|
||||
isCanaryVersion,
|
||||
verifyPackageRegistryProblems,
|
||||
verifyPackageRegistryState,
|
||||
} from "./verify-release-registry-state.mjs";
|
||||
|
||||
test("isCanaryVersion matches release canaries", () => {
|
||||
assert.equal(isCanaryVersion("2026.427.0-canary.3"), true);
|
||||
assert.equal(isCanaryVersion("2026.427.0"), false);
|
||||
});
|
||||
|
||||
test("collectInternalDependencyProblems flags missing internal versions", () => {
|
||||
const manifest = {
|
||||
dependencies: {
|
||||
"@paperclipai/plugin-sdk": "2026.425.0-canary.5",
|
||||
e2b: "^2.19.0",
|
||||
},
|
||||
};
|
||||
const packageDocsByName = new Map([
|
||||
[
|
||||
"@paperclipai/plugin-sdk",
|
||||
{
|
||||
versions: {
|
||||
"2026.427.0-canary.3": {},
|
||||
},
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
assert.deepEqual(
|
||||
collectInternalDependencyProblems(manifest, packageDocsByName),
|
||||
["dependencies requires @paperclipai/plugin-sdk@2026.425.0-canary.5, but npm does not expose that version"],
|
||||
);
|
||||
});
|
||||
|
||||
test("collectInternalDependencyProblems accepts version-specific manifests when the root document is stale", () => {
|
||||
const manifest = {
|
||||
dependencies: {
|
||||
"@paperclipai/plugin-sdk": "2026.425.0-canary.5",
|
||||
},
|
||||
};
|
||||
const packageDocsByName = new Map([
|
||||
[
|
||||
"@paperclipai/plugin-sdk",
|
||||
{
|
||||
versions: {},
|
||||
},
|
||||
],
|
||||
]);
|
||||
const packageManifestsByKey = new Map([
|
||||
[
|
||||
createManifestLookupKey("@paperclipai/plugin-sdk", "2026.425.0-canary.5"),
|
||||
{ name: "@paperclipai/plugin-sdk", version: "2026.425.0-canary.5" },
|
||||
],
|
||||
]);
|
||||
|
||||
assert.deepEqual(
|
||||
collectInternalDependencyProblems(manifest, packageDocsByName, packageManifestsByKey),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test("collectInternalDependencyProblems ignores peer dependency range specifiers", () => {
|
||||
const manifest = {
|
||||
peerDependencies: {
|
||||
"@paperclipai/server": "^2026.430.0-canary.0",
|
||||
},
|
||||
};
|
||||
|
||||
assert.deepEqual(
|
||||
collectInternalDependencyProblems(manifest, new Map()),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test("collectInternalDependencyProblems reports unfetched transitive dependency metadata neutrally", () => {
|
||||
const manifest = {
|
||||
optionalDependencies: {
|
||||
"@paperclipai/browser": "2026.430.0-canary.0",
|
||||
},
|
||||
};
|
||||
|
||||
assert.deepEqual(
|
||||
collectInternalDependencyProblems(manifest, new Map()),
|
||||
[
|
||||
"optionalDependencies requires @paperclipai/browser@2026.430.0-canary.0, but npm publication metadata was not fetched for that dependency",
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("verifyPackageRegistryState tolerates a stale root versions map when dist-tags and direct manifests are correct", () => {
|
||||
const packageDocsByName = new Map([
|
||||
[
|
||||
"@paperclipai/ui",
|
||||
{
|
||||
"dist-tags": {
|
||||
canary: "2026.430.0-canary.0",
|
||||
latest: "2026.430.0",
|
||||
},
|
||||
versions: {},
|
||||
},
|
||||
],
|
||||
[
|
||||
"@paperclipai/shared",
|
||||
{
|
||||
versions: {},
|
||||
},
|
||||
],
|
||||
]);
|
||||
const packageManifestsByKey = new Map([
|
||||
[
|
||||
createManifestLookupKey("@paperclipai/ui", "2026.430.0-canary.0"),
|
||||
{
|
||||
name: "@paperclipai/ui",
|
||||
version: "2026.430.0-canary.0",
|
||||
dependencies: {
|
||||
"@paperclipai/shared": "2026.430.0-canary.0",
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
createManifestLookupKey("@paperclipai/shared", "2026.430.0-canary.0"),
|
||||
{
|
||||
name: "@paperclipai/shared",
|
||||
version: "2026.430.0-canary.0",
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
assert.deepEqual(
|
||||
verifyPackageRegistryState({
|
||||
packageName: "@paperclipai/ui",
|
||||
packageDoc: packageDocsByName.get("@paperclipai/ui"),
|
||||
packageDocsByName,
|
||||
packageManifestsByKey,
|
||||
channel: "canary",
|
||||
distTag: "canary",
|
||||
targetVersion: "2026.430.0-canary.0",
|
||||
allowCanaryLatest: false,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test("verifyPackageRegistryState fails when canary latest is left in place by default", () => {
|
||||
const packageDocsByName = new Map([
|
||||
[
|
||||
"@paperclipai/plugin-e2b",
|
||||
{
|
||||
"dist-tags": {
|
||||
latest: "2026.425.0-canary.5",
|
||||
canary: "2026.427.0-canary.3",
|
||||
},
|
||||
versions: {
|
||||
"2026.425.0-canary.5": {
|
||||
dependencies: {
|
||||
"@paperclipai/plugin-sdk": "2026.425.0-canary.5",
|
||||
},
|
||||
},
|
||||
"2026.427.0-canary.3": {
|
||||
dependencies: {
|
||||
"@paperclipai/plugin-sdk": "2026.427.0-canary.3",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
"@paperclipai/plugin-sdk",
|
||||
{
|
||||
versions: {
|
||||
"2026.427.0-canary.3": {},
|
||||
},
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
assert.deepEqual(
|
||||
verifyPackageRegistryState({
|
||||
packageName: "@paperclipai/plugin-e2b",
|
||||
packageDoc: packageDocsByName.get("@paperclipai/plugin-e2b"),
|
||||
packageDocsByName,
|
||||
channel: "canary",
|
||||
distTag: "canary",
|
||||
targetVersion: "2026.427.0-canary.3",
|
||||
allowCanaryLatest: false,
|
||||
}),
|
||||
[
|
||||
"@paperclipai/plugin-e2b: latest dist-tag still resolves to canary 2026.425.0-canary.5; if that state is intentional, rerun the verification script directly with --allow-canary-latest",
|
||||
"@paperclipai/plugin-e2b@2026.425.0-canary.5 via latest: dependencies requires @paperclipai/plugin-sdk@2026.425.0-canary.5, but npm does not expose that version",
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("verifyPackageRegistryProblems marks canary latest drift as non-retriable", () => {
|
||||
const packageDocsByName = new Map([
|
||||
[
|
||||
"@paperclipai/plugin-e2b",
|
||||
{
|
||||
"dist-tags": {
|
||||
latest: "2026.425.0-canary.5",
|
||||
canary: "2026.427.0-canary.3",
|
||||
},
|
||||
versions: {
|
||||
"2026.427.0-canary.3": {},
|
||||
},
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
const problems = verifyPackageRegistryProblems({
|
||||
packageName: "@paperclipai/plugin-e2b",
|
||||
packageDoc: packageDocsByName.get("@paperclipai/plugin-e2b"),
|
||||
packageDocsByName,
|
||||
channel: "canary",
|
||||
distTag: "canary",
|
||||
targetVersion: "2026.427.0-canary.3",
|
||||
allowCanaryLatest: false,
|
||||
});
|
||||
|
||||
assert.equal(problems[0]?.retriable, false);
|
||||
assert.match(problems[0]?.message ?? "", /latest dist-tag still resolves to canary/);
|
||||
});
|
||||
|
||||
test("verifyPackageRegistryState allows intentional canary latest but still checks dependencies", () => {
|
||||
const packageDocsByName = new Map([
|
||||
[
|
||||
"paperclipai",
|
||||
{
|
||||
"dist-tags": {
|
||||
latest: "2026.427.0-canary.3",
|
||||
canary: "2026.427.0-canary.3",
|
||||
},
|
||||
versions: {
|
||||
"2026.427.0-canary.3": {
|
||||
dependencies: {
|
||||
"@paperclipai/server": "2026.427.0-canary.3",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
"@paperclipai/server",
|
||||
{
|
||||
versions: {
|
||||
"2026.427.0-canary.3": {},
|
||||
},
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
assert.deepEqual(
|
||||
verifyPackageRegistryState({
|
||||
packageName: "paperclipai",
|
||||
packageDoc: packageDocsByName.get("paperclipai"),
|
||||
packageDocsByName,
|
||||
channel: "canary",
|
||||
distTag: "canary",
|
||||
targetVersion: "2026.427.0-canary.3",
|
||||
allowCanaryLatest: true,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test("verifyPackageRegistryState still fails when the dist-tag is stale", () => {
|
||||
const packageDocsByName = new Map([
|
||||
[
|
||||
"@paperclipai/ui",
|
||||
{
|
||||
"dist-tags": {
|
||||
canary: "2026.429.0-canary.2",
|
||||
},
|
||||
versions: {},
|
||||
},
|
||||
],
|
||||
]);
|
||||
const packageManifestsByKey = new Map([
|
||||
[
|
||||
createManifestLookupKey("@paperclipai/ui", "2026.430.0-canary.0"),
|
||||
{
|
||||
name: "@paperclipai/ui",
|
||||
version: "2026.430.0-canary.0",
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
assert.deepEqual(
|
||||
verifyPackageRegistryState({
|
||||
packageName: "@paperclipai/ui",
|
||||
packageDoc: packageDocsByName.get("@paperclipai/ui"),
|
||||
packageDocsByName,
|
||||
packageManifestsByKey,
|
||||
channel: "canary",
|
||||
distTag: "canary",
|
||||
targetVersion: "2026.430.0-canary.0",
|
||||
allowCanaryLatest: false,
|
||||
}),
|
||||
["@paperclipai/ui: dist-tag canary resolves to 2026.429.0-canary.2, expected 2026.430.0-canary.0"],
|
||||
);
|
||||
});
|
||||
|
||||
test("verifyPackageRegistryState ignores internal peer dependency ranges", () => {
|
||||
const packageDocsByName = new Map([
|
||||
[
|
||||
"@paperclipai/plugin-sdk",
|
||||
{
|
||||
"dist-tags": {
|
||||
canary: "2026.430.0-canary.0",
|
||||
},
|
||||
versions: {
|
||||
"2026.430.0-canary.0": {
|
||||
peerDependencies: {
|
||||
"@paperclipai/server": "^2026.430.0-canary.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
assert.deepEqual(
|
||||
verifyPackageRegistryState({
|
||||
packageName: "@paperclipai/plugin-sdk",
|
||||
packageDoc: packageDocsByName.get("@paperclipai/plugin-sdk"),
|
||||
packageDocsByName,
|
||||
channel: "canary",
|
||||
distTag: "canary",
|
||||
targetVersion: "2026.430.0-canary.0",
|
||||
allowCanaryLatest: false,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test("fetchRegistryJson times out hung requests", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
globalThis.fetch = (_url, { signal }) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
signal.addEventListener(
|
||||
"abort",
|
||||
() => reject(new DOMException("The operation was aborted.", "AbortError")),
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
fetchRegistryJson(new URL("https://registry.npmjs.org/@paperclipai%2Fui"), { timeoutMs: 1 }),
|
||||
/timed out/,
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user