mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 05:11:49 +01:00
Merge remote-tracking branch 'public-gh/master' into PAP-989-multi-user-implementation-using-plan-from-pap-958
* public-gh/master: (437 commits) test(ui): wait for workspace selector in new issue dialog test Add draft routine defaults and run-time overrides Remove workspace link package preflight hooks Scope workspace link preflight to linked worktrees fix(server): reject non-participant stage mutations test(ui): align inbox badge fixture with dismissal state test(server): align isolated route specs with current behavior fix(ci): run workspace preflight through server toolchain fix(ui): repair issue detail split regressions test(server): isolate route modules in endpoint tests fix(ui): harden issue comment editor sync Disable inbox nesting on mobile Speed up issue detail comments and refreshes Guard issue chat against assistant-ui crashes Add explicit review start action in issue sidebar Improve issue detail load stability Keep interrupted runs stable in issue chat Add breathing room when focusing comment composer Make issue chat composer inline again Prevent g c from leaking into global shortcuts ...
This commit is contained in:
commit
35251727f2
494 files changed
+153449
-3588
No files matched your search
@@ -0,0 +1,93 @@
|
||||
const DEFAULT_CAPTURED_OUTPUT_BYTES = 256 * 1024;
|
||||
const DEFAULT_JSON_RESPONSE_BYTES = 64 * 1024;
|
||||
|
||||
function normalizeByteLimit(maxBytes) {
|
||||
return Math.max(1, Math.trunc(maxBytes));
|
||||
}
|
||||
|
||||
export function createCapturedOutputBuffer(maxBytes = DEFAULT_CAPTURED_OUTPUT_BYTES) {
|
||||
const limit = normalizeByteLimit(maxBytes);
|
||||
const chunks = [];
|
||||
let bufferedBytes = 0;
|
||||
let totalBytes = 0;
|
||||
let truncated = false;
|
||||
|
||||
return {
|
||||
append(chunk) {
|
||||
if (chunk === null || chunk === undefined) return;
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
if (buffer.length === 0) return;
|
||||
|
||||
chunks.push(buffer);
|
||||
bufferedBytes += buffer.length;
|
||||
totalBytes += buffer.length;
|
||||
|
||||
while (bufferedBytes > limit && chunks.length > 0) {
|
||||
const overflow = bufferedBytes - limit;
|
||||
const head = chunks[0];
|
||||
if (head.length <= overflow) {
|
||||
chunks.shift();
|
||||
bufferedBytes -= head.length;
|
||||
truncated = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
chunks[0] = head.subarray(overflow);
|
||||
bufferedBytes -= overflow;
|
||||
truncated = true;
|
||||
}
|
||||
},
|
||||
|
||||
finish() {
|
||||
const body = Buffer.concat(chunks).toString("utf8");
|
||||
if (!truncated) {
|
||||
return {
|
||||
text: body,
|
||||
truncated,
|
||||
totalBytes,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
text: `[output truncated to last ${limit} bytes; total ${totalBytes} bytes]\n${body}`,
|
||||
truncated,
|
||||
totalBytes,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function parseJsonResponseWithLimit(response, maxBytes = DEFAULT_JSON_RESPONSE_BYTES) {
|
||||
const limit = normalizeByteLimit(maxBytes);
|
||||
const contentLength = Number.parseInt(response.headers.get("content-length") ?? "", 10);
|
||||
if (Number.isFinite(contentLength) && contentLength > limit) {
|
||||
throw new Error(`Response exceeds ${limit} bytes`);
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
return JSON.parse("");
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let text = "";
|
||||
let totalBytes = 0;
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
totalBytes += value.byteLength;
|
||||
if (totalBytes > limit) {
|
||||
await reader.cancel("response too large");
|
||||
throw new Error(`Response exceeds ${limit} bytes`);
|
||||
}
|
||||
text += decoder.decode(value, { stream: true });
|
||||
}
|
||||
text += decoder.decode();
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
return JSON.parse(text);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
const DEFAULT_CAPTURED_OUTPUT_BYTES = 256 * 1024;
|
||||
const DEFAULT_JSON_RESPONSE_BYTES = 64 * 1024;
|
||||
|
||||
export type CapturedOutput = {
|
||||
text: string;
|
||||
truncated: boolean;
|
||||
totalBytes: number;
|
||||
};
|
||||
|
||||
function normalizeByteLimit(maxBytes: number) {
|
||||
return Math.max(1, Math.trunc(maxBytes));
|
||||
}
|
||||
|
||||
export function createCapturedOutputBuffer(maxBytes = DEFAULT_CAPTURED_OUTPUT_BYTES) {
|
||||
const limit = normalizeByteLimit(maxBytes);
|
||||
const chunks: Buffer[] = [];
|
||||
let bufferedBytes = 0;
|
||||
let totalBytes = 0;
|
||||
let truncated = false;
|
||||
|
||||
return {
|
||||
append(chunk: Buffer | string | null | undefined) {
|
||||
if (chunk === null || chunk === undefined) return;
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
if (buffer.length === 0) return;
|
||||
|
||||
chunks.push(buffer);
|
||||
bufferedBytes += buffer.length;
|
||||
totalBytes += buffer.length;
|
||||
|
||||
while (bufferedBytes > limit && chunks.length > 0) {
|
||||
const overflow = bufferedBytes - limit;
|
||||
const head = chunks[0]!;
|
||||
if (head.length <= overflow) {
|
||||
chunks.shift();
|
||||
bufferedBytes -= head.length;
|
||||
truncated = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
chunks[0] = head.subarray(overflow);
|
||||
bufferedBytes -= overflow;
|
||||
truncated = true;
|
||||
}
|
||||
},
|
||||
|
||||
finish(): CapturedOutput {
|
||||
const body = Buffer.concat(chunks).toString("utf8");
|
||||
if (!truncated) {
|
||||
return {
|
||||
text: body,
|
||||
truncated,
|
||||
totalBytes,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
text: `[output truncated to last ${limit} bytes; total ${totalBytes} bytes]\n${body}`,
|
||||
truncated,
|
||||
totalBytes,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function parseJsonResponseWithLimit<T>(
|
||||
response: Response,
|
||||
maxBytes = DEFAULT_JSON_RESPONSE_BYTES,
|
||||
): Promise<T> {
|
||||
const limit = normalizeByteLimit(maxBytes);
|
||||
const contentLength = Number.parseInt(response.headers.get("content-length") ?? "", 10);
|
||||
if (Number.isFinite(contentLength) && contentLength > limit) {
|
||||
throw new Error(`Response exceeds ${limit} bytes`);
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error("Response has no body");
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let text = "";
|
||||
let totalBytes = 0;
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
totalBytes += value.byteLength;
|
||||
if (totalBytes > limit) {
|
||||
await reader.cancel("response too large");
|
||||
throw new Error(`Response exceeds ${limit} bytes`);
|
||||
}
|
||||
text += decoder.decode(value, { stream: true });
|
||||
}
|
||||
text += decoder.decode();
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
return JSON.parse(text) as T;
|
||||
}
|
||||
+11
-7
@@ -5,6 +5,7 @@ import path from "node:path";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import { stdin, stdout } from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createCapturedOutputBuffer, parseJsonResponseWithLimit } from "./dev-runner-output.mjs";
|
||||
import { shouldTrackDevServerPath } from "./dev-runner-paths.mjs";
|
||||
|
||||
const mode = process.argv[2] === "watch" ? "watch" : "dev";
|
||||
@@ -250,30 +251,33 @@ async function runPnpm(args, options = {}) {
|
||||
const spawned = spawn(pnpmBin, args, {
|
||||
stdio: options.stdio ?? ["ignore", "pipe", "pipe"],
|
||||
env: options.env ?? process.env,
|
||||
cwd: options.cwd,
|
||||
shell: process.platform === "win32",
|
||||
});
|
||||
|
||||
let stdoutBuffer = "";
|
||||
let stderrBuffer = "";
|
||||
const stdoutBuffer = createCapturedOutputBuffer();
|
||||
const stderrBuffer = createCapturedOutputBuffer();
|
||||
|
||||
if (spawned.stdout) {
|
||||
spawned.stdout.on("data", (chunk) => {
|
||||
stdoutBuffer += String(chunk);
|
||||
stdoutBuffer.append(chunk);
|
||||
});
|
||||
}
|
||||
if (spawned.stderr) {
|
||||
spawned.stderr.on("data", (chunk) => {
|
||||
stderrBuffer += String(chunk);
|
||||
stderrBuffer.append(chunk);
|
||||
});
|
||||
}
|
||||
|
||||
spawned.on("error", reject);
|
||||
spawned.on("exit", (code, signal) => {
|
||||
const stdout = stdoutBuffer.finish();
|
||||
const stderr = stderrBuffer.finish();
|
||||
resolve({
|
||||
code: code ?? 0,
|
||||
signal,
|
||||
stdout: stdoutBuffer,
|
||||
stderr: stderrBuffer,
|
||||
stdout: stdout.text,
|
||||
stderr: stderr.text,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -426,7 +430,7 @@ async function getDevHealthPayload() {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Health request failed (${response.status})`);
|
||||
}
|
||||
return await response.json();
|
||||
return await parseJsonResponseWithLimit(response);
|
||||
}
|
||||
|
||||
async function waitForChildExit() {
|
||||
|
||||
Executable
+46
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env bash
|
||||
# Verify the Docker image builds successfully.
|
||||
# Skips gracefully when docker/podman is not available.
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
|
||||
# Detect container runtime
|
||||
if command -v docker >/dev/null 2>&1; then
|
||||
RUNTIME=docker
|
||||
elif command -v podman >/dev/null 2>&1; then
|
||||
RUNTIME=podman
|
||||
else
|
||||
echo "SKIP: neither docker nor podman found — skipping build test"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Verify the daemon is reachable (docker may be installed but not running)
|
||||
if ! "$RUNTIME" info >/dev/null 2>&1; then
|
||||
echo "SKIP: $RUNTIME is installed but not running — skipping build test"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
IMAGE_TAG="paperclip-build-test:$$"
|
||||
trap '"$RUNTIME" rmi "$IMAGE_TAG" >/dev/null 2>&1 || true' EXIT
|
||||
|
||||
echo "==> Testing Docker build with $RUNTIME"
|
||||
"$RUNTIME" build \
|
||||
-f "$REPO_ROOT/Dockerfile" \
|
||||
-t "$IMAGE_TAG" \
|
||||
--target production \
|
||||
"$REPO_ROOT"
|
||||
|
||||
echo "==> Verifying key binaries in image"
|
||||
"$RUNTIME" run --rm "$IMAGE_TAG" sh -c '
|
||||
set -e
|
||||
node --version
|
||||
git --version
|
||||
gh --version
|
||||
rg --version
|
||||
python3 --version
|
||||
curl --version | head -1
|
||||
claude --version 2>/dev/null || echo "claude CLI not found (OK in minimal builds)"
|
||||
'
|
||||
|
||||
echo "PASS: Docker build test succeeded"
|
||||
@@ -0,0 +1,29 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Capture runtime UID/GID from environment variables, defaulting to 1000
|
||||
PUID=${USER_UID:-1000}
|
||||
PGID=${USER_GID:-1000}
|
||||
|
||||
# Adjust the node user's UID/GID if they differ from the runtime request
|
||||
# and fix volume ownership only when a remap is needed
|
||||
changed=0
|
||||
|
||||
if [ "$(id -u node)" -ne "$PUID" ]; then
|
||||
echo "Updating node UID to $PUID"
|
||||
usermod -o -u "$PUID" node
|
||||
changed=1
|
||||
fi
|
||||
|
||||
if [ "$(id -g node)" -ne "$PGID" ]; then
|
||||
echo "Updating node GID to $PGID"
|
||||
groupmod -o -g "$PGID" node
|
||||
usermod -g "$PGID" node
|
||||
changed=1
|
||||
fi
|
||||
|
||||
if [ "$changed" = "1" ]; then
|
||||
chown -R node:node /paperclip
|
||||
fi
|
||||
|
||||
exec gosu node "$@"
|
||||
@@ -242,7 +242,7 @@ 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" \
|
||||
-f "$REPO_ROOT/docker/Dockerfile.onboard-smoke" \
|
||||
-t "$IMAGE_NAME" \
|
||||
"$REPO_ROOT"
|
||||
|
||||
|
||||
Executable
+65
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Kill all "Google Chrome for Testing" processes (agent headless browsers).
|
||||
#
|
||||
# Usage:
|
||||
# scripts/kill-agent-browsers.sh # kill all
|
||||
# scripts/kill-agent-browsers.sh --dry # preview what would be killed
|
||||
#
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
DRY_RUN=false
|
||||
if [[ "${1:-}" == "--dry" || "${1:-}" == "--dry-run" || "${1:-}" == "-n" ]]; then
|
||||
DRY_RUN=true
|
||||
fi
|
||||
|
||||
pids=()
|
||||
lines=()
|
||||
|
||||
while IFS= read -r line; do
|
||||
[[ -z "$line" ]] && continue
|
||||
pid=$(echo "$line" | awk '{print $2}')
|
||||
pids+=("$pid")
|
||||
lines+=("$line")
|
||||
done < <(ps aux | grep 'Google Chrome for Testing' | grep -v grep || true)
|
||||
|
||||
if [[ ${#pids[@]} -eq 0 ]]; then
|
||||
echo "No Google Chrome for Testing processes found."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Found ${#pids[@]} Google Chrome for Testing process(es):"
|
||||
echo ""
|
||||
|
||||
for i in "${!pids[@]}"; do
|
||||
line="${lines[$i]}"
|
||||
pid=$(echo "$line" | awk '{print $2}')
|
||||
start=$(echo "$line" | awk '{print $9}')
|
||||
cmd=$(echo "$line" | awk '{for(i=11;i<=NF;i++) printf "%s ", $i; print ""}')
|
||||
cmd=$(echo "$cmd" | sed "s|$HOME/||g")
|
||||
printf " PID %-7s started %-10s %s\n" "$pid" "$start" "$cmd"
|
||||
done
|
||||
|
||||
echo ""
|
||||
|
||||
if [[ "$DRY_RUN" == true ]]; then
|
||||
echo "Dry run — re-run without --dry to kill these processes."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Sending SIGTERM..."
|
||||
for pid in "${pids[@]}"; do
|
||||
kill -TERM "$pid" 2>/dev/null && echo " signaled $pid" || echo " $pid already gone"
|
||||
done
|
||||
|
||||
sleep 2
|
||||
|
||||
for pid in "${pids[@]}"; do
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
echo " $pid still alive, sending SIGKILL..."
|
||||
kill -KILL "$pid" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Done."
|
||||
@@ -62,6 +62,19 @@ run_isolated_worktree_init() {
|
||||
return 127
|
||||
}
|
||||
|
||||
run_isolated_worktree_init() {
|
||||
run_paperclipai_command \
|
||||
worktree \
|
||||
init \
|
||||
--force \
|
||||
--seed-mode \
|
||||
minimal \
|
||||
--name \
|
||||
"$worktree_name" \
|
||||
--from-config \
|
||||
"$source_config_path"
|
||||
}
|
||||
|
||||
write_fallback_worktree_config() {
|
||||
WORKTREE_NAME="$worktree_name" \
|
||||
BASE_CWD="$base_cwd" \
|
||||
@@ -324,6 +337,95 @@ else
|
||||
write_fallback_worktree_config
|
||||
fi
|
||||
|
||||
disable_seeded_routines() {
|
||||
local company_id="${PAPERCLIP_COMPANY_ID:-}"
|
||||
if [[ -z "$company_id" ]]; then
|
||||
echo "PAPERCLIP_COMPANY_ID not set; skipping routine disable post-step." >&2
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! run_paperclipai_command routines disable-all --config "$worktree_config_path" --company-id "$company_id"; then
|
||||
echo "paperclipai CLI not available in this workspace; skipping routine disable post-step." >&2
|
||||
fi
|
||||
}
|
||||
|
||||
disable_seeded_routines
|
||||
|
||||
list_base_node_modules_paths() {
|
||||
cd "$base_cwd" &&
|
||||
find . \
|
||||
-mindepth 1 \
|
||||
-maxdepth 4 \
|
||||
-type d \
|
||||
-name node_modules \
|
||||
! -path './.git/*' \
|
||||
! -path './.paperclip/*' \
|
||||
| sed 's#^\./##'
|
||||
}
|
||||
if [[ -f "$worktree_cwd/package.json" && -f "$worktree_cwd/pnpm-lock.yaml" ]]; then
|
||||
needs_install=0
|
||||
|
||||
while IFS= read -r relative_path; do
|
||||
[[ -n "$relative_path" ]] || continue
|
||||
target_path="$worktree_cwd/$relative_path"
|
||||
|
||||
if [[ -L "$target_path" || ! -e "$target_path" ]]; then
|
||||
needs_install=1
|
||||
break
|
||||
fi
|
||||
done < <(list_base_node_modules_paths)
|
||||
|
||||
if [[ "$needs_install" -eq 1 ]]; then
|
||||
backup_suffix=".paperclip-backup-${BASHPID:-$$}"
|
||||
moved_symlink_paths=()
|
||||
|
||||
while IFS= read -r relative_path; do
|
||||
[[ -n "$relative_path" ]] || continue
|
||||
target_path="$worktree_cwd/$relative_path"
|
||||
if [[ -L "$target_path" ]]; then
|
||||
backup_path="${target_path}${backup_suffix}"
|
||||
rm -rf "$backup_path"
|
||||
mv "$target_path" "$backup_path"
|
||||
moved_symlink_paths+=("$relative_path")
|
||||
fi
|
||||
done < <(list_base_node_modules_paths)
|
||||
|
||||
restore_moved_symlinks() {
|
||||
local relative_path target_path backup_path
|
||||
[[ ${#moved_symlink_paths[@]} -gt 0 ]] || return 0
|
||||
for relative_path in "${moved_symlink_paths[@]}"; do
|
||||
target_path="$worktree_cwd/$relative_path"
|
||||
backup_path="${target_path}${backup_suffix}"
|
||||
[[ -L "$backup_path" ]] || continue
|
||||
rm -rf "$target_path"
|
||||
mv "$backup_path" "$target_path"
|
||||
done
|
||||
}
|
||||
|
||||
cleanup_moved_symlinks() {
|
||||
local relative_path target_path backup_path
|
||||
[[ ${#moved_symlink_paths[@]} -gt 0 ]] || return 0
|
||||
for relative_path in "${moved_symlink_paths[@]}"; do
|
||||
target_path="$worktree_cwd/$relative_path"
|
||||
backup_path="${target_path}${backup_suffix}"
|
||||
[[ -L "$backup_path" ]] && rm "$backup_path"
|
||||
done
|
||||
}
|
||||
|
||||
(
|
||||
cd "$worktree_cwd"
|
||||
pnpm install --frozen-lockfile
|
||||
) || {
|
||||
restore_moved_symlinks
|
||||
exit 1
|
||||
}
|
||||
|
||||
cleanup_moved_symlinks
|
||||
fi
|
||||
|
||||
exit 0
|
||||
fi
|
||||
|
||||
while IFS= read -r relative_path; do
|
||||
[[ -n "$relative_path" ]] || continue
|
||||
source_path="$base_cwd/$relative_path"
|
||||
@@ -335,13 +437,5 @@ while IFS= read -r relative_path; do
|
||||
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#^\./##'
|
||||
list_base_node_modules_paths
|
||||
)
|
||||
@@ -123,11 +123,14 @@ function setVersion(version) {
|
||||
`.version("${version}")`,
|
||||
);
|
||||
|
||||
if (cliEntry === nextCliEntry) {
|
||||
throw new Error("failed to rewrite CLI version string in cli/src/index.ts");
|
||||
if (cliEntry !== nextCliEntry) {
|
||||
writeFileSync(cliEntryPath, nextCliEntry);
|
||||
return;
|
||||
}
|
||||
|
||||
writeFileSync(cliEntryPath, nextCliEntry);
|
||||
if (!cliEntry.includes(".version(cliVersion)")) {
|
||||
throw new Error("failed to rewrite CLI version string in cli/src/index.ts");
|
||||
}
|
||||
}
|
||||
|
||||
function listPackages() {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Screenshot utility for Paperclip UI.
|
||||
*
|
||||
* Reads the board token from ~/.paperclip/auth.json and injects it as a
|
||||
* Bearer header so Playwright can access authenticated pages.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/screenshot.cjs <url-or-path> [output.png] [--width 1280] [--height 800] [--wait 2000]
|
||||
*
|
||||
* Examples:
|
||||
* node scripts/screenshot.cjs /PAPA/agents/cto/instructions /tmp/shot.png
|
||||
* node scripts/screenshot.cjs http://localhost:5173/PAPA/agents/cto/instructions
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const os = require("os");
|
||||
|
||||
// --- CLI args -----------------------------------------------------------
|
||||
const args = process.argv.slice(2);
|
||||
function flag(name, fallback) {
|
||||
const i = args.indexOf(`--${name}`);
|
||||
if (i === -1) return fallback;
|
||||
const val = args.splice(i, 2)[1];
|
||||
return Number.isNaN(Number(val)) ? fallback : Number(val);
|
||||
}
|
||||
const width = flag("width", 1280);
|
||||
const height = flag("height", 800);
|
||||
const waitMs = flag("wait", 2000);
|
||||
|
||||
const rawUrl = args[0];
|
||||
const outPath = args[1] || "/tmp/paperclip-screenshot.png";
|
||||
|
||||
if (!rawUrl) {
|
||||
console.error("Usage: node scripts/screenshot.cjs <url-or-path> [output.png]");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// --- Auth ----------------------------------------------------------------
|
||||
function loadBoardToken() {
|
||||
const authPath = path.resolve(os.homedir(), ".paperclip/auth.json");
|
||||
try {
|
||||
const auth = JSON.parse(fs.readFileSync(authPath, "utf-8"));
|
||||
const creds = auth.credentials || {};
|
||||
const entry = Object.values(creds)[0];
|
||||
if (entry && entry.token && entry.apiBase) return { token: entry.token, apiBase: entry.apiBase };
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const cred = loadBoardToken();
|
||||
if (!cred) {
|
||||
console.error("No board token found in ~/.paperclip/auth.json");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Resolve URL — if it starts with / treat as path relative to apiBase
|
||||
const url = rawUrl.startsWith("http") ? rawUrl : `${cred.apiBase}${rawUrl}`;
|
||||
|
||||
// Validate URL before launching browser
|
||||
const origin = new URL(url).origin;
|
||||
|
||||
// --- Screenshot ----------------------------------------------------------
|
||||
(async () => {
|
||||
const { chromium } = require("playwright");
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width, height },
|
||||
});
|
||||
|
||||
const page = await context.newPage();
|
||||
// Scope the auth header to the Paperclip origin only
|
||||
await page.route(`${origin}/**`, async (route) => {
|
||||
await route.continue({
|
||||
headers: { ...route.request().headers(), Authorization: `Bearer ${cred.token}` },
|
||||
});
|
||||
});
|
||||
await page.goto(url, { waitUntil: "networkidle", timeout: 20000 });
|
||||
await page.waitForTimeout(waitMs);
|
||||
await page.screenshot({ path: outPath, fullPage: false });
|
||||
console.log(`Saved: ${outPath}`);
|
||||
} catch (err) {
|
||||
console.error(`Screenshot failed: ${err.message}`);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
})();
|
||||
Reference in new issue
Block a user