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:
1 parent
a5b8fcf9d3
commit
aa1a78d7d9
1081 files changed
+445341
No files matched your search
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Backup the configured Paperclip database to the configured backup directory
|
||||
# (default: ~/.paperclip/instances/<instance-id>/data/backups)
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/backup-db.sh
|
||||
# pnpm db:backup
|
||||
#
|
||||
# The embedded postgres must be running (start with: pnpm dev)
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
cd "$PROJECT_ROOT"
|
||||
exec pnpm paperclipai db:backup "$@"
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
@@ -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 <10ms, and produces identical output every time.
|
||||
</p>
|
||||
<p>
|
||||
Routes: <code>GET /api/companies/:id/org.svg?style=monochrome</code> and <code>GET /api/companies/:id/org.png?style=circuit</code>
|
||||
</p>
|
||||
</div>
|
||||
</body></html>`;
|
||||
|
||||
fs.writeFileSync(path.join(outDir, "comparison.html"), html);
|
||||
console.log(`\n✓ All done! ${results.length} files generated.`);
|
||||
console.log(` Open: tmp/org-chart-svg-comparison/comparison.html`);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,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#^\./##'
|
||||
)
|
||||
@@ -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);
|
||||
@@ -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"
|
||||
Reference in new issue
Block a user