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:
Harold KimandPaperclip committed 2026-07-09 10:08:57 +00:00
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;
});
+17
View File
@@ -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 "$@"
+306
View File
@@ -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,
};
+87
View File
@@ -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",
]);
});
+80
View File
@@ -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);
});
}
+115
View File
@@ -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);
});
+176
View File
@@ -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();
+115
View File
@@ -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();
}
+196
View File
@@ -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 }));
}
+170
View File
@@ -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 });
}
});
+206
View File
@@ -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 }],
]);
});
+315
View File
@@ -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();
+14
View File
@@ -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"
+13
View File
@@ -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"
+86
View File
@@ -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"
+470
View File
@@ -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();
+599
View File
@@ -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();
+517
View File
@@ -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();
+239
View File
@@ -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();
+197
View File
@@ -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.");
+99
View File
@@ -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
+93
View File
@@ -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);
}
+102
View File
@@ -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;
}
+43
View File
@@ -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;
}
+100
View File
@@ -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();
}
+603
View File
@@ -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);
}
+697
View File
@@ -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);
}
+44
View File
@@ -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,
};
}
+44
View File
@@ -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);
+78
View File
@@ -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
+46
View File
@@ -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"
+42
View File
@@ -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 "$@"
+303
View File
@@ -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"
+132
View File
@@ -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);
}
+117
View File
@@ -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);
}
+364
View File
@@ -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);
});
+114
View File
@@ -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}`);
+694
View File
@@ -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 &lt;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);
});
+46
View File
@@ -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}`);
+31
View File
@@ -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");
+65
View File
@@ -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."
+248
View File
@@ -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."
+65
View File
@@ -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."
+289
View File
@@ -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."
+98
View File
@@ -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;
}
+89
View File
@@ -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));
});
+108
View File
@@ -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();
}
+125
View File
@@ -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();
+872
View File
@@ -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);
});
+110
View File
@@ -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"
+34
View File
@@ -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"
+607
View File
@@ -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
)
+419
View File
@@ -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"
}
+164
View File
@@ -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/);
});
+152
View File
@@ -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
}
]
+330
View File
@@ -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,
};
+115
View File
@@ -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()), []);
});
+114
View File
@@ -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);
}
+197
View File
@@ -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);
});
+352
View File
@@ -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
+111
View File
@@ -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"
+93
View File
@@ -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"]);
}
+421
View File
@@ -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);
}
+95
View File
@@ -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);
});
+73
View File
@@ -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);
});
+27
View File
@@ -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}`);
+35
View File
@@ -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();
}
+85
View File
@@ -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);
});
+35
View File
@@ -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();
}
+92
View File
@@ -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();
}
})();
+80
View File
@@ -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}`);
});
+1007
View File
File diff suppressed because it is too large. Load diff
+450
View File
@@ -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
+155
View File
@@ -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");
});
+329
View File
@@ -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
+954
View File
@@ -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 "$@"
+295
View File
@@ -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
+146
View File
@@ -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"
+424
View File
@@ -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
View File
@@ -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);
});
+347
View File
@@ -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}.`);
}
}
+468
View File
@@ -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;
}
});