merge: wave 2 executor (01-02) into main

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 4.6 committed 2026-03-25 23:46:16 -04:00
1 parent a5b8fcf9d3
commit aa1a78d7d9
1081 files changed
+445341

No files matched your search

+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 "$@"
+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,115 @@
#!/usr/bin/env node
/**
* check-forbidden-tokens.mjs
*
* Scans the codebase for forbidden tokens before publishing to npm.
* Mirrors the git pre-commit hook logic, but runs against the full
* working tree (not just staged changes).
*
* Token list: .git/hooks/forbidden-tokens.txt (one per line, # comments ok).
* If the file is missing, the check still uses the active local username when
* available. If username detection fails, the check degrades gracefully.
*/
import { execSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import os from "node:os";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
function uniqueNonEmpty(values) {
return Array.from(new Set(values.map((value) => value?.trim() ?? "").filter(Boolean)));
}
export function resolveDynamicForbiddenTokens(env = process.env, osModule = os) {
const candidates = [env.USER, env.LOGNAME, env.USERNAME];
try {
candidates.push(osModule.userInfo().username);
} catch {
// Some environments do not expose userInfo; env vars are enough fallback.
}
return uniqueNonEmpty(candidates);
}
export function readForbiddenTokensFile(tokensFile) {
if (!existsSync(tokensFile)) return [];
return readFileSync(tokensFile, "utf8")
.split("\n")
.map((line) => line.trim())
.filter((line) => line && !line.startsWith("#"));
}
export function resolveForbiddenTokens(tokensFile, env = process.env, osModule = os) {
return uniqueNonEmpty([
...resolveDynamicForbiddenTokens(env, osModule),
...readForbiddenTokensFile(tokensFile),
]);
}
export function runForbiddenTokenCheck({
repoRoot,
tokens,
exec = execSync,
log = console.log,
error = console.error,
}) {
if (tokens.length === 0) {
log(" ℹ Forbidden tokens list is empty — skipping check.");
return 0;
}
let found = false;
for (const token of tokens) {
try {
const result = exec(
`git grep -in --no-color -- ${JSON.stringify(token)} -- ':!pnpm-lock.yaml' ':!.git'`,
{ encoding: "utf8", cwd: repoRoot, stdio: ["pipe", "pipe", "pipe"] },
);
if (result.trim()) {
if (!found) {
error("ERROR: Forbidden tokens found in tracked files:\n");
}
found = true;
const lines = result.trim().split("\n");
for (const line of lines) {
error(` ${line}`);
}
}
} catch {
// git grep returns exit code 1 when no matches — that's fine
}
}
if (found) {
error("\nBuild blocked. Remove the forbidden token(s) before publishing.");
return 1;
}
log(" ✓ No forbidden tokens found.");
return 0;
}
function resolveRepoPaths(exec = execSync) {
const repoRoot = exec("git rev-parse --show-toplevel", { encoding: "utf8" }).trim();
const gitDir = exec("git rev-parse --git-dir", { encoding: "utf8", cwd: repoRoot }).trim();
return {
repoRoot,
tokensFile: resolve(repoRoot, gitDir, "hooks/forbidden-tokens.txt"),
};
}
function main() {
const { repoRoot, tokensFile } = resolveRepoPaths();
const tokens = resolveForbiddenTokens(tokensFile);
process.exit(runForbiddenTokenCheck({ repoRoot, tokens }));
}
const isMainModule = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isMainModule) {
main();
}
@@ -0,0 +1,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"
@@ -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"
@@ -0,0 +1,86 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
TARGET_REF="${1:-HEAD}"
usage() {
cat <<'EOF'
Usage:
./scripts/clean-onboard-ref.sh [git-ref]
Examples:
./scripts/clean-onboard-ref.sh
./scripts/clean-onboard-ref.sh HEAD
./scripts/clean-onboard-ref.sh v0.2.7
Environment overrides:
KEEP_TEMP=1 Keep the temp directory and detached worktree for debugging
PC_TEST_ROOT=/tmp/custom Base temp directory to use
PC_DATA=/tmp/data Paperclip data dir to use
PAPERCLIP_HOST=127.0.0.1 Host passed to the onboarded server
PAPERCLIP_PORT=3232 Port passed to the onboarded server
Notes:
- Defaults to the current committed ref (HEAD), not uncommitted local edits.
- Creates an isolated temp HOME, npm cache, data dir, and detached git worktree.
EOF
}
if [ $# -gt 1 ]; then
usage
exit 1
fi
if [ $# -eq 1 ] && [[ "$1" =~ ^(-h|--help)$ ]]; then
usage
exit 0
fi
TARGET_COMMIT="$(git -C "$REPO_ROOT" rev-parse --verify "${TARGET_REF}^{commit}")"
export KEEP_TEMP="${KEEP_TEMP:-0}"
export PC_TEST_ROOT="${PC_TEST_ROOT:-$(mktemp -d /tmp/paperclip-clean-ref.XXXXXX)}"
export PC_HOME="${PC_HOME:-$PC_TEST_ROOT/home}"
export PC_CACHE="${PC_CACHE:-$PC_TEST_ROOT/npm-cache}"
export PC_DATA="${PC_DATA:-$PC_TEST_ROOT/paperclip-data}"
export PC_REPO="${PC_REPO:-$PC_TEST_ROOT/repo}"
export PAPERCLIP_HOST="${PAPERCLIP_HOST:-127.0.0.1}"
export PAPERCLIP_PORT="${PAPERCLIP_PORT:-3100}"
export PAPERCLIP_OPEN_ON_LISTEN="${PAPERCLIP_OPEN_ON_LISTEN:-false}"
cleanup() {
if [ "$KEEP_TEMP" = "1" ]; then
return
fi
git -C "$REPO_ROOT" worktree remove --force "$PC_REPO" >/dev/null 2>&1 || true
rm -rf "$PC_TEST_ROOT"
}
trap cleanup EXIT
mkdir -p "$PC_HOME" "$PC_CACHE" "$PC_DATA"
echo "TARGET_REF: $TARGET_REF"
echo "TARGET_COMMIT: $TARGET_COMMIT"
echo "PC_TEST_ROOT: $PC_TEST_ROOT"
echo "PC_HOME: $PC_HOME"
echo "PC_DATA: $PC_DATA"
echo "PC_REPO: $PC_REPO"
echo "PAPERCLIP_HOST: $PAPERCLIP_HOST"
echo "PAPERCLIP_PORT: $PAPERCLIP_PORT"
git -C "$REPO_ROOT" worktree add --detach "$PC_REPO" "$TARGET_COMMIT"
cd "$PC_REPO"
pnpm install
env \
HOME="$PC_HOME" \
npm_config_cache="$PC_CACHE" \
npm_config_userconfig="$PC_HOME/.npmrc" \
HOST="$PAPERCLIP_HOST" \
PORT="$PAPERCLIP_PORT" \
PAPERCLIP_OPEN_ON_LISTEN="$PAPERCLIP_OPEN_ON_LISTEN" \
pnpm paperclipai onboard --yes --data-dir "$PC_DATA"
@@ -0,0 +1,99 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
# shellcheck source=./release-lib.sh
. "$REPO_ROOT/scripts/release-lib.sh"
dry_run=false
version=""
usage() {
cat <<'EOF'
Usage:
./scripts/create-github-release.sh <version> [--dry-run]
Examples:
./scripts/create-github-release.sh 2026.318.0
./scripts/create-github-release.sh 2026.318.0 --dry-run
Notes:
- Run this after pushing the stable tag.
- Resolves the git remote automatically.
- In GitHub Actions, origin is used explicitly.
- If the release already exists, this script updates its title and notes.
EOF
}
while [ $# -gt 0 ]; do
case "$1" in
--dry-run) dry_run=true ;;
-h|--help)
usage
exit 0
;;
*)
if [ -n "$version" ]; then
echo "Error: only one version may be provided." >&2
exit 1
fi
version="$1"
;;
esac
shift
done
if [ -z "$version" ]; then
usage
exit 1
fi
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Error: version must be a stable calendar version like 2026.318.0." >&2
exit 1
fi
tag="v$version"
notes_file="$REPO_ROOT/releases/${tag}.md"
if [ "${GITHUB_ACTIONS:-}" = "true" ] && [ -z "${PUBLISH_REMOTE:-}" ] && git_remote_exists origin; then
PUBLISH_REMOTE=origin
fi
PUBLISH_REMOTE="$(resolve_release_remote)"
if ! command -v gh >/dev/null 2>&1; then
echo "Error: gh CLI is required to create GitHub releases." >&2
exit 1
fi
GITHUB_REPO="$(github_repo_from_remote "$PUBLISH_REMOTE" || true)"
if [ -z "$GITHUB_REPO" ]; then
echo "Error: could not determine GitHub repository from remote $PUBLISH_REMOTE." >&2
exit 1
fi
if [ ! -f "$notes_file" ]; then
echo "Error: release notes file not found at $notes_file." >&2
exit 1
fi
if ! git -C "$REPO_ROOT" rev-parse "$tag" >/dev/null 2>&1; then
echo "Error: local git tag $tag does not exist." >&2
exit 1
fi
if [ "$dry_run" = true ]; then
echo "[dry-run] gh release create $tag -R $GITHUB_REPO --title $tag --notes-file $notes_file"
exit 0
fi
if ! git -C "$REPO_ROOT" ls-remote --exit-code --tags "$PUBLISH_REMOTE" "refs/tags/$tag" >/dev/null 2>&1; then
echo "Error: remote tag $tag was not found on $PUBLISH_REMOTE. Push the release commit and tag first." >&2
exit 1
fi
if gh release view "$tag" -R "$GITHUB_REPO" >/dev/null 2>&1; then
gh release edit "$tag" -R "$GITHUB_REPO" --title "$tag" --notes-file "$notes_file"
echo "Updated GitHub Release $tag"
else
gh release create "$tag" -R "$GITHUB_REPO" --title "$tag" --notes-file "$notes_file"
echo "Created GitHub Release $tag"
fi
@@ -0,0 +1,38 @@
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",
]);
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 (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;
}
+591
View File
@@ -0,0 +1,591 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
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 { 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 watchedDirectories = [
"cli",
"scripts",
"server",
"packages/adapter-utils",
"packages/adapters",
"packages/db",
"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;
}
if (mode === "watch") {
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,
shell: process.platform === "win32",
});
let stdoutBuffer = "";
let stderrBuffer = "";
if (spawned.stdout) {
spawned.stdout.on("data", (chunk) => {
stdoutBuffer += String(chunk);
});
}
if (spawned.stderr) {
spawned.stderr.on("data", (chunk) => {
stderrBuffer += String(chunk);
});
}
spawned.on("error", reject);
spawned.on("exit", (code, signal) => {
resolve({
code: code ?? 0,
signal,
stdout: stdoutBuffer,
stderr: stderrBuffer,
});
});
});
}
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`);
if (!response.ok) {
throw new Error(`Health request failed (${response.status})`);
}
return await response.json();
}
async function waitForChildExit() {
if (!childExitPromise) {
return { code: 0, signal: null };
}
return await childExitPromise;
}
async function stopChildForRestart() {
if (!child) return { code: 0, signal: null };
childExitWasExpected = true;
child.kill("SIGTERM");
const killTimer = setTimeout(() => {
if (child) {
child.kill("SIGKILL");
}
}, gracefulShutdownTimeoutMs);
try {
return await waitForChildExit();
} finally {
clearTimeout(killTimer);
}
}
async function startServerChild() {
await buildPluginSdk();
const serverScript = mode === "watch" ? "dev:watch" : "dev";
child = spawn(
pnpmBin,
["--filter", "@paperclipai/server", serverScript, ...forwardedArgs],
{ stdio: "inherit", env, shell: process.platform === "win32" },
);
childExitPromise = new Promise((resolve, reject) => {
child.on("error", reject);
child.on("exit", (code, signal) => {
const expected = childExitWasExpected;
childExitWasExpected = false;
child = null;
childExitPromise = null;
resolve({ code: code ?? 0, signal });
if (restartInFlight || expected || shuttingDown) {
return;
}
if (signal) {
exitForSignal(signal);
return;
}
process.exit(code ?? 0);
});
});
await markChildAsCurrent();
}
async function maybeAutoRestartChild() {
if (mode !== "dev" || restartInFlight || !child) return;
if (dirtyPaths.size === 0 && pendingMigrations.length === 0) return;
restartInFlight = true;
let health;
try {
health = await getDevHealthPayload();
} catch {
restartInFlight = false;
return;
}
const devServer = health?.devServer;
if (!devServer?.enabled || devServer.autoRestartEnabled !== true) {
restartInFlight = false;
return;
}
if ((devServer.activeRunCount ?? 0) > 0) {
restartInFlight = false;
return;
}
try {
await maybePreflightMigrations({
autoApply: true,
interactive: false,
exitOnDecline: false,
});
await stopChildForRestart();
await startServerChild();
} catch (error) {
const err = toError(error, "Auto-restart failed");
process.stderr.write(`${err.stack ?? err.message}\n`);
process.exit(1);
} finally {
restartInFlight = false;
}
}
function installDevIntervals() {
if (mode !== "dev") return;
scanTimer = setInterval(() => {
void scanForBackendChanges();
}, scanIntervalMs);
autoRestartTimer = setInterval(() => {
void maybeAutoRestartChild();
}, autoRestartPollIntervalMs);
}
function clearDevIntervals() {
if (scanTimer) {
clearInterval(scanTimer);
scanTimer = null;
}
if (autoRestartTimer) {
clearInterval(autoRestartTimer);
autoRestartTimer = null;
}
}
async function shutdown(signal) {
if (shuttingDown) return;
shuttingDown = true;
clearDevIntervals();
clearDevServerStatus();
if (!child) {
if (signal) {
exitForSignal(signal);
return;
}
process.exit(0);
}
childExitWasExpected = true;
child.kill(signal);
const exit = await waitForChildExit();
if (exit.signal) {
exitForSignal(exit.signal);
return;
}
process.exit(exit.code ?? 0);
}
process.on("SIGINT", () => {
void shutdown("SIGINT");
});
process.on("SIGTERM", () => {
void shutdown("SIGTERM");
});
await maybePreflightMigrations();
await startServerChild();
installDevIntervals();
if (mode === "watch") {
const exit = await waitForChildExit();
if (exit.signal) {
exitForSignal(exit.signal);
}
process.exit(exit.code ?? 0);
}
@@ -0,0 +1,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/Dockerfile.onboard-smoke" \
-t "$IMAGE_NAME" \
"$REPO_ROOT"
echo "==> Running onboard smoke container"
echo " UI should be reachable at: http://localhost:$HOST_PORT"
echo " Public URL: $PAPERCLIP_PUBLIC_URL"
echo " Smoke auto-bootstrap: $SMOKE_AUTO_BOOTSTRAP"
echo " Detached mode: $SMOKE_DETACH"
echo " Data dir: $DATA_DIR"
echo " Deployment: $PAPERCLIP_DEPLOYMENT_MODE/$PAPERCLIP_DEPLOYMENT_EXPOSURE"
if [[ "$SMOKE_DETACH" != "true" ]]; then
echo " Live output: onboard banner and server logs stream in this terminal (Ctrl+C to stop)"
fi
docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
docker run -d --rm \
--name "$CONTAINER_NAME" \
-p "$HOST_PORT:3100" \
-e HOST=0.0.0.0 \
-e PORT=3100 \
-e PAPERCLIP_DEPLOYMENT_MODE="$PAPERCLIP_DEPLOYMENT_MODE" \
-e PAPERCLIP_DEPLOYMENT_EXPOSURE="$PAPERCLIP_DEPLOYMENT_EXPOSURE" \
-e PAPERCLIP_PUBLIC_URL="$PAPERCLIP_PUBLIC_URL" \
-v "$DATA_DIR:/paperclip" \
"$IMAGE_NAME" >/dev/null
if [[ "$SMOKE_DETACH" != "true" ]]; then
docker logs -f "$CONTAINER_NAME" &
LOG_PID=$!
fi
TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/paperclip-onboard-smoke.XXXXXX")"
COOKIE_JAR="$TMP_DIR/cookies.txt"
if ! wait_for_http "$PAPERCLIP_PUBLIC_URL/api/health" 90 1; then
echo "Smoke bootstrap failed: server did not become ready at $PAPERCLIP_PUBLIC_URL/api/health" >&2
exit 1
fi
if [[ "$SMOKE_AUTO_BOOTSTRAP" == "true" && "$PAPERCLIP_DEPLOYMENT_MODE" == "authenticated" ]]; then
auto_bootstrap_authenticated_smoke
fi
write_metadata_file
if [[ "$SMOKE_DETACH" == "true" ]]; then
PRESERVE_CONTAINER_ON_EXIT="true"
echo "==> Smoke container ready for automation"
echo " Smoke base URL: $PAPERCLIP_PUBLIC_URL"
echo " Smoke admin credentials: $SMOKE_ADMIN_EMAIL / $SMOKE_ADMIN_PASSWORD"
if [[ -n "$SMOKE_METADATA_FILE" ]]; then
echo " Smoke metadata file: $SMOKE_METADATA_FILE"
fi
exit 0
fi
wait "$LOG_PID"
@@ -0,0 +1,46 @@
#!/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 buildTargets = [
{
name: "@paperclipai/shared",
output: path.join(rootDir, "packages/shared/dist/index.js"),
tsconfig: path.join(rootDir, "packages/shared/tsconfig.json"),
},
{
name: "@paperclipai/plugin-sdk",
output: path.join(rootDir, "packages/plugins/sdk/dist/index.js"),
tsconfig: path.join(rootDir, "packages/plugins/sdk/tsconfig.json"),
},
];
if (!fs.existsSync(tscCliPath)) {
throw new Error(`TypeScript CLI not found at ${tscCliPath}`);
}
for (const target of buildTargets) {
if (fs.existsSync(target.output)) {
continue;
}
const result = spawnSync(process.execPath, [tscCliPath, "-p", target.tsconfig], {
cwd: rootDir,
stdio: "inherit",
});
if (result.error) {
throw result.error;
}
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
}
@@ -0,0 +1,364 @@
#!/usr/bin/env npx tsx
/**
* Generate org chart images and READMEs for agent company packages.
*
* Reads company packages from a directory, builds manifest-like data,
* then uses the existing server-side SVG renderer (sharp, no browser)
* and README generator.
*
* Usage:
* npx tsx scripts/generate-company-assets.ts /path/to/companies-repo
*
* Processes each subdirectory that contains a COMPANY.md file.
*/
import * as fs from "fs";
import * as path from "path";
import { renderOrgChartPng, type OrgNode, type OrgChartOverlay } from "../server/src/routes/org-chart-svg.js";
import { generateReadme } from "../server/src/services/company-export-readme.js";
import type { CompanyPortabilityManifest } from "@paperclipai/shared";
// ── YAML frontmatter parser (minimal, no deps) ──────────────────
function parseFrontmatter(content: string): { data: Record<string, unknown>; body: string } {
const match = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
if (!match) return { data: {}, body: content };
const yamlStr = match[1];
const body = match[2];
const data: Record<string, unknown> = {};
let currentKey: string | null = null;
let currentValue: string | string[] | null = null;
let inList = false;
for (const line of yamlStr.split("\n")) {
// List item
if (inList && /^\s+-\s+/.test(line)) {
const val = line.replace(/^\s+-\s+/, "").trim();
(currentValue as string[]).push(val);
continue;
}
// Save previous key
if (currentKey !== null && currentValue !== null) {
data[currentKey] = currentValue;
}
inList = false;
// Key: value line
const kvMatch = line.match(/^(\w[\w-]*)\s*:\s*(.*)$/);
if (kvMatch) {
currentKey = kvMatch[1];
let val = kvMatch[2].trim();
if (val === "" || val === ">") {
// Could be a multi-line value or list — peek ahead handled by next iterations
currentValue = "";
continue;
}
if (val === "null" || val === "~") {
currentValue = null;
data[currentKey] = null;
currentKey = null;
currentValue = null;
continue;
}
// Remove surrounding quotes
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
val = val.slice(1, -1);
}
currentValue = val;
} else if (currentKey !== null && line.match(/^\s+-\s+/)) {
// Start of list
inList = true;
currentValue = [];
const val = line.replace(/^\s+-\s+/, "").trim();
(currentValue as string[]).push(val);
} else if (currentKey !== null && line.match(/^\s+\S/)) {
// Continuation of multi-line scalar
const trimmed = line.trim();
if (typeof currentValue === "string") {
currentValue = currentValue ? `${currentValue} ${trimmed}` : trimmed;
}
}
}
// Save last key
if (currentKey !== null && currentValue !== null) {
data[currentKey] = currentValue;
}
return { data, body };
}
// ── Slug to role mapping ─────────────────────────────────────────
const SLUG_TO_ROLE: Record<string, string> = {
ceo: "ceo",
cto: "cto",
cmo: "cmo",
cfo: "cfo",
coo: "coo",
};
function inferRole(slug: string, title: string | null): string {
// Check direct slug match first
if (SLUG_TO_ROLE[slug]) return SLUG_TO_ROLE[slug];
// Check title for C-suite
const t = (title || "").toLowerCase();
if (t.includes("chief executive")) return "ceo";
if (t.includes("chief technology")) return "cto";
if (t.includes("chief marketing")) return "cmo";
if (t.includes("chief financial")) return "cfo";
if (t.includes("chief operating")) return "coo";
if (t.includes("vp") || t.includes("vice president")) return "vp";
if (t.includes("manager")) return "manager";
if (t.includes("qa") || t.includes("quality")) return "engineer";
// Default to engineer
return "engineer";
}
// ── Parse a company package directory ────────────────────────────
interface CompanyPackage {
dir: string;
name: string;
description: string | null;
slug: string;
agents: CompanyPortabilityManifest["agents"];
skills: CompanyPortabilityManifest["skills"];
}
function parseCompanyPackage(companyDir: string): CompanyPackage | null {
const companyMdPath = path.join(companyDir, "COMPANY.md");
if (!fs.existsSync(companyMdPath)) return null;
const companyMd = fs.readFileSync(companyMdPath, "utf-8");
const { data: companyData } = parseFrontmatter(companyMd);
const name = (companyData.name as string) || path.basename(companyDir);
const description = (companyData.description as string) || null;
const slug = (companyData.slug as string) || path.basename(companyDir);
// Parse agents
const agentsDir = path.join(companyDir, "agents");
const agents: CompanyPortabilityManifest["agents"] = [];
if (fs.existsSync(agentsDir)) {
for (const agentSlug of fs.readdirSync(agentsDir)) {
const agentMdName = fs.existsSync(path.join(agentsDir, agentSlug, "AGENT.md"))
? "AGENT.md"
: fs.existsSync(path.join(agentsDir, agentSlug, "AGENTS.md"))
? "AGENTS.md"
: null;
if (!agentMdName) continue;
const agentMdPath = path.join(agentsDir, agentSlug, agentMdName);
const agentMd = fs.readFileSync(agentMdPath, "utf-8");
const { data: agentData } = parseFrontmatter(agentMd);
const agentName = (agentData.name as string) || agentSlug;
const title = (agentData.title as string) || null;
const reportsTo = agentData.reportsTo as string | null;
const skills = (agentData.skills as string[]) || [];
const role = inferRole(agentSlug, title);
agents.push({
slug: agentSlug,
name: agentName,
path: `agents/${agentSlug}/${agentMdName}`,
skills,
role,
title,
icon: null,
capabilities: null,
reportsToSlug: reportsTo || null,
adapterType: "claude_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
budgetMonthlyCents: 0,
metadata: null,
});
}
}
// Parse skills
const skillsDir = path.join(companyDir, "skills");
const skills: CompanyPortabilityManifest["skills"] = [];
if (fs.existsSync(skillsDir)) {
for (const skillSlug of fs.readdirSync(skillsDir)) {
const skillMdPath = path.join(skillsDir, skillSlug, "SKILL.md");
if (!fs.existsSync(skillMdPath)) continue;
const skillMd = fs.readFileSync(skillMdPath, "utf-8");
const { data: skillData } = parseFrontmatter(skillMd);
const skillName = (skillData.name as string) || skillSlug;
const skillDesc = (skillData.description as string) || null;
// Extract source info from metadata
let sourceType = "local";
let sourceLocator: string | null = null;
const metadata = skillData.metadata as Record<string, unknown> | undefined;
if (metadata) {
// metadata.sources is parsed as a nested structure, but our simple parser
// doesn't handle it well. Check for github repo in the raw SKILL.md instead.
const repoMatch = skillMd.match(/repo:\s*(.+)/);
const pathMatch = skillMd.match(/path:\s*(.+)/);
if (repoMatch) {
sourceType = "github";
const repo = repoMatch[1].trim();
const filePath = pathMatch ? pathMatch[1].trim() : "";
sourceLocator = `https://github.com/${repo}/blob/main/${filePath}`;
}
}
skills.push({
key: skillSlug,
slug: skillSlug,
name: skillName,
path: `skills/${skillSlug}/SKILL.md`,
description: skillDesc,
sourceType,
sourceLocator,
sourceRef: null,
trustLevel: null,
compatibility: null,
metadata: null,
fileInventory: [{ path: `skills/${skillSlug}/SKILL.md`, kind: "skill" }],
});
}
}
return { dir: companyDir, name, description, slug, agents, skills };
}
// ── Build OrgNode tree from agents ───────────────────────────────
const ROLE_LABELS: Record<string, string> = {
ceo: "Chief Executive",
cto: "Technology",
cmo: "Marketing",
cfo: "Finance",
coo: "Operations",
vp: "VP",
manager: "Manager",
engineer: "Engineer",
agent: "Agent",
};
function buildOrgTree(agents: CompanyPortabilityManifest["agents"]): OrgNode[] {
const bySlug = new Map(agents.map((a) => [a.slug, a]));
const childrenOf = new Map<string | null, typeof agents>();
for (const a of agents) {
const parent = a.reportsToSlug ?? null;
const list = childrenOf.get(parent) ?? [];
list.push(a);
childrenOf.set(parent, list);
}
const build = (parentSlug: string | null): OrgNode[] => {
const members = childrenOf.get(parentSlug) ?? [];
return members.map((m) => ({
id: m.slug,
name: m.name,
role: ROLE_LABELS[m.role] ?? m.role,
status: "active",
reports: build(m.slug),
}));
};
const roots = agents.filter((a) => !a.reportsToSlug || !bySlug.has(a.reportsToSlug));
const tree = build(null);
for (const root of roots) {
if (root.reportsToSlug && !bySlug.has(root.reportsToSlug)) {
tree.push({
id: root.slug,
name: root.name,
role: ROLE_LABELS[root.role] ?? root.role,
status: "active",
reports: build(root.slug),
});
}
}
return tree;
}
// ── Main ─────────────────────────────────────────────────────────
async function main() {
const companiesDir = process.argv[2];
if (!companiesDir) {
console.error("Usage: npx tsx scripts/generate-company-assets.ts <companies-dir>");
process.exit(1);
}
const resolvedDir = path.resolve(companiesDir);
if (!fs.existsSync(resolvedDir)) {
console.error(`Directory not found: ${resolvedDir}`);
process.exit(1);
}
const entries = fs.readdirSync(resolvedDir, { withFileTypes: true });
let processed = 0;
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const companyDir = path.join(resolvedDir, entry.name);
const pkg = parseCompanyPackage(companyDir);
if (!pkg) continue;
console.log(`\n── ${pkg.name} (${pkg.slug}) ──`);
console.log(` ${pkg.agents.length} agents, ${pkg.skills.length} skills`);
// Generate org chart PNG
if (pkg.agents.length > 0) {
const orgTree = buildOrgTree(pkg.agents);
console.log(` Org tree roots: ${orgTree.map((n) => n.name).join(", ")}`);
const overlay: OrgChartOverlay = {
companyName: pkg.name,
stats: `Agents: ${pkg.agents.length}, Skills: ${pkg.skills.length}`,
};
const pngBuffer = await renderOrgChartPng(orgTree, "warmth", overlay);
const imagesDir = path.join(companyDir, "images");
fs.mkdirSync(imagesDir, { recursive: true });
const pngPath = path.join(imagesDir, "org-chart.png");
fs.writeFileSync(pngPath, pngBuffer);
console.log(` ✓ ${path.relative(resolvedDir, pngPath)} (${(pngBuffer.length / 1024).toFixed(1)}kb)`);
}
// Generate README
const manifest: CompanyPortabilityManifest = {
schemaVersion: 1,
generatedAt: new Date().toISOString(),
source: null,
includes: { company: true, agents: true, projects: false, issues: false, skills: true },
company: null,
agents: pkg.agents,
skills: pkg.skills,
projects: [],
issues: [],
envInputs: [],
};
const readme = generateReadme(manifest, {
companyName: pkg.name,
companyDescription: pkg.description,
});
const readmePath = path.join(companyDir, "README.md");
fs.writeFileSync(readmePath, readme);
console.log(` ✓ ${path.relative(resolvedDir, readmePath)}`);
processed++;
}
console.log(`\n✓ Processed ${processed} companies.`);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
@@ -0,0 +1,112 @@
#!/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/opencode-local",
"packages/adapters/openclaw-gateway",
];
// Workspace packages that are NOT bundled and must stay as npm dependencies.
// These get published separately and resolved at runtime.
const externalWorkspacePackages = new Set([
"@paperclipai/server",
]);
// Collect all external dependencies from all workspace packages
const allDeps = {};
const allOptionalDeps = {};
for (const pkgPath of workspacePaths) {
const pkg = readPkg(pkgPath);
const deps = pkg.dependencies || {};
const optDeps = pkg.optionalDependencies || {};
for (const [name, version] of Object.entries(deps)) {
if (name.startsWith("@paperclipai/") && !externalWorkspacePackages.has(name)) continue;
// For external workspace packages, read their version directly
if (externalWorkspacePackages.has(name)) {
const pkgDirMap = { "@paperclipai/server": "server" };
const wsPkg = readPkg(pkgDirMap[name]);
allDeps[name] = wsPkg.version;
continue;
}
// Keep the more specific (pinned) version if conflict
if (!allDeps[name] || !version.startsWith("^")) {
allDeps[name] = version;
}
}
for (const [name, version] of Object.entries(optDeps)) {
allOptionalDeps[name] = version;
}
}
// Sort alphabetically
const sortedDeps = Object.fromEntries(Object.entries(allDeps).sort(([a], [b]) => a.localeCompare(b)));
const sortedOptDeps = Object.fromEntries(
Object.entries(allOptionalDeps).sort(([a], [b]) => a.localeCompare(b)),
);
// Read the CLI package metadata — prefer the dev backup if it exists
const devPkgPath = resolve(repoRoot, "cli/package.dev.json");
const cliPkg = existsSync(devPkgPath)
? JSON.parse(readFileSync(devPkgPath, "utf8"))
: readPkg("cli");
// Build the publishable package.json
const publishPkg = {
name: cliPkg.name,
version: cliPkg.version,
description: cliPkg.description,
type: cliPkg.type,
bin: cliPkg.bin,
keywords: cliPkg.keywords,
license: cliPkg.license,
repository: cliPkg.repository,
homepage: cliPkg.homepage,
bugs: cliPkg.bugs,
files: cliPkg.files,
engines: { node: ">=20" },
dependencies: sortedDeps,
};
if (Object.keys(sortedOptDeps).length > 0) {
publishPkg.optionalDependencies = sortedOptDeps;
}
const output = JSON.stringify(publishPkg, null, 2) + "\n";
const outPath = resolve(repoRoot, "cli/package.json");
writeFileSync(outPath, output);
console.log(` ✓ Generated publishable package.json (${Object.keys(sortedDeps).length} deps)`);
console.log(` Version: ${cliPkg.version}`);
@@ -0,0 +1,694 @@
#!/usr/bin/env npx tsx
/**
* Standalone org chart image generator.
*
* Renders each of the 5 org chart styles to PNG using Playwright (headless Chromium).
* This gives us browser-native emoji rendering, full CSS support, and pixel-perfect output.
*
* Usage:
* npx tsx scripts/generate-org-chart-images.ts
*
* Output: tmp/org-chart-images/<style>-<size>.png
*/
import { chromium } from "@playwright/test";
import * as fs from "fs";
import * as path from "path";
// ── Org data (same as index.html) ──────────────────────────────
interface OrgNode {
name: string;
role: string;
icon?: string;
tag: string;
children?: OrgNode[];
}
const ORGS: Record<string, OrgNode> = {
sm: {
name: "CEO",
role: "Chief Executive",
icon: "👑",
tag: "ceo",
children: [
{ name: "Engineer", role: "Engineer", icon: "⌨️", tag: "eng" },
{ name: "Designer", role: "Design", icon: "🪄", tag: "des" },
],
},
med: {
name: "CEO",
role: "Chief Executive",
icon: "👑",
tag: "ceo",
children: [
{
name: "CTO",
role: "Technology",
icon: "💻",
tag: "cto",
children: [
{ name: "ClaudeCoder", role: "Engineer", tag: "eng" },
{ name: "CodexCoder", role: "Engineer", tag: "eng" },
{ name: "SparkCoder", role: "Engineer", tag: "eng" },
{ name: "CursorCoder", role: "Engineer", tag: "eng" },
{ name: "QA", role: "Quality", tag: "qa" },
],
},
{
name: "CMO",
role: "Marketing",
icon: "🌐",
tag: "cmo",
children: [{ name: "Designer", role: "Design", tag: "des" }],
},
],
},
lg: {
name: "CEO",
role: "Chief Executive",
icon: "👑",
tag: "ceo",
children: [
{
name: "CTO",
role: "Technology",
icon: "💻",
tag: "cto",
children: [
{ name: "Eng 1", role: "Eng", tag: "eng" },
{ name: "Eng 2", role: "Eng", tag: "eng" },
{ name: "Eng 3", role: "Eng", tag: "eng" },
{ name: "QA", role: "QA", tag: "qa" },
],
},
{
name: "CMO",
role: "Marketing",
icon: "🌐",
tag: "cmo",
children: [
{ name: "Designer", role: "Design", tag: "des" },
{ name: "Content", role: "Writer", tag: "eng" },
],
},
{
name: "CFO",
role: "Finance",
icon: "📊",
tag: "fin",
children: [{ name: "Analyst", role: "Finance", tag: "fin" }],
},
{
name: "COO",
role: "Operations",
icon: "⚙️",
tag: "ops",
children: [
{ name: "Ops 1", role: "Ops", tag: "ops" },
{ name: "Ops 2", role: "Ops", tag: "ops" },
{ name: "DevOps", role: "Infra", tag: "ops" },
],
},
],
},
};
// OG collapsed org
const OG_ORG: OrgNode = {
name: "CEO",
role: "Chief Executive",
tag: "ceo",
children: [
{ name: "CTO", role: "×5 reports", tag: "cto" },
{ name: "CMO", role: "×1 report", tag: "cmo" },
],
};
// ── Style definitions ──────────────────────────────────────────
interface StyleDef {
key: string;
name: string;
css: string;
renderCard: (node: OrgNode, isOg: boolean) => string;
}
const COMMON_CSS = `
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap');
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
-webkit-font-smoothing: antialiased;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 0;
}
.org-tree {
display: flex;
flex-direction: column;
align-items: center;
width: max-content;
--line-color: #3f3f46;
--line-w: 1.5px;
--drop-h: 20px;
--child-gap: 14px;
}
.org-node {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
}
.org-children {
display: flex;
justify-content: center;
padding-top: calc(var(--drop-h) * 2);
position: relative;
gap: var(--child-gap);
}
.org-children::before {
content: '';
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
width: var(--line-w);
height: calc(var(--drop-h) * 2);
background: var(--line-color);
}
.org-children > .org-node {
padding-top: var(--drop-h);
position: relative;
}
.org-children > .org-node::before {
content: '';
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
width: var(--line-w);
height: var(--drop-h);
background: var(--line-color);
}
.org-children > .org-node::after {
content: '';
position: absolute;
top: 0;
left: calc(-0.5 * var(--child-gap));
right: calc(-0.5 * var(--child-gap));
height: var(--line-w);
background: var(--line-color);
}
.org-children > .org-node:first-child::after { left: 50%; }
.org-children > .org-node:last-child::after { right: 50%; }
.org-children > .org-node:only-child::after { display: none; }
.org-card {
text-align: center;
position: relative;
}
.org-card .name { white-space: nowrap; }
.org-card .role { white-space: nowrap; }
.org-card .icon-wrap { margin-bottom: 8px; font-size: 18px; line-height: 1; }
/* OG compact overrides */
.og-compact .org-card { padding: 10px 14px !important; min-width: 80px !important; }
.og-compact .org-card .name { font-size: 11px !important; }
.og-compact .org-card .role { font-size: 9px !important; }
.og-compact .org-card .icon-wrap { font-size: 14px !important; margin-bottom: 5px !important; }
.og-compact .org-card .avatar { width: 24px !important; height: 24px !important; font-size: 11px !important; margin-bottom: 6px !important; }
.og-compact .org-children { padding-top: 20px !important; gap: 8px !important; }
.og-compact .org-tree { --drop-h: 10px; --child-gap: 8px; }
/* Watermark */
.watermark {
position: absolute;
bottom: 12px;
right: 16px;
font-size: 11px;
font-weight: 500;
color: rgba(128,128,128,0.4);
font-family: 'Inter', sans-serif;
letter-spacing: 0.02em;
display: flex;
align-items: center;
gap: 5px;
}
.watermark svg { opacity: 0.4; }
`;
const STYLES: StyleDef[] = [
{
key: "mono",
name: "Monochrome",
css: `
body { background: #18181b; }
.org-tree { --line-color: #3f3f46; }
.org-card {
background: #18181b;
border: 1px solid #27272a;
border-radius: 6px;
padding: 16px 22px;
min-width: 130px;
}
.org-card .name {
font-size: 14px; font-weight: 600; color: #fafafa;
letter-spacing: -0.01em; margin-bottom: 3px;
}
.org-card .role {
font-size: 10px; color: #71717a;
text-transform: uppercase; letter-spacing: 0.06em; font-weight: 500;
}
.watermark { color: rgba(255,255,255,0.25); }
.watermark svg { stroke: rgba(255,255,255,0.25); }
`,
renderCard: (node, isOg) => {
const icon =
node.icon && !isOg
? `<div class="icon-wrap">${node.icon}</div>`
: "";
return `<div class="org-card">${icon}<div class="name">${node.name}</div><div class="role">${node.role}</div></div>`;
},
},
{
key: "nebula",
name: "Nebula",
css: `
body { background: #0f0c29; }
.org-tree {
background: linear-gradient(135deg, #0f0c29 0%, #302b63 50%, #24243e 100%);
border-radius: 6px;
padding: 36px 28px;
position: relative;
overflow: hidden;
--line-color: rgba(255,255,255,0.25);
--line-w: 1.5px;
}
.org-tree::before {
content: '';
position: absolute;
inset: 0;
background:
radial-gradient(ellipse 600px 400px at 25% 30%, rgba(99,102,241,0.12) 0%, transparent 70%),
radial-gradient(ellipse 500px 350px at 75% 65%, rgba(168,85,247,0.08) 0%, transparent 70%);
pointer-events: none;
}
.org-node { z-index: 1; }
.org-card {
background: rgba(255,255,255,0.07);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(255,255,255,0.12);
border-radius: 6px;
padding: 16px 22px;
min-width: 130px;
}
.org-card .name {
font-size: 14px; font-weight: 600; color: #fff; margin-bottom: 3px;
}
.org-card .role {
font-size: 10px; color: rgba(255,255,255,0.45);
text-transform: uppercase; letter-spacing: 0.06em; font-weight: 500;
}
.watermark { color: rgba(255,255,255,0.2); }
.watermark svg { stroke: rgba(255,255,255,0.2); }
`,
renderCard: (node, isOg) => {
const icon =
node.icon && !isOg
? `<div class="icon-wrap">${node.icon}</div>`
: "";
return `<div class="org-card">${icon}<div class="name">${node.name}</div><div class="role">${node.role}</div></div>`;
},
},
{
key: "circuit",
name: "Circuit",
css: `
body { background: #0c0c0e; }
.org-tree {
background: #0c0c0e;
border-radius: 6px;
padding: 36px 28px;
--line-color: rgba(99,102,241,0.35);
--line-w: 1.5px;
}
.org-card {
background: linear-gradient(135deg, rgba(99,102,241,0.06), rgba(99,102,241,0.01));
border: 1px solid rgba(99,102,241,0.18);
border-radius: 5px;
padding: 14px 20px;
min-width: 120px;
}
.org-card.chief {
border-color: rgba(168,85,247,0.35);
background: linear-gradient(135deg, rgba(168,85,247,0.08), rgba(168,85,247,0.01));
}
.org-card .name {
font-size: 13px; font-weight: 600; color: #e4e4e7;
margin-bottom: 3px; letter-spacing: -0.005em;
}
.org-card .role {
font-size: 10px; color: #6366f1;
text-transform: uppercase; letter-spacing: 0.07em; font-weight: 500;
}
.watermark { color: rgba(99,102,241,0.3); }
.watermark svg { stroke: rgba(99,102,241,0.3); }
`,
renderCard: (node, isOg) => {
const cls = node.tag === "ceo" ? " chief" : "";
const icon =
node.icon && !isOg
? `<div class="icon-wrap">${node.icon}</div>`
: "";
return `<div class="org-card${cls}">${icon}<div class="name">${node.name}</div><div class="role">${node.role}</div></div>`;
},
},
{
key: "warm",
name: "Warmth",
css: `
body { background: #fafaf9; }
.org-tree {
background: #fafaf9;
border-radius: 6px;
padding: 36px 28px;
--line-color: #d6d3d1;
--line-w: 2px;
}
.org-card {
background: #fff;
border: 1px solid #e7e5e4;
border-radius: 6px;
padding: 16px 22px;
min-width: 130px;
box-shadow: 0 1px 3px rgba(0,0,0,0.05), 0 1px 2px rgba(0,0,0,0.03);
}
.org-card .avatar {
width: 34px; height: 34px; border-radius: 50%;
margin: 0 auto 10px;
display: flex; align-items: center; justify-content: center;
font-size: 15px; line-height: 1;
}
.org-card .avatar.r-ceo { background: #fef3c7; }
.org-card .avatar.r-cto { background: #dbeafe; }
.org-card .avatar.r-cmo { background: #dcfce7; }
.org-card .avatar.r-eng { background: #f3e8ff; }
.org-card .avatar.r-qa { background: #ffe4e6; }
.org-card .avatar.r-des { background: #fce7f3; }
.org-card .avatar.r-fin { background: #fef3c7; }
.org-card .avatar.r-ops { background: #e0f2fe; }
.org-card .name {
font-size: 14px; font-weight: 600; color: #1c1917; margin-bottom: 2px;
}
.org-card .role {
font-size: 11px; color: #78716c; font-weight: 500;
}
.watermark { color: rgba(0,0,0,0.25); }
.watermark svg { stroke: rgba(0,0,0,0.25); }
`,
renderCard: (node, isOg) => {
const icons: Record<string, string> = {
ceo: "👑",
cto: "💻",
cmo: "🌐",
eng: "⌨️",
qa: "🔬",
des: "🪄",
fin: "📊",
ops: "⚙️",
};
const ic = node.icon || icons[node.tag] || "";
const sizeStyle = isOg
? "width:24px;height:24px;font-size:11px;margin-bottom:6px;"
: "";
const avatar = `<div class="avatar r-${node.tag}" style="${sizeStyle}">${ic}</div>`;
return `<div class="org-card">${avatar}<div class="name">${node.name}</div><div class="role">${node.role}</div></div>`;
},
},
{
key: "schema",
name: "Schematic",
css: `
body { background: #0d1117; }
.org-tree {
font-family: 'JetBrains Mono', 'SF Mono', monospace;
background: #0d1117;
background-image:
linear-gradient(rgba(48,54,61,0.25) 1px, transparent 1px),
linear-gradient(90deg, rgba(48,54,61,0.25) 1px, transparent 1px);
background-size: 20px 20px;
border-radius: 4px;
padding: 36px 28px;
border: 1px solid #21262d;
--line-color: #30363d;
--line-w: 1.5px;
}
.org-card {
background: rgba(13,17,23,0.92);
border: 1px solid #30363d;
border-radius: 4px;
padding: 12px 16px;
min-width: 120px;
position: relative;
}
.org-card::after {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 2px;
border-radius: 4px 4px 0 0;
}
.org-card.t-ceo::after { background: #f0883e; }
.org-card.t-cto::after { background: #58a6ff; }
.org-card.t-cmo::after { background: #3fb950; }
.org-card.t-eng::after { background: #bc8cff; }
.org-card.t-qa::after { background: #f778ba; }
.org-card.t-des::after { background: #79c0ff; }
.org-card.t-fin::after { background: #f0883e; }
.org-card.t-ops::after { background: #58a6ff; }
.org-card .name {
font-size: 12px; font-weight: 600; color: #c9d1d9; margin-bottom: 2px;
}
.org-card .role {
font-size: 10px; color: #8b949e; letter-spacing: 0.02em;
}
.watermark { color: rgba(139,148,158,0.3); font-family: 'JetBrains Mono', monospace; }
.watermark svg { stroke: rgba(139,148,158,0.3); }
`,
renderCard: (node, isOg) => {
const schemaRoles: Record<string, string> = {
ceo: "chief_executive",
cto: "chief_technology",
cmo: "chief_marketing",
eng: "engineer",
qa: "quality",
des: "designer",
fin: "finance",
ops: "operations",
};
const icon =
node.icon && !isOg
? `<div class="icon-wrap">${node.icon}</div>`
: "";
const roleText =
isOg
? node.role
: node.children
? node.role
: schemaRoles[node.tag] || node.role;
return `<div class="org-card t-${node.tag}">${icon}<div class="name">${node.name}</div><div class="role">${roleText}</div></div>`;
},
},
];
// ── HTML rendering ─────────────────────────────────────────────
function renderNode(
node: OrgNode,
style: StyleDef,
isOg: boolean,
): string {
const cardHtml = style.renderCard(node, isOg);
if (!node.children || node.children.length === 0) {
return `<div class="org-node">${cardHtml}</div>`;
}
const childrenHtml = node.children
.map((c) => renderNode(c, style, isOg))
.join("");
return `<div class="org-node">${cardHtml}<div class="org-children">${childrenHtml}</div></div>`;
}
function renderTree(
orgData: OrgNode,
style: StyleDef,
isOg: boolean,
): string {
const compact = isOg ? " og-compact" : "";
return `<div class="org-tree${compact}">${renderNode(orgData, style, isOg)}</div>`;
}
const PAPERCLIP_WATERMARK = `<div class="watermark">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m18 4-8.414 8.586a2 2 0 0 0 2.829 2.829l8.414-8.586a4 4 0 1 0-5.657-5.657l-8.379 8.551a6 6 0 1 0 8.485 8.485l8.379-8.551"/>
</svg>
Paperclip
</div>`;
function buildHtml(
style: StyleDef,
orgData: OrgNode,
isOg: boolean,
): string {
const tree = renderTree(orgData, style, isOg);
return `<!DOCTYPE html>
<html><head>
<meta charset="UTF-8">
<style>${COMMON_CSS}${style.css}</style>
</head><body>
<div style="position:relative;display:inline-block;">
${tree}
${PAPERCLIP_WATERMARK}
</div>
</body></html>`;
}
// ── Main ───────────────────────────────────────────────────────
async function main() {
const outDir = path.resolve("tmp/org-chart-images");
fs.mkdirSync(outDir, { recursive: true });
const browser = await chromium.launch();
const context = await browser.newContext({
deviceScaleFactor: 2, // retina quality
});
const sizes = ["sm", "med", "lg"] as const;
const results: string[] = [];
for (const style of STYLES) {
// README sizes
for (const size of sizes) {
const page = await context.newPage();
const html = buildHtml(style, ORGS[size], false);
await page.setContent(html, { waitUntil: "networkidle" });
// Wait for fonts to load
await page.waitForFunction(() => document.fonts.ready);
await page.waitForTimeout(300);
// Fit to content
const box = await page.evaluate(() => {
const el = document.querySelector(".org-tree")!;
const rect = el.getBoundingClientRect();
return {
width: Math.ceil(rect.width) + 32,
height: Math.ceil(rect.height) + 32,
};
});
await page.setViewportSize({
width: Math.max(box.width, 400),
height: Math.max(box.height, 300),
});
const filename = `${style.key}-${size}.png`;
await page.screenshot({
path: path.join(outDir, filename),
clip: {
x: 0,
y: 0,
width: Math.max(box.width, 400),
height: Math.max(box.height, 300),
},
});
await page.close();
results.push(filename);
console.log(` ✓ ${filename}`);
}
// OG card (1200×630)
{
const page = await context.newPage();
await page.setViewportSize({ width: 1200, height: 630 });
const html = buildHtml(style, OG_ORG, true);
// For OG, center the tree in a fixed viewport
const ogHtml = html.replace(
"<body>",
`<body style="width:1200px;height:630px;display:flex;align-items:center;justify-content:center;">`,
);
await page.setContent(ogHtml, { waitUntil: "networkidle" });
await page.waitForFunction(() => document.fonts.ready);
await page.waitForTimeout(300);
const filename = `${style.key}-og.png`;
await page.screenshot({
path: path.join(outDir, filename),
clip: { x: 0, y: 0, width: 1200, height: 630 },
});
await page.close();
results.push(filename);
console.log(` ✓ ${filename}`);
}
}
await browser.close();
// Build an HTML comparison page
let compHtml = `<!DOCTYPE html>
<html><head>
<meta charset="UTF-8">
<title>Org Chart Style Comparison</title>
<style>
body { font-family: 'Inter', system-ui, sans-serif; background: #050505; color: #eee; padding: 40px; }
h1 { font-size: 28px; font-weight: 700; margin-bottom: 8px; letter-spacing: -0.03em; }
p.sub { color: #888; font-size: 14px; margin-bottom: 40px; }
.style-section { margin-bottom: 60px; }
.style-section h2 { font-size: 20px; font-weight: 600; margin-bottom: 16px; letter-spacing: -0.02em; }
.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 16px; }
.grid img { width: 100%; border-radius: 8px; border: 1px solid #222; }
.og-row { max-width: 600px; }
.og-row img { width: 100%; border-radius: 8px; border: 1px solid #222; }
.label { font-size: 11px; color: #666; text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 6px; font-weight: 500; }
</style>
</head><body>
<h1>Org Chart Export — Style Comparison</h1>
<p class="sub">5 styles × 3 org sizes + OG cards. All rendered via Playwright (browser-native emojis, full CSS).</p>
`;
for (const style of STYLES) {
compHtml += `<div class="style-section">
<h2>${style.name}</h2>
<div class="label">README — Small / Medium / Large</div>
<div class="grid">
<img src="${style.key}-sm.png" />
<img src="${style.key}-med.png" />
<img src="${style.key}-lg.png" />
</div>
<div class="label">OG Card (1200×630)</div>
<div class="og-row"><img src="${style.key}-og.png" /></div>
</div>`;
}
compHtml += `</body></html>`;
fs.writeFileSync(path.join(outDir, "comparison.html"), compHtml);
console.log(`\n✓ All done! ${results.length} images generated.`);
console.log(` Open: tmp/org-chart-images/comparison.html`);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
@@ -0,0 +1,225 @@
#!/usr/bin/env npx tsx
/**
* Standalone org chart comparison generator — pure SVG (no Playwright).
*
* Generates SVG files for all 5 styles × 3 org sizes, plus a comparison HTML page.
* Uses the server-side SVG renderer directly — same code that powers the routes.
*
* Usage:
* npx tsx scripts/generate-org-chart-satori-comparison.ts
*
* Output: tmp/org-chart-svg-comparison/
*/
import * as fs from "fs";
import * as path from "path";
import {
renderOrgChartSvg,
renderOrgChartPng,
type OrgNode,
type OrgChartStyle,
ORG_CHART_STYLES,
} from "../server/src/routes/org-chart-svg.js";
// ── Sample org data ──────────────────────────────────────────────
const ORGS: Record<string, OrgNode> = {
sm: {
id: "ceo",
name: "CEO",
role: "Chief Executive",
status: "active",
reports: [
{ id: "eng1", name: "Engineer", role: "Engineering", status: "active", reports: [] },
{ id: "des1", name: "Designer", role: "Design", status: "active", reports: [] },
],
},
med: {
id: "ceo",
name: "CEO",
role: "Chief Executive",
status: "active",
reports: [
{
id: "cto",
name: "CTO",
role: "Technology",
status: "active",
reports: [
{ id: "eng1", name: "ClaudeCoder", role: "Engineering", status: "active", reports: [] },
{ id: "eng2", name: "CodexCoder", role: "Engineering", status: "active", reports: [] },
{ id: "eng3", name: "SparkCoder", role: "Engineering", status: "active", reports: [] },
{ id: "eng4", name: "CursorCoder", role: "Engineering", status: "active", reports: [] },
{ id: "qa1", name: "QA", role: "Quality", status: "active", reports: [] },
],
},
{
id: "cmo",
name: "CMO",
role: "Marketing",
status: "active",
reports: [
{ id: "des1", name: "Designer", role: "Design", status: "active", reports: [] },
],
},
],
},
lg: {
id: "ceo",
name: "CEO",
role: "Chief Executive",
status: "active",
reports: [
{
id: "cto",
name: "CTO",
role: "Technology",
status: "active",
reports: [
{ id: "eng1", name: "Eng 1", role: "Engineering", status: "active", reports: [] },
{ id: "eng2", name: "Eng 2", role: "Engineering", status: "active", reports: [] },
{ id: "eng3", name: "Eng 3", role: "Engineering", status: "active", reports: [] },
{ id: "qa1", name: "QA", role: "Quality", status: "active", reports: [] },
],
},
{
id: "cmo",
name: "CMO",
role: "Marketing",
status: "active",
reports: [
{ id: "des1", name: "Designer", role: "Design", status: "active", reports: [] },
{ id: "wrt1", name: "Content", role: "Engineering", status: "active", reports: [] },
],
},
{
id: "cfo",
name: "CFO",
role: "Finance",
status: "active",
reports: [
{ id: "fin1", name: "Analyst", role: "Finance", status: "active", reports: [] },
],
},
{
id: "coo",
name: "COO",
role: "Operations",
status: "active",
reports: [
{ id: "ops1", name: "Ops 1", role: "Operations", status: "active", reports: [] },
{ id: "ops2", name: "Ops 2", role: "Operations", status: "active", reports: [] },
{ id: "devops1", name: "DevOps", role: "Operations", status: "active", reports: [] },
],
},
],
},
};
const STYLE_META: Record<OrgChartStyle, { name: string; vibe: string; bestFor: string }> = {
monochrome: { name: "Monochrome", vibe: "Vercel — zero color noise, dark", bestFor: "GitHub READMEs, developer docs" },
nebula: { name: "Nebula", vibe: "Glassmorphism — cosmic gradient", bestFor: "Hero sections, marketing" },
circuit: { name: "Circuit", vibe: "Linear/Raycast — indigo traces", bestFor: "Product pages, dev tools" },
warmth: { name: "Warmth", vibe: "Airbnb — light, colored avatars", bestFor: "Light-mode READMEs, presentations" },
schematic: { name: "Schematic", vibe: "Blueprint — grid bg, monospace", bestFor: "Technical docs, infra diagrams" },
};
// ── Main ─────────────────────────────────────────────────────────
async function main() {
const outDir = path.resolve("tmp/org-chart-svg-comparison");
fs.mkdirSync(outDir, { recursive: true });
const sizes = ["sm", "med", "lg"] as const;
const results: string[] = [];
for (const style of ORG_CHART_STYLES) {
for (const size of sizes) {
const svg = renderOrgChartSvg([ORGS[size]], style);
const svgFile = `${style}-${size}.svg`;
fs.writeFileSync(path.join(outDir, svgFile), svg);
results.push(svgFile);
console.log(` ✓ ${svgFile}`);
// Also generate PNG
try {
const png = await renderOrgChartPng([ORGS[size]], style);
const pngFile = `${style}-${size}.png`;
fs.writeFileSync(path.join(outDir, pngFile), png);
results.push(pngFile);
console.log(` ✓ ${pngFile}`);
} catch (e) {
console.log(` ⚠ PNG failed for ${style}-${size}: ${(e as Error).message}`);
}
}
}
// Build comparison HTML
let html = `<!DOCTYPE html>
<html><head>
<meta charset="UTF-8">
<title>Org Chart Style Comparison — Pure SVG (No Playwright)</title>
<style>
body { font-family: 'Inter', system-ui, sans-serif; background: #050505; color: #eee; padding: 40px; }
h1 { font-size: 28px; font-weight: 700; margin-bottom: 8px; letter-spacing: -0.03em; }
p.sub { color: #888; font-size: 14px; margin-bottom: 16px; }
.badge { display: inline-block; background: #1a1a2e; border: 1px solid #333; border-radius: 4px; padding: 4px 10px; font-size: 12px; color: #6366f1; margin-bottom: 32px; }
.style-section { margin-bottom: 60px; }
.style-section h2 { font-size: 20px; font-weight: 600; margin-bottom: 4px; letter-spacing: -0.02em; }
.style-meta { font-size: 13px; color: #666; margin-bottom: 16px; }
.style-meta em { color: #888; font-style: normal; }
.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 16px; }
.grid img, .grid object { width: 100%; border-radius: 8px; border: 1px solid #222; background: #111; }
.label { font-size: 11px; color: #666; text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 6px; font-weight: 500; }
.size-label { font-size: 10px; color: #555; text-align: center; margin-top: 4px; }
.note { background: #111; border: 1px solid #222; border-radius: 6px; padding: 16px 20px; margin-top: 40px; font-size: 13px; color: #999; line-height: 1.6; }
.note h3 { font-size: 14px; color: #ccc; margin-bottom: 8px; }
.note code { background: #1a1a1a; padding: 2px 6px; border-radius: 3px; font-size: 12px; color: #6366f1; }
</style>
</head><body>
<h1>Org Chart Export — Style Comparison</h1>
<p class="sub">5 styles × 3 org sizes. Pure SVG — no Playwright, no Satori, no browser needed.</p>
<div class="badge">Server-side compatible — works on any route</div>
`;
for (const style of ORG_CHART_STYLES) {
const meta = STYLE_META[style];
html += `<div class="style-section">
<h2>${meta.name}</h2>
<div class="style-meta"><em>${meta.vibe}</em> — Best for: ${meta.bestFor}</div>
<div class="label">Small / Medium / Large</div>
<div class="grid">
<div><img src="${style}-sm.png" onerror="this.outerHTML='<object data=\\'${style}-sm.svg\\' type=\\'image/svg+xml\\' style=\\'width:100%;border-radius:8px;border:1px solid #222\\'></object>'" /><div class="size-label">3 agents</div></div>
<div><img src="${style}-med.png" onerror="this.outerHTML='<object data=\\'${style}-med.svg\\' type=\\'image/svg+xml\\' style=\\'width:100%;border-radius:8px;border:1px solid #222\\'></object>'" /><div class="size-label">8 agents</div></div>
<div><img src="${style}-lg.png" onerror="this.outerHTML='<object data=\\'${style}-lg.svg\\' type=\\'image/svg+xml\\' style=\\'width:100%;border-radius:8px;border:1px solid #222\\'></object>'" /><div class="size-label">14 agents</div></div>
</div>
</div>`;
}
html += `
<div class="note">
<h3>Why Pure SVG instead of Satori?</h3>
<p>
<strong>Satori</strong> converts JSX → SVG using Yoga (flexbox). It's great for OG cards but has limitations for org charts:
no <code>::before/::after</code> pseudo-elements, no CSS grid, limited gradient support,
and connector lines between nodes would need post-processing.
</p>
<p>
<strong>Pure SVG rendering</strong> (what we're using here) gives us full control over layout, connectors,
gradients, filters, and patterns — with zero runtime dependencies beyond <code>sharp</code> for PNG.
It runs on any Node.js route, generates in &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);
});
+71
View File
@@ -0,0 +1,71 @@
#!/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
DRY_RUN=false
if [[ "${1:-}" == "--dry" || "${1:-}" == "--dry-run" || "${1:-}" == "-n" ]]; then
DRY_RUN=true
fi
# Collect PIDs of node processes running from any paperclip directory.
# Matches paths like /Users/*/paperclip/... or /Users/*/paperclip-*/...
# Excludes postgres-related processes.
pids=()
lines=()
while IFS= read -r line; do
[[ -z "$line" ]] && continue
# skip postgres processes
[[ "$line" == *postgres* ]] && continue
pid=$(echo "$line" | awk '{print $2}')
pids+=("$pid")
lines+=("$line")
done < <(ps aux | grep -E '/paperclip(-[^/]+)?/' | grep node | grep -v grep || true)
if [[ ${#pids[@]} -eq 0 ]]; then
echo "No Paperclip dev processes found."
exit 0
fi
echo "Found ${#pids[@]} Paperclip dev 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 ""}')
# Shorten the command for readability
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 "$pid" 2>/dev/null && echo " killed $pid" || echo " $pid already gone"
done
# Give processes a moment to exit, then SIGKILL any stragglers
sleep 2
for pid in "${pids[@]}"; do
if kill -0 "$pid" 2>/dev/null; then
echo " $pid still alive, sending SIGKILL..."
kill -9 "$pid" 2>/dev/null || true
fi
done
echo "Done."
@@ -0,0 +1,125 @@
import { eq } from "drizzle-orm";
import { agents, createDb } from "@paperclipai/db";
import { secretService } from "../server/src/services/secrets.js";
const SENSITIVE_ENV_KEY_RE =
/(api[-_]?key|access[-_]?token|auth(?:_?token)?|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)/i;
type EnvBinding =
| string
| { type: "plain"; value: string }
| { type: "secret_ref"; secretId: string; version?: number | "latest" };
function asRecord(value: unknown): Record<string, unknown> | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function toPlainValue(binding: unknown): string | null {
if (typeof binding === "string") return binding;
if (typeof binding !== "object" || binding === null || Array.isArray(binding)) return null;
const rec = binding as Record<string, unknown>;
if (rec.type === "plain" && typeof rec.value === "string") return rec.value;
return null;
}
function secretName(agentId: string, key: string) {
return `agent_${agentId.slice(0, 8)}_${key.toLowerCase()}`;
}
async function main() {
const dbUrl = process.env.DATABASE_URL;
if (!dbUrl) {
console.error("DATABASE_URL is required");
process.exit(1);
}
const apply = process.argv.includes("--apply");
const db = createDb(dbUrl);
const secrets = secretService(db);
const allAgents = await db.select().from(agents);
let changedAgents = 0;
let createdSecrets = 0;
let rotatedSecrets = 0;
for (const agent of allAgents) {
const adapterConfig = asRecord(agent.adapterConfig);
if (!adapterConfig) continue;
const env = asRecord(adapterConfig.env);
if (!env) continue;
let changed = false;
const nextEnv: Record<string, EnvBinding> = { ...(env as Record<string, EnvBinding>) };
for (const [key, rawBinding] of Object.entries(env)) {
if (!SENSITIVE_ENV_KEY_RE.test(key)) continue;
const plain = toPlainValue(rawBinding);
if (plain === null) continue;
if (plain.trim().length === 0) continue;
const name = secretName(agent.id, key);
if (apply) {
const existing = await secrets.getByName(agent.companyId, name);
if (existing) {
await secrets.rotate(
existing.id,
{ value: plain },
{ userId: "migration", agentId: null },
);
rotatedSecrets += 1;
nextEnv[key] = { type: "secret_ref", secretId: existing.id, version: "latest" };
} else {
const created = await secrets.create(
agent.companyId,
{
name,
provider: "local_encrypted",
value: plain,
description: `Migrated from agent ${agent.id} env ${key}`,
},
{ userId: "migration", agentId: null },
);
createdSecrets += 1;
nextEnv[key] = { type: "secret_ref", secretId: created.id, version: "latest" };
}
} else {
nextEnv[key] = {
type: "secret_ref",
secretId: `<would-create:${name}>`,
version: "latest",
};
}
changed = true;
}
if (!changed) continue;
changedAgents += 1;
if (apply) {
await db
.update(agents)
.set({
adapterConfig: {
...adapterConfig,
env: nextEnv,
},
updatedAt: new Date(),
})
.where(eq(agents.id, agent.id));
}
}
if (!apply) {
console.log(`Dry run: ${changedAgents} agents would be updated`);
console.log("Re-run with --apply to persist changes");
process.exit(0);
}
console.log(
`Updated ${changedAgents} agents, created ${createdSecrets} secrets, rotated ${rotatedSecrets} secrets`,
);
process.exit(0);
}
void main();
@@ -0,0 +1,21 @@
#!/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.
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
UI_DIST="$REPO_ROOT/ui/dist"
SERVER_UI_DIST="$REPO_ROOT/server/ui-dist"
echo " -> Building @paperclipai/ui..."
pnpm --dir "$REPO_ROOT" --filter @paperclipai/ui build
if [ ! -f "$UI_DIST/index.html" ]; then
echo "Error: UI build output missing at $UI_DIST/index.html"
exit 1
fi
rm -rf "$SERVER_UI_DIST"
cp -r "$UI_DIST" "$SERVER_UI_DIST"
echo " -> Copied ui/dist to server/ui-dist"
@@ -0,0 +1,37 @@
#!/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}"
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
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 < <(
cd "$base_cwd" &&
find . \
-mindepth 1 \
-maxdepth 3 \
-type d \
-name node_modules \
! -path './.git/*' \
! -path './.paperclip/*' \
| sed 's#^\./##'
)
+306
View File
@@ -0,0 +1,306 @@
#!/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 date = input ? new Date(`${input}T00:00:00Z`) : new Date();
if (Number.isNaN(date.getTime())) {
console.error(`invalid date: ${input}`);
process.exit(1);
}
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 = [];
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 pattern = new RegExp(`^${stable.replace(/\./g, '\\.')}-canary\\.(\\d+)$`);
let max = -1;
for (const packageName of packageNames) {
let versions = [];
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
}
require_clean_worktree() {
if [ -n "$(git -C "$REPO_ROOT" status --porcelain)" ]; then
release_fail "working tree is not clean. Commit, stash, or remove changes before releasing."
fi
}
require_on_master_branch() {
local current_branch
current_branch="$(git_current_branch)"
if [ "$current_branch" != "master" ]; then
release_fail "this release step must run from branch master, but current branch is ${current_branch:-<detached>}."
fi
}
require_npm_publish_auth() {
local dry_run="$1"
if [ "$dry_run" = true ]; then
return
fi
if npm whoami >/dev/null 2>&1; then
release_info " ✓ Logged in to npm as $(npm whoami)"
return
fi
if [ "${GITHUB_ACTIONS:-}" = "true" ]; then
release_info " ✓ npm publish auth will be provided by GitHub Actions trusted publishing"
return
fi
release_fail "npm publish auth is not available. Use 'npm login' locally or run from GitHub Actions with trusted publishing."
}
list_public_package_info() {
node "$REPO_ROOT/scripts/release-package-map.mjs" list
}
set_public_package_version() {
node "$REPO_ROOT/scripts/release-package-map.mjs" set-version "$1"
}
@@ -0,0 +1,168 @@
#!/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 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 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 public 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 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 = sortTopologically(discoverPublicPackages());
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) {
throw new Error("failed to rewrite CLI version string in cli/src/index.ts");
}
writeFileSync(cliEntryPath, nextCliEntry);
}
function listPackages() {
const packages = sortTopologically(discoverPublicPackages());
for (const pkg of packages) {
process.stdout.write(`${pkg.dir}\t${pkg.name}\t${pkg.version}\n`);
}
}
function usage() {
process.stderr.write(
[
"Usage:",
" node scripts/release-package-map.mjs list",
" node scripts/release-package-map.mjs set-version <version>",
"",
].join("\n"),
);
}
const [command, arg] = process.argv.slice(2);
if (command === "list") {
listPackages();
process.exit(0);
}
if (command === "set-version") {
if (!arg) {
usage();
process.exit(1);
}
setVersion(arg);
process.exit(0);
}
usage();
process.exit(1);
+312
View File
@@ -0,0 +1,312 @@
#!/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."
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
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
while IFS= read -r package_name; do
[ -z "$package_name" ] && continue
if npm_package_version_exists "$package_name" "$TARGET_PUBLISH_VERSION"; then
release_fail "npm version ${package_name}@${TARGET_PUBLISH_VERSION} already exists."
fi
done <<< "$(printf '%s\n' "${PUBLIC_PACKAGE_NAMES[@]}")"
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
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
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"
pnpm publish --no-git-checks --tag "$DIST_TAG" --access public
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..."
VERIFY_ATTEMPTS="${NPM_PUBLISH_VERIFY_ATTEMPTS:-12}"
VERIFY_DELAY_SECONDS="${NPM_PUBLISH_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"
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
@@ -0,0 +1,111 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
dry_run=false
version=""
usage() {
cat <<'EOF'
Usage:
./scripts/rollback-latest.sh <stable-version> [--dry-run]
Examples:
./scripts/rollback-latest.sh 2026.318.0
./scripts/rollback-latest.sh 2026.318.0 --dry-run
Notes:
- This repoints the npm dist-tag "latest" for every public package.
- It does not unpublish anything.
EOF
}
while [ $# -gt 0 ]; do
case "$1" in
--dry-run) dry_run=true ;;
-h|--help)
usage
exit 0
;;
*)
if [ -n "$version" ]; then
echo "Error: only one version may be provided." >&2
exit 1
fi
version="$1"
;;
esac
shift
done
if [ -z "$version" ]; then
usage
exit 1
fi
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Error: version must be a stable calendar version like 2026.318.0." >&2
exit 1
fi
if [ "$dry_run" = false ] && ! npm whoami >/dev/null 2>&1; then
echo "Error: npm publish rights are required. Run 'npm login' first." >&2
exit 1
fi
list_public_package_names() {
node - "$REPO_ROOT" <<'NODE'
const fs = require('fs');
const path = require('path');
const root = process.argv[2];
const roots = ['packages', 'server', 'ui', 'cli'];
const seen = new Set();
function walk(relDir) {
const absDir = path.join(root, relDir);
const pkgPath = path.join(absDir, 'package.json');
if (fs.existsSync(pkgPath)) {
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
if (!pkg.private && !seen.has(pkg.name)) {
seen.add(pkg.name);
process.stdout.write(`${pkg.name}\n`);
}
return;
}
if (!fs.existsSync(absDir)) {
return;
}
for (const entry of fs.readdirSync(absDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.git') continue;
walk(path.join(relDir, entry.name));
}
}
for (const rel of roots) {
walk(rel);
}
NODE
}
package_names="$(list_public_package_names)"
if [ -z "$package_names" ]; then
echo "Error: no public packages were found in the workspace." >&2
exit 1
fi
while IFS= read -r package_name; do
[ -z "$package_name" ] && continue
if [ "$dry_run" = true ]; then
echo "[dry-run] npm dist-tag add ${package_name}@${version} latest"
else
npm dist-tag add "${package_name}@${version}" latest
echo "Updated latest -> ${package_name}@${version}"
fi
done <<< "$package_names"
@@ -0,0 +1,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
@@ -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 "$@"
@@ -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
@@ -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"