Compare commits

..

63 Commits

Author SHA1 Message Date
Dotta
56aeddfa1c Fix dev migration prompt and embedded db:migrate 2026-03-10 15:31:05 -05:00
Dotta
42c8aca5c0 Merge branch 'master' of github.com-dotta:paperclipai/paperclip
* 'master' of github.com-dotta:paperclipai/paperclip:
  Fix approvals service idempotency test
  Add workspace strategy plan doc
  Copy git hooks during worktree init
2026-03-10 15:09:00 -05:00
Dotta
00495d3d89 Merge pull request #534 from paperclipai/fix/approval-service-idempotency
Fix approvals service idempotency test
2026-03-10 15:07:45 -05:00
Dotta
a613435249 Fix approvals service idempotency test 2026-03-10 15:05:19 -05:00
Dotta
576b408682 Merge pull request #532 from paperclipai/feature/worktree-init-copy-hooks
Copy git hooks during worktree init
2026-03-10 14:58:18 -05:00
Dotta
193b7c0570 Add workspace strategy plan doc 2026-03-10 14:57:18 -05:00
Dotta
93a8b55ff8 Copy git hooks during worktree init 2026-03-10 14:55:35 -05:00
Dotta
24a553c255 doc for workspace strategy 2026-03-10 14:52:43 -05:00
Dotta
2332a79e0b Merge branch 'master' of github.com-dotta:paperclipai/paperclip
* 'master' of github.com-dotta:paperclipai/paperclip:
  updating paths
  Rebind seeded project workspaces to the current worktree
  Add command-based worktree provisioning
  Refine project and agent configuration UI
  Add configuration tabs to project and agent pages
  Add project-first execution workspace policies
  Add worktree-aware workspace runtime support
2026-03-10 14:47:50 -05:00
Dotta
65af1d77a4 Merge pull request #530 from paperclipai/feature/workspace-runtime-support
Add issue worktree runtime support
2026-03-10 14:46:29 -05:00
Dotta
b0b7ec779a updating paths 2026-03-10 14:43:34 -05:00
Dotta
859c82aa12 Merge remote-tracking branch 'public-gh/master' into feature/workspace-runtime-support
* public-gh/master:
  Rebind seeded project workspaces to the current worktree
  Copy seeded secrets key into worktree instances
  server: make approval retries idempotent (#499)
  fix: address review feedback — stale error message and * wildcard
  Update server/src/routes/assets.ts
  feat: make attachment content types configurable via env var
  fix: wire parentId query filter into issues list endpoint
2026-03-10 14:19:11 -05:00
Dotta
6fd29e05ad Merge pull request #522 from paperclipai/feature/worktree-rebind-seeded-workspaces
Rebind seeded project workspaces to the current worktree
2026-03-10 13:54:50 -05:00
Dotta
12216b5cc6 Rebind seeded project workspaces to the current worktree 2026-03-10 13:50:29 -05:00
Dotta
0c525febf2 Merge remote-tracking branch 'public-gh/master'
* public-gh/master:
  Copy seeded secrets key into worktree instances
  server: make approval retries idempotent (#499)
  Fix doctor summary after repairs
  Fix worktree minimal clone startup
  Add minimal worktree seed mode
  Add worktree init CLI for isolated development instances
  fix: address review feedback — stale error message and * wildcard
  Update server/src/routes/assets.ts
  feat: make attachment content types configurable via env var
  fix: wire parentId query filter into issues list endpoint
  Apply suggestions from code review
  fix(adapter-utils): strip Claude Code env vars from child processes
2026-03-10 13:41:47 -05:00
Dotta
b0fe48b730 Revert "ui: move settings to footer icon beside theme toggle"
This reverts commit f3a9b6de21.
2026-03-10 13:41:37 -05:00
Dotta
f3a9b6de21 ui: move settings to footer icon beside theme toggle 2026-03-10 13:23:35 -05:00
Dotta
31561724f7 Merge pull request #491 from lazmo88/fix/parentid-filter-issues-list
fix: wire parentId query filter into issues list endpoint
2026-03-10 13:07:20 -05:00
Dotta
c363428966 Merge pull request #517 from paperclipai/feature/worktree-seed-secrets-key
Copy seeded secrets key into worktree instances
2026-03-10 12:59:54 -05:00
Dotta
f783f66866 Merge pull request #495 from subhendukundu/feat/configurable-attachment-types
feat: make attachment content types configurable via env var
2026-03-10 12:58:15 -05:00
Dotta
deec68ab16 Copy seeded secrets key into worktree instances 2026-03-10 12:57:53 -05:00
Dotta
6733a6cd7e Merge pull request #502 from davidahmann/codex/issue-499-approval-idempotency
Make approval resolution retries idempotent
2026-03-10 12:56:30 -05:00
Dotta
dfbb4f1ccb Add command-based worktree provisioning 2026-03-10 12:42:36 -05:00
David Ahmann
9c68c1b80b server: make approval retries idempotent (#499) 2026-03-10 12:00:29 -04:00
Dotta
e94ce47ba5 Refine project and agent configuration UI 2026-03-10 10:58:43 -05:00
Dotta
6186eba098 Add configuration tabs to project and agent pages 2026-03-10 10:58:43 -05:00
Dotta
b83a87f42f Add project-first execution workspace policies 2026-03-10 10:58:43 -05:00
Dotta
3120c72372 Add worktree-aware workspace runtime support 2026-03-10 10:58:38 -05:00
Dotta
7934952a77 Merge pull request #496 from paperclipai/feature/worktree-development-tools
feat(cli): add isolated worktree-local Paperclip instance tools
2026-03-10 10:56:36 -05:00
Dotta
d9574fea71 Fix doctor summary after repairs 2026-03-10 10:13:05 -05:00
Dotta
83738b45cd Fix worktree minimal clone startup 2026-03-10 10:13:05 -05:00
Dotta
4a67db6a4d Add minimal worktree seed mode 2026-03-10 10:13:05 -05:00
Dotta
0704854926 Add worktree init CLI for isolated development instances 2026-03-10 10:13:05 -05:00
Subhendu Kundu
1959badde7 fix: address review feedback — stale error message and * wildcard
- assets.ts: change "Image exceeds" to "File exceeds" in size-limit error
- attachment-types.ts: handle plain "*" as allow-all wildcard pattern
- Add test for "*" wildcard (12 tests total)
2026-03-10 20:01:08 +05:30
Subhendu Kundu
3ff07c23d2 Update server/src/routes/assets.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-10 19:54:42 +05:30
Subhendu Kundu
dec02225f1 feat: make attachment content types configurable via env var
Add PAPERCLIP_ALLOWED_ATTACHMENT_TYPES env var to configure allowed
MIME types for issue attachments and asset uploads. Supports exact
types (application/pdf) and wildcard patterns (image/*, text/*).

Falls back to the existing image-only defaults when the env var is
unset, preserving backward compatibility.

- Extract shared module `attachment-types.ts` with `isAllowedContentType()`
  and `matchesContentType()` (pure, testable)
- Update `routes/issues.ts` and `routes/assets.ts` to use shared module
- Add unit tests for parsing and wildcard matching

Closes #487
2026-03-10 19:40:22 +05:30
Claude
f6f5fee200 fix: wire parentId query filter into issues list endpoint
The parentId parameter on GET /api/companies/:companyId/issues was
silently ignored — the filter was never extracted from the query string,
never passed to the service layer, and the IssueFilters type did not
include it. All other filters (status, assigneeAgentId, projectId, etc.)
worked correctly.

This caused subtask lookups to return every issue in the company instead
of only children of the specified parent.

Changes:
- Add parentId to IssueFilters interface
- Add eq(issues.parentId, filters.parentId) condition in list()
- Extract parentId from req.query in the route handler

Fixes: LAS-101
2026-03-10 15:54:31 +02:00
Dotta
49b9511889 Merge pull request #485 from jknair/fix/strip-claudecode-env-from-child-processes
fix(adapter-utils): strip Claude Code env vars from child processes
2026-03-10 07:25:30 -05:00
Dotta
1a53567cb6 Apply suggestions from code review
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-10 07:24:48 -05:00
Jayakrishnan
9248881d42 fix(adapter-utils): strip Claude Code env vars from child processes
When the Paperclip server is started from within a Claude Code session
(e.g. `npx paperclipai run` in a Claude Code terminal), the `CLAUDECODE`
and related env vars (`CLAUDE_CODE_ENTRYPOINT`, `CLAUDE_CODE_SESSION`,
`CLAUDE_CODE_PARENT_SESSION`) leak into `process.env`. Since
`runChildProcess()` spreads `process.env` into the child environment,
every spawned `claude` CLI process inherits these vars and immediately
exits with: "Claude Code cannot be launched inside another Claude Code
session."

This is particularly disruptive for the `claude-local` adapter, where
every agent run spawns a `claude` child process. A single contaminated
server start (or cron job that inherits the env) silently breaks all
agent executions until the server is restarted in a clean environment.

The fix deletes the four known Claude Code nesting-guard env vars from
the merged environment before passing it to `spawn()`.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 12:01:46 +00:00
Dotta
ef978dd601 Merge pull request #446 from paperclipai/codex/pr-report-skill
feat: add pr-report skill
2026-03-09 17:05:35 -05:00
Dotta
fbf9d5714f feat: add pr-report skill 2026-03-09 17:01:45 -05:00
Dotta
8ac064499f Merge pull request #445 from paperclipai/release/0.3.0
Release/0.3.0
2026-03-09 16:45:02 -05:00
Dotta
cbbf695c35 release files 2026-03-09 16:43:53 -05:00
Dotta
7e8908afa2 chore: release v0.3.0 2026-03-09 16:31:12 -05:00
Dotta
58d4d04e99 Merge pull request #444 from paperclipai/release/0.3.0
Release/0.3.0
2026-03-09 16:20:22 -05:00
Dotta
c672b71f7f Refresh bootstrap gate while setup is pending 2026-03-09 16:13:15 -05:00
Dotta
01c5a6f198 Unblock canary onboard smoke bootstrap 2026-03-09 16:06:16 -05:00
Dotta
64f5c3f837 Fix authenticated smoke bootstrap flow 2026-03-09 15:30:08 -05:00
Dotta
c62266aa6a tweaks to docker smoke 2026-03-09 14:41:00 -05:00
Dotta
5dd1e6335a Fix root TypeScript solution config 2026-03-09 14:09:30 -05:00
Dotta
469bfe3953 chore: add release train workflow 2026-03-09 13:55:30 -05:00
Dotta
d20341c797 Merge pull request #413 from online5880/fix/windows-command-compat
fix: support Windows command wrappers for local adapters
2026-03-09 12:50:20 -05:00
online5880
756ddb6cf7 fix: remove lockfile changes from PR 2026-03-10 02:34:52 +09:00
Dotta
200dd66f63 Merge pull request #400 from AiMagic5000/fix/docker-non-root-node-user
fix(docker): run production server as non-root node user
2026-03-09 12:18:20 -05:00
Dotta
9859bac440 Merge pull request #423 from RememberV/fix/onboarding-navigates-to-dashboard
fix: navigate to dashboard after onboarding, not first issue
2026-03-09 12:14:58 -05:00
online5880
8d6b20b47b Merge branch 'master' into fix/windows-command-compat 2026-03-10 02:05:41 +09:00
online5880
a418106005 fix: restore cross-env in server dev watch 2026-03-10 01:43:45 +09:00
Dotta
d9ba4790e9 Merge branch 'master' into fix/windows-command-compat 2026-03-09 11:25:18 -05:00
RememberV
af0e05f38c fix: onboarding wizard navigates to dashboard instead of first issue
After onboarding, the wizard navigated to the newly created issue
(e.g. /JAR/issues/JAR-1). useCompanyPageMemory then saved this path,
causing every subsequent company switch to land on that stale issue
instead of the dashboard.

Remove the issue-specific navigation branch so handleLaunch always
falls through to the dashboard route.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 15:35:40 +00:00
online5880
f4a9788f2d fix: tighten Windows adapter command handling 2026-03-09 22:08:50 +09:00
online5880
d7b98a72b4 fix: support Windows command wrappers for local adapters 2026-03-09 21:52:06 +09:00
AiMagic5000
57406dbc90 fix(docker): run production server as non-root node user
Switch the production stage to the built-in node user from
node:lts-trixie-slim, fixing two runtime failures:

1. Claude CLI rejects --dangerously-skip-permissions when the
   process UID is 0, making the claude-local adapter unusable.
2. The server crashed at startup (EACCES) because /paperclip was
   root-owned and the process could not write logs or instance data.

Changes vs the naive fix:
- Use COPY --chown=node:node instead of a separate RUN chown -R,
  avoiding a duplicate image layer that would double the size of
  the /app tree in the final image.
- Consolidate mkdir /paperclip + chown into the same RUN layer as
  the npm global install (already runs as root) to keep layer count
  minimal.
- Add USER node before CMD so the process runs unprivileged.

The VOLUME declaration comes after chown so freshly-mounted
anonymous volumes inherit the correct node:node ownership.

Fixes #344
2026-03-08 13:47:59 -07:00
94 changed files with 4495 additions and 941 deletions

View File

@@ -1,5 +0,0 @@
---
"@paperclipai/shared": minor
---
Add support for Pi local adapter in constants and onboarding UI.

View File

@@ -32,7 +32,7 @@ concurrency:
jobs:
verify:
if: github.ref == 'refs/heads/master'
if: startsWith(github.ref, 'refs/heads/release/')
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
@@ -68,7 +68,7 @@ jobs:
run: pnpm build
publish:
if: github.ref == 'refs/heads/master'
if: startsWith(github.ref, 'refs/heads/release/')
needs: verify
runs-on: ubuntu-latest
timeout-minutes: 45
@@ -115,9 +115,9 @@ jobs:
fi
./scripts/release.sh "${args[@]}"
- name: Push stable release commit and tag
- name: Push stable release branch commit and tag
if: inputs.channel == 'stable' && !inputs.dry_run
run: git push origin HEAD:master --follow-tags
run: git push origin "HEAD:${GITHUB_REF_NAME}" --follow-tags
- name: Create GitHub Release
if: inputs.channel == 'stable' && !inputs.dry_run

View File

@@ -32,8 +32,10 @@ RUN test -f server/dist/index.js || (echo "ERROR: server build output missing" &
FROM base AS production
WORKDIR /app
COPY --from=build /app /app
RUN npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/codex@latest opencode-ai
COPY --chown=node:node --from=build /app /app
RUN npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/codex@latest opencode-ai \
&& mkdir -p /paperclip \
&& chown node:node /paperclip
ENV NODE_ENV=production \
HOME=/paperclip \
@@ -49,4 +51,5 @@ ENV NODE_ENV=production \
VOLUME ["/paperclip"]
EXPOSE 3100
USER node
CMD ["node", "--import", "./server/node_modules/tsx/dist/loader.mjs", "server/dist/index.js"]

View File

@@ -1,5 +1,26 @@
# paperclipai
## 0.3.0
### Minor Changes
- Stable release preparation for 0.3.0
### Patch Changes
- Updated dependencies [6077ae6]
- Updated dependencies
- @paperclipai/shared@0.3.0
- @paperclipai/adapter-utils@0.3.0
- @paperclipai/adapter-claude-local@0.3.0
- @paperclipai/adapter-codex-local@0.3.0
- @paperclipai/adapter-cursor-local@0.3.0
- @paperclipai/adapter-openclaw-gateway@0.3.0
- @paperclipai/adapter-opencode-local@0.3.0
- @paperclipai/adapter-pi-local@0.3.0
- @paperclipai/db@0.3.0
- @paperclipai/server@0.3.0
## 0.2.7
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "paperclipai",
"version": "0.2.7",
"version": "0.3.0",
"description": "Paperclip CLI — orchestrate AI agent teams to run a business",
"type": "module",
"bin": {

View File

@@ -1,5 +1,9 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { describe, expect, it } from "vitest";
import { copyGitHooksToWorktreeGitDir, copySeededSecretsKey, rebindWorkspaceCwd } from "../commands/worktree.js";
import {
buildWorktreeConfig,
buildWorktreeEnvEntries,
@@ -122,4 +126,126 @@ describe("worktree helpers", () => {
expect(full.excludedTables).toEqual([]);
expect(full.nullifyColumns).toEqual({});
});
it("copies the source local_encrypted secrets key into the seeded worktree instance", () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-secrets-"));
try {
const sourceConfigPath = path.join(tempRoot, "source", "config.json");
const sourceKeyPath = path.join(tempRoot, "source", "secrets", "master.key");
const targetKeyPath = path.join(tempRoot, "target", "secrets", "master.key");
fs.mkdirSync(path.dirname(sourceKeyPath), { recursive: true });
fs.writeFileSync(sourceKeyPath, "source-master-key", "utf8");
const sourceConfig = buildSourceConfig();
sourceConfig.secrets.localEncrypted.keyFilePath = sourceKeyPath;
copySeededSecretsKey({
sourceConfigPath,
sourceConfig,
sourceEnvEntries: {},
targetKeyFilePath: targetKeyPath,
});
expect(fs.readFileSync(targetKeyPath, "utf8")).toBe("source-master-key");
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
it("writes the source inline secrets master key into the seeded worktree instance", () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-secrets-"));
try {
const sourceConfigPath = path.join(tempRoot, "source", "config.json");
const targetKeyPath = path.join(tempRoot, "target", "secrets", "master.key");
copySeededSecretsKey({
sourceConfigPath,
sourceConfig: buildSourceConfig(),
sourceEnvEntries: {
PAPERCLIP_SECRETS_MASTER_KEY: "inline-source-master-key",
},
targetKeyFilePath: targetKeyPath,
});
expect(fs.readFileSync(targetKeyPath, "utf8")).toBe("inline-source-master-key");
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
it("rebinds same-repo workspace paths onto the current worktree root", () => {
expect(
rebindWorkspaceCwd({
sourceRepoRoot: "/Users/example/paperclip",
targetRepoRoot: "/Users/example/paperclip-pr-432",
workspaceCwd: "/Users/example/paperclip",
}),
).toBe("/Users/example/paperclip-pr-432");
expect(
rebindWorkspaceCwd({
sourceRepoRoot: "/Users/example/paperclip",
targetRepoRoot: "/Users/example/paperclip-pr-432",
workspaceCwd: "/Users/example/paperclip/packages/db",
}),
).toBe("/Users/example/paperclip-pr-432/packages/db");
});
it("does not rebind paths outside the source repo root", () => {
expect(
rebindWorkspaceCwd({
sourceRepoRoot: "/Users/example/paperclip",
targetRepoRoot: "/Users/example/paperclip-pr-432",
workspaceCwd: "/Users/example/other-project",
}),
).toBeNull();
});
it("copies shared git hooks into a linked worktree git dir", () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-hooks-"));
const repoRoot = path.join(tempRoot, "repo");
const worktreePath = path.join(tempRoot, "repo-feature");
try {
fs.mkdirSync(repoRoot, { recursive: true });
execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" });
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoRoot, stdio: "ignore" });
execFileSync("git", ["config", "user.name", "Test User"], { cwd: repoRoot, stdio: "ignore" });
fs.writeFileSync(path.join(repoRoot, "README.md"), "# temp\n", "utf8");
execFileSync("git", ["add", "README.md"], { cwd: repoRoot, stdio: "ignore" });
execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: repoRoot, stdio: "ignore" });
const sourceHooksDir = path.join(repoRoot, ".git", "hooks");
const sourceHookPath = path.join(sourceHooksDir, "pre-commit");
const sourceTokensPath = path.join(sourceHooksDir, "forbidden-tokens.txt");
fs.writeFileSync(sourceHookPath, "#!/usr/bin/env bash\nexit 0\n", { encoding: "utf8", mode: 0o755 });
fs.chmodSync(sourceHookPath, 0o755);
fs.writeFileSync(sourceTokensPath, "secret-token\n", "utf8");
execFileSync("git", ["worktree", "add", "--detach", worktreePath], { cwd: repoRoot, stdio: "ignore" });
const copied = copyGitHooksToWorktreeGitDir(worktreePath);
const worktreeGitDir = execFileSync("git", ["rev-parse", "--git-dir"], {
cwd: worktreePath,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
const resolvedSourceHooksDir = fs.realpathSync(sourceHooksDir);
const resolvedTargetHooksDir = fs.realpathSync(path.resolve(worktreePath, worktreeGitDir, "hooks"));
const targetHookPath = path.join(resolvedTargetHooksDir, "pre-commit");
const targetTokensPath = path.join(resolvedTargetHooksDir, "forbidden-tokens.txt");
expect(copied).toMatchObject({
sourceHooksPath: resolvedSourceHooksDir,
targetHooksPath: resolvedTargetHooksDir,
copied: true,
});
expect(fs.readFileSync(targetHookPath, "utf8")).toBe("#!/usr/bin/env bash\nexit 0\n");
expect(fs.statSync(targetHookPath).mode & 0o111).not.toBe(0);
expect(fs.readFileSync(targetTokensPath, "utf8")).toBe("secret-token\n");
} finally {
execFileSync("git", ["worktree", "remove", "--force", worktreePath], { cwd: repoRoot, stdio: "ignore" });
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
});

View File

@@ -75,6 +75,11 @@ export async function bootstrapCeoInvite(opts: {
}
const db = createDb(dbUrl);
const closableDb = db as typeof db & {
$client?: {
end?: (options?: { timeout?: number }) => Promise<void>;
};
};
try {
const existingAdminCount = await db
.select()
@@ -122,5 +127,7 @@ export async function bootstrapCeoInvite(opts: {
} catch (err) {
p.log.error(`Could not create bootstrap invite: ${err instanceof Error ? err.message : String(err)}`);
p.log.info("If using embedded-postgres, start the Paperclip server and run this command again.");
} finally {
await closableDb.$client?.end?.({ timeout: 5 }).catch(() => undefined);
}
}

View File

@@ -86,11 +86,29 @@ export async function runCommand(opts: RunOptions): Promise<void> {
await bootstrapCeoInvite({
config: configPath,
dbUrl: startedServer.databaseUrl,
baseUrl: startedServer.apiUrl.replace(/\/api$/, ""),
baseUrl: resolveBootstrapInviteBaseUrl(config, startedServer),
});
}
}
function resolveBootstrapInviteBaseUrl(
config: PaperclipConfig,
startedServer: StartedServer,
): string {
const explicitBaseUrl =
process.env.PAPERCLIP_PUBLIC_URL ??
process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL ??
process.env.BETTER_AUTH_URL ??
process.env.BETTER_AUTH_BASE_URL ??
(config.auth.baseUrlMode === "explicit" ? config.auth.publicBaseUrl : undefined);
if (typeof explicitBaseUrl === "string" && explicitBaseUrl.trim().length > 0) {
return explicitBaseUrl.trim().replace(/\/+$/, "");
}
return startedServer.apiUrl.replace(/\/api$/, "");
}
function formatError(err: unknown): string {
if (err instanceof Error) {
if (err.message && err.message.trim().length > 0) return err.message;

View File

@@ -1,14 +1,29 @@
import { existsSync, readFileSync, rmSync } from "node:fs";
import {
chmodSync,
copyFileSync,
existsSync,
mkdirSync,
readdirSync,
readFileSync,
readlinkSync,
rmSync,
statSync,
symlinkSync,
writeFileSync,
} from "node:fs";
import os from "node:os";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { createServer } from "node:net";
import * as p from "@clack/prompts";
import pc from "picocolors";
import { eq } from "drizzle-orm";
import {
applyPendingMigrations,
createDb,
ensurePostgresDatabase,
formatDatabaseBackupResult,
projectWorkspaces,
runDatabaseBackup,
runDatabaseRestore,
} from "@paperclipai/db";
@@ -18,6 +33,7 @@ import { expandHomePrefix } from "../config/home.js";
import type { PaperclipConfig } from "../config/schema.js";
import { readConfig, resolveConfigPath, writeConfig } from "../config/store.js";
import { printPaperclipCliBanner } from "../utils/banner.js";
import { resolveRuntimeLikePath } from "../utils/path-resolver.js";
import {
buildWorktreeConfig,
buildWorktreeEnvEntries,
@@ -73,6 +89,28 @@ type EmbeddedPostgresHandle = {
stop: () => Promise<void>;
};
type GitWorkspaceInfo = {
root: string;
commonDir: string;
gitDir: string;
hooksPath: string;
};
type CopiedGitHooksResult = {
sourceHooksPath: string;
targetHooksPath: string;
copied: boolean;
};
type SeedWorktreeDatabaseResult = {
backupSummary: string;
reboundWorkspaces: Array<{
name: string;
fromCwd: string;
toCwd: string;
}>;
};
function nonEmpty(value: string | null | undefined): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
@@ -132,6 +170,180 @@ function detectGitBranchName(cwd: string): string | null {
}
}
function detectGitWorkspaceInfo(cwd: string): GitWorkspaceInfo | null {
try {
const root = execFileSync("git", ["rev-parse", "--show-toplevel"], {
cwd,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
const commonDirRaw = execFileSync("git", ["rev-parse", "--git-common-dir"], {
cwd: root,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
const gitDirRaw = execFileSync("git", ["rev-parse", "--git-dir"], {
cwd: root,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
const hooksPathRaw = execFileSync("git", ["rev-parse", "--git-path", "hooks"], {
cwd: root,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
return {
root: path.resolve(root),
commonDir: path.resolve(root, commonDirRaw),
gitDir: path.resolve(root, gitDirRaw),
hooksPath: path.resolve(root, hooksPathRaw),
};
} catch {
return null;
}
}
function copyDirectoryContents(sourceDir: string, targetDir: string): boolean {
if (!existsSync(sourceDir)) return false;
const entries = readdirSync(sourceDir, { withFileTypes: true });
if (entries.length === 0) return false;
mkdirSync(targetDir, { recursive: true });
let copied = false;
for (const entry of entries) {
const sourcePath = path.resolve(sourceDir, entry.name);
const targetPath = path.resolve(targetDir, entry.name);
if (entry.isDirectory()) {
mkdirSync(targetPath, { recursive: true });
copyDirectoryContents(sourcePath, targetPath);
copied = true;
continue;
}
if (entry.isSymbolicLink()) {
rmSync(targetPath, { recursive: true, force: true });
symlinkSync(readlinkSync(sourcePath), targetPath);
copied = true;
continue;
}
copyFileSync(sourcePath, targetPath);
try {
chmodSync(targetPath, statSync(sourcePath).mode & 0o777);
} catch {
// best effort
}
copied = true;
}
return copied;
}
export function copyGitHooksToWorktreeGitDir(cwd: string): CopiedGitHooksResult | null {
const workspace = detectGitWorkspaceInfo(cwd);
if (!workspace) return null;
const sourceHooksPath = workspace.hooksPath;
const targetHooksPath = path.resolve(workspace.gitDir, "hooks");
if (sourceHooksPath === targetHooksPath) {
return {
sourceHooksPath,
targetHooksPath,
copied: false,
};
}
return {
sourceHooksPath,
targetHooksPath,
copied: copyDirectoryContents(sourceHooksPath, targetHooksPath),
};
}
export function rebindWorkspaceCwd(input: {
sourceRepoRoot: string;
targetRepoRoot: string;
workspaceCwd: string;
}): string | null {
const sourceRepoRoot = path.resolve(input.sourceRepoRoot);
const targetRepoRoot = path.resolve(input.targetRepoRoot);
const workspaceCwd = path.resolve(input.workspaceCwd);
const relative = path.relative(sourceRepoRoot, workspaceCwd);
if (!relative || relative === "") {
return targetRepoRoot;
}
if (relative.startsWith("..") || path.isAbsolute(relative)) {
return null;
}
return path.resolve(targetRepoRoot, relative);
}
async function rebindSeededProjectWorkspaces(input: {
targetConnectionString: string;
currentCwd: string;
}): Promise<SeedWorktreeDatabaseResult["reboundWorkspaces"]> {
const targetRepo = detectGitWorkspaceInfo(input.currentCwd);
if (!targetRepo) return [];
const db = createDb(input.targetConnectionString);
const closableDb = db as typeof db & {
$client?: { end?: (opts?: { timeout?: number }) => Promise<void> };
};
try {
const rows = await db
.select({
id: projectWorkspaces.id,
name: projectWorkspaces.name,
cwd: projectWorkspaces.cwd,
})
.from(projectWorkspaces);
const rebound: SeedWorktreeDatabaseResult["reboundWorkspaces"] = [];
for (const row of rows) {
const workspaceCwd = nonEmpty(row.cwd);
if (!workspaceCwd) continue;
const sourceRepo = detectGitWorkspaceInfo(workspaceCwd);
if (!sourceRepo) continue;
if (sourceRepo.commonDir !== targetRepo.commonDir) continue;
const reboundCwd = rebindWorkspaceCwd({
sourceRepoRoot: sourceRepo.root,
targetRepoRoot: targetRepo.root,
workspaceCwd,
});
if (!reboundCwd) continue;
const normalizedCurrent = path.resolve(workspaceCwd);
if (reboundCwd === normalizedCurrent) continue;
if (!existsSync(reboundCwd)) continue;
await db
.update(projectWorkspaces)
.set({
cwd: reboundCwd,
updatedAt: new Date(),
})
.where(eq(projectWorkspaces.id, row.id));
rebound.push({
name: row.name,
fromCwd: normalizedCurrent,
toCwd: reboundCwd,
});
}
return rebound;
} finally {
await closableDb.$client?.end?.({ timeout: 5 }).catch(() => undefined);
}
}
function resolveSourceConfigPath(opts: WorktreeInitOptions): string {
if (opts.fromConfig) return path.resolve(opts.fromConfig);
const sourceHome = path.resolve(expandHomePrefix(opts.fromDataDir ?? "~/.paperclip"));
@@ -154,6 +366,54 @@ function resolveSourceConnectionString(config: PaperclipConfig, envEntries: Reco
return `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`;
}
export function copySeededSecretsKey(input: {
sourceConfigPath: string;
sourceConfig: PaperclipConfig;
sourceEnvEntries: Record<string, string>;
targetKeyFilePath: string;
}): void {
if (input.sourceConfig.secrets.provider !== "local_encrypted") {
return;
}
mkdirSync(path.dirname(input.targetKeyFilePath), { recursive: true });
const sourceInlineMasterKey =
nonEmpty(input.sourceEnvEntries.PAPERCLIP_SECRETS_MASTER_KEY) ??
nonEmpty(process.env.PAPERCLIP_SECRETS_MASTER_KEY);
if (sourceInlineMasterKey) {
writeFileSync(input.targetKeyFilePath, sourceInlineMasterKey, {
encoding: "utf8",
mode: 0o600,
});
try {
chmodSync(input.targetKeyFilePath, 0o600);
} catch {
// best effort
}
return;
}
const sourceKeyFileOverride =
nonEmpty(input.sourceEnvEntries.PAPERCLIP_SECRETS_MASTER_KEY_FILE) ??
nonEmpty(process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE);
const sourceConfiguredKeyPath = sourceKeyFileOverride ?? input.sourceConfig.secrets.localEncrypted.keyFilePath;
const sourceKeyFilePath = resolveRuntimeLikePath(sourceConfiguredKeyPath, input.sourceConfigPath);
if (!existsSync(sourceKeyFilePath)) {
throw new Error(
`Cannot seed worktree database because source local_encrypted secrets key was not found at ${sourceKeyFilePath}.`,
);
}
copyFileSync(sourceKeyFilePath, input.targetKeyFilePath);
try {
chmodSync(input.targetKeyFilePath, 0o600);
} catch {
// best effort
}
}
async function ensureEmbeddedPostgres(dataDir: string, preferredPort: number): Promise<EmbeddedPostgresHandle> {
const moduleName = "embedded-postgres";
let EmbeddedPostgres: EmbeddedPostgresCtor;
@@ -211,10 +471,16 @@ async function seedWorktreeDatabase(input: {
targetPaths: WorktreeLocalPaths;
instanceId: string;
seedMode: WorktreeSeedMode;
}): Promise<string> {
}): Promise<SeedWorktreeDatabaseResult> {
const seedPlan = resolveWorktreeSeedPlan(input.seedMode);
const sourceEnvFile = resolvePaperclipEnvFile(input.sourceConfigPath);
const sourceEnvEntries = readPaperclipEnvEntries(sourceEnvFile);
copySeededSecretsKey({
sourceConfigPath: input.sourceConfigPath,
sourceConfig: input.sourceConfig,
sourceEnvEntries,
targetKeyFilePath: input.targetPaths.secretsKeyFilePath,
});
let sourceHandle: EmbeddedPostgresHandle | null = null;
let targetHandle: EmbeddedPostgresHandle | null = null;
@@ -253,8 +519,15 @@ async function seedWorktreeDatabase(input: {
backupFile: backup.backupFile,
});
await applyPendingMigrations(targetConnectionString);
const reboundWorkspaces = await rebindSeededProjectWorkspaces({
targetConnectionString,
currentCwd: input.targetPaths.cwd,
});
return formatDatabaseBackupResult(backup);
return {
backupSummary: formatDatabaseBackupResult(backup),
reboundWorkspaces,
};
} finally {
if (targetHandle?.startedByThisProcess) {
await targetHandle.stop();
@@ -313,8 +586,10 @@ export async function worktreeInitCommand(opts: WorktreeInitOptions): Promise<vo
mergePaperclipEnvEntries(buildWorktreeEnvEntries(paths), paths.envPath);
ensureAgentJwtSecret(paths.configPath);
loadPaperclipEnvFile(paths.configPath);
const copiedGitHooks = copyGitHooksToWorktreeGitDir(cwd);
let seedSummary: string | null = null;
let reboundWorkspaceSummary: SeedWorktreeDatabaseResult["reboundWorkspaces"] = [];
if (opts.seed !== false) {
if (!sourceConfig) {
throw new Error(
@@ -324,7 +599,7 @@ export async function worktreeInitCommand(opts: WorktreeInitOptions): Promise<vo
const spinner = p.spinner();
spinner.start(`Seeding isolated worktree database from source instance (${seedMode})...`);
try {
seedSummary = await seedWorktreeDatabase({
const seeded = await seedWorktreeDatabase({
sourceConfigPath,
sourceConfig,
targetConfig,
@@ -332,6 +607,8 @@ export async function worktreeInitCommand(opts: WorktreeInitOptions): Promise<vo
instanceId,
seedMode,
});
seedSummary = seeded.backupSummary;
reboundWorkspaceSummary = seeded.reboundWorkspaces;
spinner.stop(`Seeded isolated worktree database (${seedMode}).`);
} catch (error) {
spinner.stop(pc.red("Failed to seed worktree database."));
@@ -344,9 +621,19 @@ export async function worktreeInitCommand(opts: WorktreeInitOptions): Promise<vo
p.log.message(pc.dim(`Isolated home: ${paths.homeDir}`));
p.log.message(pc.dim(`Instance: ${paths.instanceId}`));
p.log.message(pc.dim(`Server port: ${serverPort} | DB port: ${databasePort}`));
if (copiedGitHooks?.copied) {
p.log.message(
pc.dim(`Mirrored git hooks: ${copiedGitHooks.sourceHooksPath} -> ${copiedGitHooks.targetHooksPath}`),
);
}
if (seedSummary) {
p.log.message(pc.dim(`Seed mode: ${seedMode}`));
p.log.message(pc.dim(`Seed snapshot: ${seedSummary}`));
for (const rebound of reboundWorkspaceSummary) {
p.log.message(
pc.dim(`Rebound workspace ${rebound.name}: ${rebound.fromCwd} -> ${rebound.toCwd}`),
);
}
}
p.outro(
pc.green(

View File

@@ -1,5 +1,5 @@
{
"extends": "../tsconfig.json",
"extends": "../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"

View File

@@ -19,6 +19,14 @@ That's it. On first start the server:
Data persists across restarts in `~/.paperclip/instances/default/db/`. To reset local dev data, delete that directory.
If you need to apply pending migrations manually, run:
```sh
pnpm db:migrate
```
When `DATABASE_URL` is unset, this command targets the current embedded PostgreSQL instance for your active Paperclip config/instance.
This mode is ideal for local development and one-command installs.
Docker note: the Docker quickstart image also uses embedded PostgreSQL by default. Persist `/paperclip` to keep DB state across container restarts (see `doc/DOCKER.md`).

View File

@@ -138,6 +138,7 @@ This command:
- writes repo-local files at `.paperclip/config.json` and `.paperclip/.env`
- creates an isolated instance under `~/.paperclip-worktrees/instances/<worktree-id>/`
- when run inside a linked git worktree, mirrors the effective git hooks into that worktree's private git dir
- picks a free app port and embedded PostgreSQL port
- by default seeds the isolated DB in `minimal` mode from your main instance via a logical SQL snapshot
@@ -168,6 +169,8 @@ paperclipai worktree init --from-data-dir ~/.paperclip
paperclipai worktree init --force
```
For project execution worktrees, Paperclip can also run a project-defined provision command after it creates or reuses an isolated git worktree. Configure this on the project's execution workspace policy (`workspaceStrategy.provisionCommand`). The command runs inside the derived worktree and receives `PAPERCLIP_WORKSPACE_*`, `PAPERCLIP_PROJECT_ID`, `PAPERCLIP_AGENT_ID`, and `PAPERCLIP_ISSUE_*` environment variables so each repo can bootstrap itself however it wants.
## Quick Health Checks
In another terminal:

View File

@@ -122,5 +122,7 @@ Notes:
- Container runtime user id defaults to your local `id -u` so the mounted data dir stays writable while avoiding root runtime.
- Smoke script defaults to `authenticated/private` mode so `HOST=0.0.0.0` can be exposed to the host.
- Smoke script defaults host port to `3131` to avoid conflicts with local Paperclip on `3100`.
- Smoke script also defaults `PAPERCLIP_PUBLIC_URL` to `http://localhost:<HOST_PORT>` so bootstrap invite URLs and auth callbacks use the reachable host port instead of the container's internal `3100`.
- In authenticated mode, the smoke script defaults `SMOKE_AUTO_BOOTSTRAP=true` and drives the real bootstrap path automatically: it signs up a real user, runs `paperclipai auth bootstrap-ceo` inside the container to mint a real bootstrap invite, accepts that invite over HTTP, and verifies board session access.
- Run the script in the foreground to watch the onboarding flow; stop with `Ctrl+C` after validation.
- The image definition is in `Dockerfile.onboard-smoke`.

View File

@@ -8,10 +8,11 @@ For the maintainer release workflow, use [doc/RELEASING.md](RELEASING.md). This
Use these scripts instead of older one-off publish commands:
- [`scripts/release-start.sh`](../scripts/release-start.sh) to create or resume `release/X.Y.Z`
- [`scripts/release-preflight.sh`](../scripts/release-preflight.sh) before any canary or stable release
- [`scripts/release.sh`](../scripts/release.sh) for canary and stable npm publishes
- [`scripts/rollback-latest.sh`](../scripts/rollback-latest.sh) to repoint `latest` during rollback
- [`scripts/create-github-release.sh`](../scripts/create-github-release.sh) after a stable push
- [`scripts/create-github-release.sh`](../scripts/create-github-release.sh) after pushing the stable branch tag
## Why the CLI needs special packaging
@@ -87,7 +88,7 @@ This means:
Stable releases publish normal semver versions such as `1.2.3` under the npm dist-tag `latest`.
The stable publish flow also creates the local release commit and git tag. Pushing the commit/tag and creating the GitHub Release happen afterward as separate maintainer steps.
The stable publish flow also creates the local release commit and git tag on `release/X.Y.Z`. Pushing that branch commit/tag, creating the GitHub Release, and merging the release branch back to `master` happen afterward as separate maintainer steps.
## Rollback model
@@ -109,7 +110,7 @@ Recommended CI release setup:
- use npm trusted publishing via GitHub OIDC
- require approval through the `npm-release` environment
- run releases from `master`
- run releases from `release/X.Y.Z`
- use canary first, then stable
## Related Files

View File

@@ -2,260 +2,138 @@
Maintainer runbook for shipping a full Paperclip release across npm, GitHub, and the website-facing changelog surface.
This document is intentionally practical:
The release model is branch-driven:
- TL;DR command sequences are at the top.
- Detailed checklists come next.
- Motivation, failure handling, and rollback playbooks follow after that.
1. Start a release train on `release/X.Y.Z`
2. Draft the stable changelog on that branch
3. Publish one or more canaries from that branch
4. Publish stable from that same branch head
5. Push the branch commit and tag
6. Create the GitHub Release
7. Merge `release/X.Y.Z` back to `master` without squash or rebase
## Release Surfaces
Every Paperclip release has four separate surfaces:
Every release has four separate surfaces:
1. **Verification** — the exact git SHA must pass typecheck, tests, and build.
2. **npm**`paperclipai` and the public workspace packages are published.
3. **GitHub** — the stable release gets a git tag and a GitHub Release.
4. **Website / announcements** — the stable changelog is published externally and announced.
1. **Verification** — the exact git SHA passes typecheck, tests, and build
2. **npm**`paperclipai` and public workspace packages are published
3. **GitHub** — the stable release gets a git tag and GitHub Release
4. **Website / announcements** — the stable changelog is published externally and announced
Treat those as related but separate. npm can succeed while the GitHub Release is still pending. GitHub can be correct while the website changelog is stale. A maintainer release is done only when all four surfaces are handled.
A release is done only when all four surfaces are handled.
## Core Invariants
- Canary and stable for `X.Y.Z` must come from the same `release/X.Y.Z` branch.
- The release scripts must run from the matching `release/X.Y.Z` branch.
- Once `vX.Y.Z` exists locally, on GitHub, or on npm, that release train is frozen.
- Do not squash-merge or rebase-merge a release branch PR back to `master`.
- The stable changelog is always `releases/vX.Y.Z.md`. Never create canary changelog files.
The reason for the merge rule is simple: the tag must keep pointing at the exact published commit. Squash or rebase breaks that property.
## TL;DR
### Canary release
### 1. Start the release train
Use this when you want an installable prerelease without changing `latest`.
Use this to compute the next version, create or resume the branch, create or resume a dedicated worktree, and push the branch to GitHub.
```bash
# 0. Confirm master already has the CI-owned lockfile refresh merged
# If package manifests changed recently, wait for the refresh-lockfile PR first.
./scripts/release-start.sh patch
```
# 1. Preflight the canary candidate
./scripts/release-preflight.sh canary patch
That script:
# 2. Draft or update the stable changelog for the intended stable version
VERSION=0.2.8
- fetches the release remote and tags
- computes the next stable version from the latest `v*` tag
- creates or resumes `release/X.Y.Z`
- creates or resumes a dedicated worktree
- pushes the branch to the remote by default
- refuses to reuse a frozen release train
### 2. Draft the stable changelog
From the release worktree:
```bash
VERSION=X.Y.Z
claude --print --output-format stream-json --verbose --dangerously-skip-permissions --model claude-opus-4-6 "Use the release-changelog skill to draft or update releases/v${VERSION}.md for Paperclip. Read doc/RELEASING.md and skills/release-changelog/SKILL.md, then generate the stable changelog for v${VERSION} from commits since the last stable tag. Do not create a canary changelog."
```
# 3. Preview the canary release
### 3. Verify and publish a canary
```bash
./scripts/release-preflight.sh canary patch
./scripts/release.sh patch --canary --dry-run
# 4. Publish the canary
./scripts/release.sh patch --canary
# 5. Smoke test what users will actually install
PAPERCLIPAI_VERSION=canary ./scripts/docker-onboard-smoke.sh
```
# Users install with:
Users install canaries with:
```bash
npx paperclipai@canary onboard
```
Result:
- npm gets a prerelease such as `1.2.3-canary.0` under dist-tag `canary`
- `latest` is unchanged
- no git tag is created
- no GitHub Release is created
- the working tree returns to clean after the script finishes
- after stable `0.2.7`, a patch canary targets `0.2.8-canary.0`, never `0.2.7-canary.N`
### Stable release
Use this only after the canary SHA is good enough to become the public default.
### 4. Publish stable
```bash
# 0. Confirm master already has the CI-owned lockfile refresh merged
# If package manifests changed recently, wait for the refresh-lockfile PR first.
# 1. Start from the vetted commit
git checkout master
git pull
# 2. Preflight the stable candidate
./scripts/release-preflight.sh stable patch
# 3. Confirm the stable changelog exists
VERSION=0.2.8
ls "releases/v${VERSION}.md"
# 4. Preview the stable publish
./scripts/release.sh patch --dry-run
# 5. Publish the stable release to npm and create the local release commit + tag
./scripts/release.sh patch
# 6. Push the release commit and tag
git push public-gh HEAD:master --follow-tags
# 7. Create or update the GitHub Release from the pushed tag
git push public-gh HEAD --follow-tags
./scripts/create-github-release.sh X.Y.Z
```
Result:
Then open a PR from `release/X.Y.Z` to `master` and merge without squash or rebase.
- npm gets stable `X.Y.Z` under dist-tag `latest`
- a local git commit and tag `vX.Y.Z` are created
- after push, GitHub gets the matching Release
- the website and announcement steps still need to be handled manually
## Release Branches
### Emergency rollback
Paperclip uses one release branch per target stable version:
If `latest` is broken after publish, repoint it to the last known good stable version first, then work on the fix.
- `release/0.3.0`
- `release/0.3.1`
- `release/1.0.0`
Do not create separate per-canary branches like `canary/0.3.0-1`. A canary is just a prerelease snapshot of the same stable train.
## Script Entry Points
- [`scripts/release-start.sh`](../scripts/release-start.sh) — create or resume the release train branch/worktree
- [`scripts/release-preflight.sh`](../scripts/release-preflight.sh) — validate branch, version plan, git/npm state, and verification gate
- [`scripts/release.sh`](../scripts/release.sh) — publish canary or stable from the release branch
- [`scripts/create-github-release.sh`](../scripts/create-github-release.sh) — create or update the GitHub Release after pushing the tag
- [`scripts/rollback-latest.sh`](../scripts/rollback-latest.sh) — repoint `latest` to the last good stable version
## Detailed Workflow
### 1. Start or resume the release train
Run:
```bash
# Preview
./scripts/rollback-latest.sh X.Y.Z --dry-run
# Roll back latest for every public package
./scripts/rollback-latest.sh X.Y.Z
./scripts/release-start.sh <patch|minor|major>
```
This does **not** unpublish anything. It only moves the `latest` dist-tag back to the last good stable release.
### Standalone onboarding smoke
You already have a script for isolated onboarding verification:
Useful options:
```bash
HOST_PORT=3232 DATA_DIR=./data/release-smoke-canary PAPERCLIPAI_VERSION=canary ./scripts/docker-onboard-smoke.sh
HOST_PORT=3233 DATA_DIR=./data/release-smoke-stable PAPERCLIPAI_VERSION=latest ./scripts/docker-onboard-smoke.sh
./scripts/release-start.sh patch --dry-run
./scripts/release-start.sh minor --worktree-dir ../paperclip-release-0.4.0
./scripts/release-start.sh patch --no-push
```
This is the best existing fit when you want:
The script is intentionally idempotent:
- a standalone Paperclip data dir
- a dedicated host port
- an end-to-end `npx paperclipai ... onboard` check
- if `release/X.Y.Z` already exists locally, it reuses it
- if the branch already exists on the remote, it resumes it locally
- if the branch is already checked out in another worktree, it points you there
- if `vX.Y.Z` already exists locally, remotely, or on npm, it refuses to reuse that train
In authenticated/private mode, the expected result is a full authenticated onboarding flow, including printing the bootstrap CEO invite once startup completes.
### 2. Write the stable changelog early
If you want to exercise onboarding from a fresh local checkout rather than npm, use:
```bash
./scripts/clean-onboard-git.sh
```
That is not a required release step every time, but it is a useful higher-confidence check when onboarding is the main risk area or when you need to verify what the current codebase does before publishing.
If you want to exercise onboarding from the current committed ref in your local repo, use:
```bash
./scripts/clean-onboard-ref.sh
PAPERCLIP_PORT=3234 ./scripts/clean-onboard-ref.sh
./scripts/clean-onboard-ref.sh HEAD
```
This uses the current committed `HEAD` in a detached temp worktree. It does **not** include uncommitted local edits.
### GitHub Actions release
There is also a manual workflow at [`.github/workflows/release.yml`](../.github/workflows/release.yml). It is designed for npm trusted publishing via GitHub OIDC instead of long-lived npm tokens.
Use it from the Actions tab:
1. Choose `Release`
2. Choose `channel`: `canary` or `stable`
3. Choose `bump`: `patch`, `minor`, or `major`
4. Choose whether this is a `dry_run`
5. Run it from `master`
The workflow:
- reruns `typecheck`, `test:run`, and `build`
- gates publish behind the `npm-release` environment
- can publish canaries without touching `latest`
- can publish stable, push the release commit and tag, and create the GitHub Release
## Release Checklist
### Before any publish
- [ ] The working tree is clean, including untracked files
- [ ] The target branch and SHA are the ones you actually want to release
- [ ] If package manifests changed, the CI-owned `pnpm-lock.yaml` refresh is already merged on `master`
- [ ] The required verification gate passed on that exact SHA
- [ ] The bump type is correct for the user-visible impact
- [ ] The stable changelog file exists or is ready to be written at `releases/vX.Y.Z.md`
- [ ] You know which previous stable version you would roll back to if needed
### Before a canary
- [ ] You are intentionally testing something that should be installable before it becomes default
- [ ] You are comfortable with users installing it via `npx paperclipai@canary onboard`
- [ ] You understand that each canary is a new immutable npm version such as `1.2.3-canary.1`
### Before a stable
- [ ] The candidate has already passed smoke testing
- [ ] The changelog should be the stable version only, for example `v1.2.3`
- [ ] You are ready to push the release commit and tag immediately after npm publish
- [ ] You are ready to create the GitHub Release immediately after the push
- [ ] You have a post-release website / announcement plan
### After a stable
- [ ] `npm view paperclipai@latest version` matches the new stable version
- [ ] The git tag exists on GitHub
- [ ] The GitHub Release exists and uses `releases/vX.Y.Z.md`
- [ ] The website changelog is updated
- [ ] Any announcement copy matches the shipped release, not the canary
## Verification Gate
The repository standard is:
```bash
pnpm -r typecheck
pnpm test:run
pnpm build
```
This matches [`.github/workflows/pr-verify.yml`](../.github/workflows/pr-verify.yml). Run it before claiming a release candidate is ready.
The release workflow at [`.github/workflows/release.yml`](../.github/workflows/release.yml) installs with `pnpm install --frozen-lockfile`. That is intentional. Releases must use the exact dependency graph already committed on `master`; if manifests changed and the CI-owned lockfile refresh has not landed yet, the release should fail until that prerequisite is merged.
For release work, prefer:
```bash
./scripts/release-preflight.sh canary <patch|minor|major>
./scripts/release-preflight.sh stable <patch|minor|major>
```
That script runs the verification gate and prints the computed target versions before you publish anything.
## Versioning Policy
### Stable versions
Stable releases use normal semver:
- `patch` for bug fixes
- `minor` for additive features, endpoints, and additive migrations
- `major` for destructive migrations, removed APIs, or other breaking behavior
### Canary versions
Canaries are semver prereleases of the **intended stable version**:
- `1.2.3-canary.0`
- `1.2.3-canary.1`
- `1.2.3-canary.2`
That gives you three useful properties:
1. Users can install the prerelease explicitly with `@canary`
2. `latest` stays safe
3. The stable changelog can remain just `v1.2.3`
We do **not** create separate changelog files for canary versions.
Concrete example:
- if the latest stable release is `0.2.7`, a patch canary is `0.2.8-canary.0`
- `0.2.7-canary.0` is invalid, because `0.2.7` is already the shipped stable version
## Changelog Policy
The maintainer changelog source of truth is:
Create or update:
- `releases/vX.Y.Z.md`
@@ -268,14 +146,13 @@ Recommended structure:
- `Improvements`
- `Fixes`
- `Upgrade Guide` when needed
- `Contributors` — @-mention every contributor by GitHub username (no emails)
Package-level `CHANGELOG.md` files are generated as part of the release mechanics. They are not the main release narrative.
## Detailed Workflow
### 3. Run release preflight
### 1. Decide the bump
Run preflight first:
From the `release/X.Y.Z` worktree:
```bash
./scripts/release-preflight.sh canary <patch|minor|major>
@@ -283,70 +160,54 @@ Run preflight first:
./scripts/release-preflight.sh stable <patch|minor|major>
```
That command:
The preflight script now checks all of the following before it runs the verification gate:
- verifies the worktree is clean, including untracked files
- shows the last stable tag and computed next versions
- shows the commit range since the last stable tag
- highlights migration and breaking-change signals
- runs `pnpm -r typecheck`, `pnpm test:run`, and `pnpm build`
- the worktree is clean, including untracked files
- the current branch matches the computed `release/X.Y.Z`
- the release train is not frozen
- the target version is still free on npm
- the target tag does not already exist locally or remotely
- whether the remote release branch already exists
- whether `releases/vX.Y.Z.md` is present
If you want the raw inputs separately, review the range since the last stable tag:
Then it runs:
```bash
LAST_TAG=$(git tag --list 'v*' --sort=-version:refname | head -1)
git log "${LAST_TAG}..HEAD" --oneline --no-merges
git diff --name-only "${LAST_TAG}..HEAD" -- packages/db/src/migrations/
git diff "${LAST_TAG}..HEAD" -- packages/db/src/schema/
git log "${LAST_TAG}..HEAD" --format="%s" | grep -E 'BREAKING CHANGE|BREAKING:|^[a-z]+!:' || true
pnpm -r typecheck
pnpm test:run
pnpm build
```
Use the higher bump if there is any doubt.
### 2. Write the stable changelog first
Create or update:
```bash
VERSION=X.Y.Z
claude -p "Use the release-changelog skill to draft or update releases/v${VERSION}.md for Paperclip. Read doc/RELEASING.md and skills/release-changelog/SKILL.md, then generate the stable changelog for v${VERSION} from commits since the last stable tag. Do not create a canary changelog."
```
This is deliberate. The release notes should describe the stable story, not the canary mechanics.
### 3. Publish one or more canaries
### 4. Publish one or more canaries
Run:
```bash
./scripts/release.sh <patch|minor|major> --canary --dry-run
./scripts/release.sh <patch|minor|major> --canary
```
What the script does:
Result:
1. Verifies the working tree is clean
2. Computes the intended stable version from the last stable tag
3. Computes the next canary ordinal from npm
4. Versions the public packages to `X.Y.Z-canary.N`
5. Builds the workspace and publishable CLI
6. Publishes to npm under dist-tag `canary`
7. Cleans up the temporary versioning state so your branch returns to clean
- npm gets a prerelease such as `1.2.3-canary.0` under dist-tag `canary`
- `latest` is unchanged
- no git tag is created
- no GitHub Release is created
- the worktree returns to clean after the script finishes
This means the script is safe to repeat as many times as needed while iterating:
Guardrails:
- `1.2.3-canary.0`
- `1.2.3-canary.1`
- `1.2.3-canary.2`
- the script refuses to run from the wrong branch
- the script refuses to publish from a frozen train
- the canary is always derived from the next stable version
- if the stable notes file is missing, the script warns before you forget it
The target stable release can still remain `1.2.3`.
Concrete example:
Guardrail:
- if the latest stable is `0.2.7`, a patch canary targets `0.2.8-canary.0`
- `0.2.7-canary.N` is invalid because `0.2.7` is already stable
- the canary is always derived from the **next stable version**
- after stable `0.2.7`, the next patch canary is `0.2.8-canary.0`
- the scripts refuse to publish `0.2.7-canary.N` once `0.2.7` is already the stable release
### 4. Smoke test the canary
### 5. Smoke test the canary
Run the actual install path in Docker:
@@ -361,165 +222,198 @@ HOST_PORT=3232 DATA_DIR=./data/release-smoke-canary PAPERCLIPAI_VERSION=canary .
HOST_PORT=3233 DATA_DIR=./data/release-smoke-stable PAPERCLIPAI_VERSION=latest ./scripts/docker-onboard-smoke.sh
```
If you want to smoke onboarding from the current codebase rather than npm, run:
If you want to exercise onboarding from the current committed ref instead of npm, use:
```bash
./scripts/clean-onboard-git.sh
./scripts/clean-onboard-ref.sh
PAPERCLIP_PORT=3234 ./scripts/clean-onboard-ref.sh
./scripts/clean-onboard-ref.sh HEAD
```
Minimum checks:
- [ ] `npx paperclipai@canary onboard` installs
- [ ] onboarding completes without crashes
- [ ] the server boots
- [ ] the UI loads
- [ ] basic company creation and dashboard load work
- `npx paperclipai@canary onboard` installs
- onboarding completes without crashes
- the server boots
- the UI loads
- basic company creation and dashboard load work
### 5. Publish stable from the vetted commit
If smoke testing fails:
Once the candidate SHA is good, run the stable flow on that exact commit:
1. stop the stable release
2. fix the issue on the same `release/X.Y.Z` branch
3. publish another canary
4. rerun smoke testing
### 6. Publish stable from the same release branch
Once the branch head is vetted, run:
```bash
./scripts/release.sh <patch|minor|major> --dry-run
./scripts/release.sh <patch|minor|major>
```
What the script does:
Stable publish:
1. Verifies the working tree is clean
2. Versions the public packages to the stable semver
3. Builds the workspace and CLI publish bundle
4. Publishes to npm under `latest`
5. Restores temporary publish artifacts
6. Creates the local release commit and git tag
- publishes `X.Y.Z` to npm under `latest`
- creates the local release commit
- creates the local tag `vX.Y.Z`
What it does **not** do:
Stable publish refuses to proceed if:
- it does not push for you
- it does not update the website
- it does not announce the release for you
- the current branch is not `release/X.Y.Z`
- the remote release branch does not exist yet
- the stable notes file is missing
- the target tag already exists locally or remotely
- the stable version already exists on npm
### 6. Push the release and create the GitHub Release
Those checks intentionally freeze the train after stable publish.
After a stable publish succeeds:
### 7. Push the stable branch commit and tag
After stable publish succeeds:
```bash
git push public-gh HEAD:master --follow-tags
git push public-gh HEAD --follow-tags
./scripts/create-github-release.sh X.Y.Z
```
The GitHub release notes come from:
The GitHub Release notes come from:
- `releases/vX.Y.Z.md`
### 7. Complete the external surfaces
### 8. Merge the release branch back to `master`
Open a PR:
- base: `master`
- head: `release/X.Y.Z`
Merge rule:
- allowed: merge commit or fast-forward
- forbidden: squash merge
- forbidden: rebase merge
Post-merge verification:
```bash
git fetch public-gh --tags
git merge-base --is-ancestor "vX.Y.Z" "public-gh/master"
```
That command must succeed. If it fails, the published tagged commit is not reachable from `master`, which means the merge strategy was wrong.
### 9. Finish the external surfaces
After GitHub is correct:
- publish the changelog on the website
- write the announcement copy
- write and send the announcement copy
- ensure public docs and install guidance point to the stable version
## GitHub Actions and npm Trusted Publishing
## GitHub Actions Release
If you want GitHub to own the actual npm publish, use [`.github/workflows/release.yml`](../.github/workflows/release.yml) together with npm trusted publishing.
There is also a manual workflow at [`.github/workflows/release.yml`](../.github/workflows/release.yml).
Recommended setup:
Use it from the Actions tab on the relevant `release/X.Y.Z` branch:
1. Configure the GitHub Actions workflow as a trusted publisher for **every public package** on npm
2. Use the `npm-release` GitHub environment with required reviewers
3. Run stable publishes from `master` only
4. Keep the workflow manual via `workflow_dispatch`
1. Choose `Release`
2. Choose `channel`: `canary` or `stable`
3. Choose `bump`: `patch`, `minor`, or `major`
4. Choose whether this is a `dry_run`
5. Run it from the release branch, not from `master`
Why this is the right shape:
The workflow:
- no long-lived npm token needs to live in GitHub secrets
- reviewers can approve the publish step at the environment gate
- the workflow reruns verification on the release SHA before publish
- stable and canary use the same mechanics
- reruns `typecheck`, `test:run`, and `build`
- gates publish behind the `npm-release` environment
- can publish canaries without touching `latest`
- can publish stable, push the stable branch commit and tag, and create the GitHub Release
It does not merge the release branch back to `master` for you.
## Release Checklist
### Before any publish
- [ ] The release train exists on `release/X.Y.Z`
- [ ] The working tree is clean, including untracked files
- [ ] If package manifests changed, the CI-owned `pnpm-lock.yaml` refresh is already merged on `master` before the train is cut
- [ ] The required verification gate passed on the exact branch head you want to publish
- [ ] The bump type is correct for the user-visible impact
- [ ] The stable changelog file exists or is ready at `releases/vX.Y.Z.md`
- [ ] You know which previous stable version you would roll back to if needed
### Before a stable
- [ ] The candidate has already passed smoke testing
- [ ] The remote `release/X.Y.Z` branch exists
- [ ] You are ready to push the stable branch commit and tag immediately after npm publish
- [ ] You are ready to create the GitHub Release immediately after the push
- [ ] You are ready to open the PR back to `master`
### After a stable
- [ ] `npm view paperclipai@latest version` matches the new stable version
- [ ] The git tag exists on GitHub
- [ ] The GitHub Release exists and uses `releases/vX.Y.Z.md`
- [ ] `vX.Y.Z` is reachable from `master`
- [ ] The website changelog is updated
- [ ] Announcement copy matches the stable release, not the canary
## Failure Playbooks
### If the canary fails before publish
Nothing shipped. Fix the code and rerun the canary workflow.
### If the canary publishes but the smoke test fails
Do **not** publish stable.
Do not publish stable.
Instead:
1. Fix the issue
2. Publish another canary
3. Re-run smoke testing
1. fix the issue on `release/X.Y.Z`
2. publish another canary
3. rerun smoke testing
The canary version number will increase, but the stable target version can remain the same.
### If the stable npm publish succeeds but push fails
### If stable npm publish succeeds but push or GitHub release creation fails
This is a partial release. npm is already live.
Do this immediately:
1. Fix the git issue
2. Push the release commit and tag from the same checkout
3. Create the GitHub Release
1. fix the git or GitHub issue from the same checkout
2. push the stable branch commit and tag
3. create the GitHub Release
Do **not** publish the same version again.
Do not republish the same version.
### If the stable release is bad after `latest` moves
### If `latest` is broken after stable publish
Use the rollback script first:
Preview:
```bash
./scripts/rollback-latest.sh <last-good-version>
./scripts/rollback-latest.sh X.Y.Z --dry-run
```
Then:
Roll back:
1. open an incident note or maintainer comment
2. fix forward on a new patch release
3. update the changelog / release notes if the user-facing guidance changed
```bash
./scripts/rollback-latest.sh X.Y.Z
```
### If the GitHub Release is wrong
This does not unpublish anything. It only moves the `latest` dist-tag back to the last good stable release.
Edit it by re-running:
Then fix forward with a new patch release.
### If the GitHub Release notes are wrong
Re-run:
```bash
./scripts/create-github-release.sh X.Y.Z
```
This updates the release notes if the GitHub Release already exists.
### If the website changelog is wrong
Fix the website independently. Do not republish npm just to repair the website surface.
## Rollback Strategy
The default rollback strategy is **dist-tag rollback, then fix forward**.
Why:
- npm versions are immutable
- users need `npx paperclipai onboard` to recover quickly
- moving `latest` back is faster and safer than trying to delete history
Rollback procedure:
1. identify the last known good stable version
2. run `./scripts/rollback-latest.sh <version>`
3. verify `npm view paperclipai@latest version`
4. fix forward with a new stable release
## Scripts Reference
- [`scripts/release.sh`](../scripts/release.sh) — stable and canary npm publish flow
- [`scripts/release-preflight.sh`](../scripts/release-preflight.sh) — clean-tree, version-plan, and verification-gate preflight
- [`scripts/create-github-release.sh`](../scripts/create-github-release.sh) — create or update the GitHub Release after push
- [`scripts/rollback-latest.sh`](../scripts/rollback-latest.sh) — repoint `latest` to the last good stable release
- [`scripts/docker-onboard-smoke.sh`](../scripts/docker-onboard-smoke.sh) — Docker smoke test for the installed CLI
If the release already exists, the script updates it.
## Related Docs

View File

@@ -0,0 +1,62 @@
# Issue worktree support
Status: experimental, runtime-only, not shipping as a user-facing feature yet.
This branch contains the runtime and seeding work needed for issue-scoped worktrees:
- project execution workspace policy support
- issue-level execution workspace settings
- git worktree realization for isolated issue execution
- optional command-based worktree provisioning
- seeded worktree fixes for secrets key compatibility
- seeded project workspace rebinding to the current git worktree
We are intentionally not shipping the UI for this yet. The runtime code remains in place, but the main UI entrypoints are hard-gated off for now.
## What works today
- projects can carry execution workspace policy in the backend
- issues can carry execution workspace settings in the backend
- heartbeat execution can realize isolated git worktrees
- runtime can run a project-defined provision command inside the derived worktree
- seeded worktree instances can keep local-encrypted secrets working
- seeded worktree instances can rebind same-repo project workspace paths onto the current git worktree
## Hidden UI entrypoints
These are the current user-facing UI surfaces for the feature, now intentionally disabled:
- project settings:
- `ui/src/components/ProjectProperties.tsx`
- execution workspace policy controls
- git worktree base ref / branch template / parent dir
- provision / teardown command inputs
- issue creation:
- `ui/src/components/NewIssueDialog.tsx`
- isolated issue checkout toggle
- defaulting issue execution workspace settings from project policy
- issue editing:
- `ui/src/components/IssueProperties.tsx`
- issue-level workspace mode toggle
- defaulting issue execution workspace settings when project changes
- agent/runtime settings:
- `ui/src/adapters/runtime-json-fields.tsx`
- runtime services JSON field, which is part of the broader workspace-runtime support surface
## Why the UI is hidden
- the runtime behavior is still being validated
- the workflow and operator ergonomics are not final
- we do not want to expose a partially-baked user-facing feature in issues, projects, or settings
## Re-enable plan
When this is ready to ship:
- re-enable the gated UI sections in the files above
- review wording and defaults for project and issue controls
- decide which agent/runtime settings should remain advanced-only
- add end-to-end product-level verification for the full UI workflow

View File

@@ -4,7 +4,7 @@
"type": "module",
"scripts": {
"dev": "node scripts/dev-runner.mjs watch",
"dev:watch": "PAPERCLIP_MIGRATION_PROMPT=never node scripts/dev-runner.mjs watch",
"dev:watch": "node scripts/dev-runner.mjs watch",
"dev:once": "node scripts/dev-runner.mjs dev",
"dev:server": "pnpm --filter @paperclipai/server dev",
"dev:ui": "pnpm --filter @paperclipai/ui dev",
@@ -18,6 +18,7 @@
"db:backup": "./scripts/backup-db.sh",
"paperclipai": "node cli/node_modules/tsx/dist/cli.mjs cli/src/index.ts",
"build:npm": "./scripts/build-npm.sh",
"release:start": "./scripts/release-start.sh",
"release": "./scripts/release.sh",
"release:preflight": "./scripts/release-preflight.sh",
"release:github": "./scripts/create-github-release.sh",
@@ -34,6 +35,7 @@
},
"devDependencies": {
"@changesets/cli": "^2.30.0",
"cross-env": "^10.1.0",
"@playwright/test": "^1.58.2",
"esbuild": "^0.27.3",
"typescript": "^5.7.3",

View File

@@ -1,5 +1,11 @@
# @paperclipai/adapter-utils
## 0.3.0
### Minor Changes
- Stable release preparation for 0.3.0
## 0.2.7
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@paperclipai/adapter-utils",
"version": "0.2.7",
"version": "0.3.0",
"type": "module",
"exports": {
".": "./src/index.ts",

View File

@@ -15,6 +15,11 @@ interface RunningProcess {
graceSec: number;
}
interface SpawnTarget {
command: string;
args: string[];
}
type ChildProcessWithEvents = ChildProcess & {
on(event: "error", listener: (err: Error) => void): ChildProcess;
on(
@@ -125,6 +130,78 @@ export function defaultPathForPlatform() {
return "/usr/local/bin:/opt/homebrew/bin:/usr/local/sbin:/usr/bin:/bin:/usr/sbin:/sbin";
}
function windowsPathExts(env: NodeJS.ProcessEnv): string[] {
return (env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";").filter(Boolean);
}
async function pathExists(candidate: string) {
try {
await fs.access(candidate, process.platform === "win32" ? fsConstants.F_OK : fsConstants.X_OK);
return true;
} catch {
return false;
}
}
async function resolveCommandPath(command: string, cwd: string, env: NodeJS.ProcessEnv): Promise<string | null> {
const hasPathSeparator = command.includes("/") || command.includes("\\");
if (hasPathSeparator) {
const absolute = path.isAbsolute(command) ? command : path.resolve(cwd, command);
return (await pathExists(absolute)) ? absolute : null;
}
const pathValue = env.PATH ?? env.Path ?? "";
const delimiter = process.platform === "win32" ? ";" : ":";
const dirs = pathValue.split(delimiter).filter(Boolean);
const exts = process.platform === "win32" ? windowsPathExts(env) : [""];
const hasExtension = process.platform === "win32" && path.extname(command).length > 0;
for (const dir of dirs) {
const candidates =
process.platform === "win32"
? hasExtension
? [path.join(dir, command)]
: exts.map((ext) => path.join(dir, `${command}${ext}`))
: [path.join(dir, command)];
for (const candidate of candidates) {
if (await pathExists(candidate)) return candidate;
}
}
return null;
}
function quoteForCmd(arg: string) {
if (!arg.length) return '""';
const escaped = arg.replace(/"/g, '""');
return /[\s"&<>|^()]/.test(escaped) ? `"${escaped}"` : escaped;
}
async function resolveSpawnTarget(
command: string,
args: string[],
cwd: string,
env: NodeJS.ProcessEnv,
): Promise<SpawnTarget> {
const resolved = await resolveCommandPath(command, cwd, env);
const executable = resolved ?? command;
if (process.platform !== "win32") {
return { command: executable, args };
}
if (/\.(cmd|bat)$/i.test(executable)) {
const shell = env.ComSpec || process.env.ComSpec || "cmd.exe";
const commandLine = [quoteForCmd(executable), ...args.map(quoteForCmd)].join(" ");
return {
command: shell,
args: ["/d", "/s", "/c", commandLine],
};
}
return { command: executable, args };
}
export function ensurePathInEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
if (typeof env.PATH === "string" && env.PATH.length > 0) return env;
if (typeof env.Path === "string" && env.Path.length > 0) return env;
@@ -169,36 +246,12 @@ export async function ensureAbsoluteDirectory(
}
export async function ensureCommandResolvable(command: string, cwd: string, env: NodeJS.ProcessEnv) {
const hasPathSeparator = command.includes("/") || command.includes("\\");
if (hasPathSeparator) {
const resolved = await resolveCommandPath(command, cwd, env);
if (resolved) return;
if (command.includes("/") || command.includes("\\")) {
const absolute = path.isAbsolute(command) ? command : path.resolve(cwd, command);
try {
await fs.access(absolute, fsConstants.X_OK);
} catch {
throw new Error(`Command is not executable: "${command}" (resolved: "${absolute}")`);
}
return;
throw new Error(`Command is not executable: "${command}" (resolved: "${absolute}")`);
}
const pathValue = env.PATH ?? env.Path ?? "";
const delimiter = process.platform === "win32" ? ";" : ":";
const dirs = pathValue.split(delimiter).filter(Boolean);
const windowsExt = process.platform === "win32"
? (env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";")
: [""];
for (const dir of dirs) {
for (const ext of windowsExt) {
const candidate = path.join(dir, process.platform === "win32" ? `${command}${ext}` : command);
try {
await fs.access(candidate, fsConstants.X_OK);
return;
} catch {
// continue scanning PATH
}
}
}
throw new Error(`Command not found in PATH: "${command}"`);
}
@@ -219,79 +272,100 @@ export async function runChildProcess(
const onLogError = opts.onLogError ?? ((err, id, msg) => console.warn({ err, runId: id }, msg));
return new Promise<RunProcessResult>((resolve, reject) => {
const mergedEnv = ensurePathInEnv({ ...process.env, ...opts.env });
const child = spawn(command, args, {
cwd: opts.cwd,
env: mergedEnv,
shell: false,
stdio: [opts.stdin != null ? "pipe" : "ignore", "pipe", "pipe"],
}) as ChildProcessWithEvents;
const rawMerged: NodeJS.ProcessEnv = { ...process.env, ...opts.env };
if (opts.stdin != null && child.stdin) {
child.stdin.write(opts.stdin);
child.stdin.end();
// Strip Claude Code nesting-guard env vars so spawned `claude` processes
// don't refuse to start with "cannot be launched inside another session".
// These vars leak in when the Paperclip server itself is started from
// within a Claude Code session (e.g. `npx paperclipai run` in a terminal
// owned by Claude Code) or when cron inherits a contaminated shell env.
const CLAUDE_CODE_NESTING_VARS = [
"CLAUDECODE",
"CLAUDE_CODE_ENTRYPOINT",
"CLAUDE_CODE_SESSION",
"CLAUDE_CODE_PARENT_SESSION",
] as const;
for (const key of CLAUDE_CODE_NESTING_VARS) {
delete rawMerged[key];
}
runningProcesses.set(runId, { child, graceSec: opts.graceSec });
const mergedEnv = ensurePathInEnv(rawMerged);
void resolveSpawnTarget(command, args, opts.cwd, mergedEnv)
.then((target) => {
const child = spawn(target.command, target.args, {
cwd: opts.cwd,
env: mergedEnv,
shell: false,
stdio: [opts.stdin != null ? "pipe" : "ignore", "pipe", "pipe"],
}) as ChildProcessWithEvents;
let timedOut = false;
let stdout = "";
let stderr = "";
let logChain: Promise<void> = Promise.resolve();
if (opts.stdin != null && child.stdin) {
child.stdin.write(opts.stdin);
child.stdin.end();
}
const timeout =
opts.timeoutSec > 0
? setTimeout(() => {
timedOut = true;
child.kill("SIGTERM");
setTimeout(() => {
if (!child.killed) {
child.kill("SIGKILL");
}
}, Math.max(1, opts.graceSec) * 1000);
}, opts.timeoutSec * 1000)
: null;
runningProcesses.set(runId, { child, graceSec: opts.graceSec });
child.stdout?.on("data", (chunk: unknown) => {
const text = String(chunk);
stdout = appendWithCap(stdout, text);
logChain = logChain
.then(() => opts.onLog("stdout", text))
.catch((err) => onLogError(err, runId, "failed to append stdout log chunk"));
});
let timedOut = false;
let stdout = "";
let stderr = "";
let logChain: Promise<void> = Promise.resolve();
child.stderr?.on("data", (chunk: unknown) => {
const text = String(chunk);
stderr = appendWithCap(stderr, text);
logChain = logChain
.then(() => opts.onLog("stderr", text))
.catch((err) => onLogError(err, runId, "failed to append stderr log chunk"));
});
const timeout =
opts.timeoutSec > 0
? setTimeout(() => {
timedOut = true;
child.kill("SIGTERM");
setTimeout(() => {
if (!child.killed) {
child.kill("SIGKILL");
}
}, Math.max(1, opts.graceSec) * 1000);
}, opts.timeoutSec * 1000)
: null;
child.on("error", (err: Error) => {
if (timeout) clearTimeout(timeout);
runningProcesses.delete(runId);
const errno = (err as NodeJS.ErrnoException).code;
const pathValue = mergedEnv.PATH ?? mergedEnv.Path ?? "";
const msg =
errno === "ENOENT"
? `Failed to start command "${command}" in "${opts.cwd}". Verify adapter command, working directory, and PATH (${pathValue}).`
: `Failed to start command "${command}" in "${opts.cwd}": ${err.message}`;
reject(new Error(msg));
});
child.on("close", (code: number | null, signal: NodeJS.Signals | null) => {
if (timeout) clearTimeout(timeout);
runningProcesses.delete(runId);
void logChain.finally(() => {
resolve({
exitCode: code,
signal,
timedOut,
stdout,
stderr,
child.stdout?.on("data", (chunk: unknown) => {
const text = String(chunk);
stdout = appendWithCap(stdout, text);
logChain = logChain
.then(() => opts.onLog("stdout", text))
.catch((err) => onLogError(err, runId, "failed to append stdout log chunk"));
});
});
});
child.stderr?.on("data", (chunk: unknown) => {
const text = String(chunk);
stderr = appendWithCap(stderr, text);
logChain = logChain
.then(() => opts.onLog("stderr", text))
.catch((err) => onLogError(err, runId, "failed to append stderr log chunk"));
});
child.on("error", (err: Error) => {
if (timeout) clearTimeout(timeout);
runningProcesses.delete(runId);
const errno = (err as NodeJS.ErrnoException).code;
const pathValue = mergedEnv.PATH ?? mergedEnv.Path ?? "";
const msg =
errno === "ENOENT"
? `Failed to start command "${command}" in "${opts.cwd}". Verify adapter command, working directory, and PATH (${pathValue}).`
: `Failed to start command "${command}" in "${opts.cwd}": ${err.message}`;
reject(new Error(msg));
});
child.on("close", (code: number | null, signal: NodeJS.Signals | null) => {
if (timeout) clearTimeout(timeout);
runningProcesses.delete(runId);
void logChain.finally(() => {
resolve({
exitCode: code,
signal,
timedOut,
stdout,
stderr,
});
});
});
})
.catch(reject);
});
}

View File

@@ -1,5 +1,5 @@
{
"extends": "../../tsconfig.json",
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"

View File

@@ -1,5 +1,16 @@
# @paperclipai/adapter-claude-local
## 0.3.0
### Minor Changes
- Stable release preparation for 0.3.0
### Patch Changes
- Updated dependencies
- @paperclipai/adapter-utils@0.3.0
## 0.2.7
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@paperclipai/adapter-claude-local",
"version": "0.2.7",
"version": "0.3.0",
"type": "module",
"exports": {
".": "./src/index.ts",

View File

@@ -1,5 +1,5 @@
{
"extends": "../../../tsconfig.json",
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"

View File

@@ -1,5 +1,16 @@
# @paperclipai/adapter-codex-local
## 0.3.0
### Minor Changes
- Stable release preparation for 0.3.0
### Patch Changes
- Updated dependencies
- @paperclipai/adapter-utils@0.3.0
## 0.2.7
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@paperclipai/adapter-codex-local",
"version": "0.2.7",
"version": "0.3.0",
"type": "module",
"exports": {
".": "./src/index.ts",

View File

@@ -1,5 +1,5 @@
{
"extends": "../../../tsconfig.json",
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"

View File

@@ -1,5 +1,16 @@
# @paperclipai/adapter-cursor-local
## 0.3.0
### Minor Changes
- Stable release preparation for 0.3.0
### Patch Changes
- Updated dependencies
- @paperclipai/adapter-utils@0.3.0
## 0.2.7
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@paperclipai/adapter-cursor-local",
"version": "0.2.7",
"version": "0.3.0",
"type": "module",
"exports": {
".": "./src/index.ts",

View File

@@ -1,5 +1,5 @@
{
"extends": "../../../tsconfig.json",
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",

View File

@@ -0,0 +1,12 @@
# @paperclipai/adapter-openclaw-gateway
## 0.3.0
### Minor Changes
- Stable release preparation for 0.3.0
### Patch Changes
- Updated dependencies
- @paperclipai/adapter-utils@0.3.0

View File

@@ -1,6 +1,6 @@
{
"name": "@paperclipai/adapter-openclaw-gateway",
"version": "0.2.7",
"version": "0.3.0",
"type": "module",
"exports": {
".": "./src/index.ts",

View File

@@ -1,5 +1,5 @@
{
"extends": "../../../tsconfig.json",
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"

View File

@@ -1,5 +1,16 @@
# @paperclipai/adapter-opencode-local
## 0.3.0
### Minor Changes
- Stable release preparation for 0.3.0
### Patch Changes
- Updated dependencies
- @paperclipai/adapter-utils@0.3.0
## 0.2.7
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@paperclipai/adapter-opencode-local",
"version": "0.2.7",
"version": "0.3.0",
"type": "module",
"exports": {
".": "./src/index.ts",

View File

@@ -1,5 +1,5 @@
{
"extends": "../../../tsconfig.json",
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"

View File

@@ -0,0 +1,12 @@
# @paperclipai/adapter-pi-local
## 0.3.0
### Minor Changes
- Stable release preparation for 0.3.0
### Patch Changes
- Updated dependencies
- @paperclipai/adapter-utils@0.3.0

View File

@@ -1,6 +1,6 @@
{
"name": "@paperclipai/adapter-pi-local",
"version": "0.1.0",
"version": "0.3.0",
"type": "module",
"exports": {
".": "./src/index.ts",

View File

@@ -1,5 +1,5 @@
{
"extends": "../../../tsconfig.json",
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"

View File

@@ -1,5 +1,17 @@
# @paperclipai/db
## 0.3.0
### Minor Changes
- Stable release preparation for 0.3.0
### Patch Changes
- Updated dependencies [6077ae6]
- Updated dependencies
- @paperclipai/shared@0.3.0
## 0.2.7
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@paperclipai/db",
"version": "0.2.7",
"version": "0.3.0",
"type": "module",
"exports": {
".": "./src/index.ts",

View File

@@ -1,21 +1,29 @@
import { applyPendingMigrations, inspectMigrations } from "./client.js";
import { resolveMigrationConnection } from "./migration-runtime.js";
const url = process.env.DATABASE_URL;
async function main(): Promise<void> {
const resolved = await resolveMigrationConnection();
if (!url) {
throw new Error("DATABASE_URL is required for db:migrate");
}
console.log(`Migrating database via ${resolved.source}`);
const before = await inspectMigrations(url);
if (before.status === "upToDate") {
console.log("No pending migrations");
} else {
console.log(`Applying ${before.pendingMigrations.length} pending migration(s)...`);
await applyPendingMigrations(url);
try {
const before = await inspectMigrations(resolved.connectionString);
if (before.status === "upToDate") {
console.log("No pending migrations");
return;
}
const after = await inspectMigrations(url);
if (after.status !== "upToDate") {
throw new Error(`Migrations incomplete: ${after.pendingMigrations.join(", ")}`);
console.log(`Applying ${before.pendingMigrations.length} pending migration(s)...`);
await applyPendingMigrations(resolved.connectionString);
const after = await inspectMigrations(resolved.connectionString);
if (after.status !== "upToDate") {
throw new Error(`Migrations incomplete: ${after.pendingMigrations.join(", ")}`);
}
console.log("Migrations complete");
} finally {
await resolved.stop();
}
console.log("Migrations complete");
}
await main();

View File

@@ -0,0 +1,134 @@
import { existsSync, readFileSync, rmSync } from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { ensurePostgresDatabase } from "./client.js";
import { resolveDatabaseTarget } from "./runtime-config.js";
type EmbeddedPostgresInstance = {
initialise(): Promise<void>;
start(): Promise<void>;
stop(): Promise<void>;
};
type EmbeddedPostgresCtor = new (opts: {
databaseDir: string;
user: string;
password: string;
port: number;
persistent: boolean;
onLog?: (message: unknown) => void;
onError?: (message: unknown) => void;
}) => EmbeddedPostgresInstance;
export type MigrationConnection = {
connectionString: string;
source: string;
stop: () => Promise<void>;
};
function readRunningPostmasterPid(postmasterPidFile: string): number | null {
if (!existsSync(postmasterPidFile)) return null;
try {
const pid = Number(readFileSync(postmasterPidFile, "utf8").split("\n")[0]?.trim());
if (!Number.isInteger(pid) || pid <= 0) return null;
process.kill(pid, 0);
return pid;
} catch {
return null;
}
}
function readPidFilePort(postmasterPidFile: string): number | null {
if (!existsSync(postmasterPidFile)) return null;
try {
const lines = readFileSync(postmasterPidFile, "utf8").split("\n");
const port = Number(lines[3]?.trim());
return Number.isInteger(port) && port > 0 ? port : null;
} catch {
return null;
}
}
async function loadEmbeddedPostgresCtor(): Promise<EmbeddedPostgresCtor> {
const require = createRequire(import.meta.url);
const resolveCandidates = [
path.resolve(fileURLToPath(new URL("../..", import.meta.url))),
path.resolve(fileURLToPath(new URL("../../server", import.meta.url))),
path.resolve(fileURLToPath(new URL("../../cli", import.meta.url))),
process.cwd(),
];
try {
const resolvedModulePath = require.resolve("embedded-postgres", { paths: resolveCandidates });
const mod = await import(pathToFileURL(resolvedModulePath).href);
return mod.default as EmbeddedPostgresCtor;
} catch {
throw new Error(
"Embedded PostgreSQL support requires dependency `embedded-postgres`. Reinstall dependencies and try again.",
);
}
}
async function ensureEmbeddedPostgresConnection(
dataDir: string,
preferredPort: number,
): Promise<MigrationConnection> {
const EmbeddedPostgres = await loadEmbeddedPostgresCtor();
const postmasterPidFile = path.resolve(dataDir, "postmaster.pid");
const runningPid = readRunningPostmasterPid(postmasterPidFile);
const runningPort = readPidFilePort(postmasterPidFile);
if (runningPid) {
const port = runningPort ?? preferredPort;
const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`;
await ensurePostgresDatabase(adminConnectionString, "paperclip");
return {
connectionString: `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`,
source: `embedded-postgres@${port}`,
stop: async () => {},
};
}
const instance = new EmbeddedPostgres({
databaseDir: dataDir,
user: "paperclip",
password: "paperclip",
port: preferredPort,
persistent: true,
onLog: () => {},
onError: () => {},
});
if (!existsSync(path.resolve(dataDir, "PG_VERSION"))) {
await instance.initialise();
}
if (existsSync(postmasterPidFile)) {
rmSync(postmasterPidFile, { force: true });
}
await instance.start();
const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${preferredPort}/postgres`;
await ensurePostgresDatabase(adminConnectionString, "paperclip");
return {
connectionString: `postgres://paperclip:paperclip@127.0.0.1:${preferredPort}/paperclip`,
source: `embedded-postgres@${preferredPort}`,
stop: async () => {
await instance.stop();
},
};
}
export async function resolveMigrationConnection(): Promise<MigrationConnection> {
const target = resolveDatabaseTarget();
if (target.mode === "postgres") {
return {
connectionString: target.connectionString,
source: target.source,
stop: async () => {},
};
}
return ensureEmbeddedPostgresConnection(target.dataDir, target.port);
}

View File

@@ -0,0 +1,45 @@
import { inspectMigrations } from "./client.js";
import { resolveMigrationConnection } from "./migration-runtime.js";
const jsonMode = process.argv.includes("--json");
async function main(): Promise<void> {
const connection = await resolveMigrationConnection();
try {
const state = await inspectMigrations(connection.connectionString);
const payload =
state.status === "upToDate"
? {
source: connection.source,
status: "upToDate" as const,
tableCount: state.tableCount,
pendingMigrations: [] as string[],
}
: {
source: connection.source,
status: "needsMigrations" as const,
tableCount: state.tableCount,
pendingMigrations: state.pendingMigrations,
reason: state.reason,
};
if (jsonMode) {
console.log(JSON.stringify(payload));
return;
}
if (payload.status === "upToDate") {
console.log(`Database is up to date via ${payload.source}`);
return;
}
console.log(
`Pending migrations via ${payload.source}: ${payload.pendingMigrations.join(", ")}`,
);
} finally {
await connection.stop();
}
}
await main();

View File

@@ -0,0 +1,107 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { resolveDatabaseTarget } from "./runtime-config.js";
const ORIGINAL_CWD = process.cwd();
const ORIGINAL_ENV = { ...process.env };
function writeJson(filePath: string, value: unknown) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(value, null, 2));
}
function writeText(filePath: string, value: string) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, value);
}
afterEach(() => {
process.chdir(ORIGINAL_CWD);
for (const key of Object.keys(process.env)) {
if (!(key in ORIGINAL_ENV)) delete process.env[key];
}
for (const [key, value] of Object.entries(ORIGINAL_ENV)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
});
describe("resolveDatabaseTarget", () => {
it("uses DATABASE_URL from process env first", () => {
process.env.DATABASE_URL = "postgres://env-user:env-pass@db.example.com:5432/paperclip";
const target = resolveDatabaseTarget();
expect(target).toMatchObject({
mode: "postgres",
connectionString: "postgres://env-user:env-pass@db.example.com:5432/paperclip",
source: "DATABASE_URL",
});
});
it("uses DATABASE_URL from repo-local .paperclip/.env", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-db-runtime-"));
const projectDir = path.join(tempDir, "repo");
fs.mkdirSync(projectDir, { recursive: true });
process.chdir(projectDir);
writeJson(path.join(projectDir, ".paperclip", "config.json"), {
database: { mode: "embedded-postgres", embeddedPostgresPort: 54329 },
});
writeText(
path.join(projectDir, ".paperclip", ".env"),
'DATABASE_URL="postgres://file-user:file-pass@db.example.com:6543/paperclip"\n',
);
const target = resolveDatabaseTarget();
expect(target).toMatchObject({
mode: "postgres",
connectionString: "postgres://file-user:file-pass@db.example.com:6543/paperclip",
source: "paperclip-env",
});
});
it("uses config postgres connection string when configured", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-db-runtime-"));
const configPath = path.join(tempDir, "instance", "config.json");
process.env.PAPERCLIP_CONFIG = configPath;
writeJson(configPath, {
database: {
mode: "postgres",
connectionString: "postgres://cfg-user:cfg-pass@db.example.com:5432/paperclip",
},
});
const target = resolveDatabaseTarget();
expect(target).toMatchObject({
mode: "postgres",
connectionString: "postgres://cfg-user:cfg-pass@db.example.com:5432/paperclip",
source: "config.database.connectionString",
});
});
it("falls back to embedded postgres settings from config", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-db-runtime-"));
const configPath = path.join(tempDir, "instance", "config.json");
process.env.PAPERCLIP_CONFIG = configPath;
writeJson(configPath, {
database: {
mode: "embedded-postgres",
embeddedPostgresDataDir: "~/paperclip-test-db",
embeddedPostgresPort: 55444,
},
});
const target = resolveDatabaseTarget();
expect(target).toMatchObject({
mode: "embedded-postgres",
dataDir: path.resolve(os.homedir(), "paperclip-test-db"),
port: 55444,
source: "embedded-postgres@55444",
});
});
});

View File

@@ -0,0 +1,267 @@
import { existsSync, readFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
const DEFAULT_INSTANCE_ID = "default";
const CONFIG_BASENAME = "config.json";
const ENV_BASENAME = ".env";
const INSTANCE_ID_RE = /^[a-zA-Z0-9_-]+$/;
type PartialConfig = {
database?: {
mode?: "embedded-postgres" | "postgres";
connectionString?: string;
embeddedPostgresDataDir?: string;
embeddedPostgresPort?: number;
pgliteDataDir?: string;
pglitePort?: number;
};
};
export type ResolvedDatabaseTarget =
| {
mode: "postgres";
connectionString: string;
source: "DATABASE_URL" | "paperclip-env" | "config.database.connectionString";
configPath: string;
envPath: string;
}
| {
mode: "embedded-postgres";
dataDir: string;
port: number;
source: `embedded-postgres@${number}`;
configPath: string;
envPath: string;
};
function expandHomePrefix(value: string): string {
if (value === "~") return os.homedir();
if (value.startsWith("~/")) return path.resolve(os.homedir(), value.slice(2));
return value;
}
function resolvePaperclipHomeDir(): string {
const envHome = process.env.PAPERCLIP_HOME?.trim();
if (envHome) return path.resolve(expandHomePrefix(envHome));
return path.resolve(os.homedir(), ".paperclip");
}
function resolvePaperclipInstanceId(): string {
const raw = process.env.PAPERCLIP_INSTANCE_ID?.trim() || DEFAULT_INSTANCE_ID;
if (!INSTANCE_ID_RE.test(raw)) {
throw new Error(`Invalid PAPERCLIP_INSTANCE_ID '${raw}'.`);
}
return raw;
}
function resolveDefaultConfigPath(): string {
return path.resolve(
resolvePaperclipHomeDir(),
"instances",
resolvePaperclipInstanceId(),
CONFIG_BASENAME,
);
}
function resolveDefaultEmbeddedPostgresDir(): string {
return path.resolve(resolvePaperclipHomeDir(), "instances", resolvePaperclipInstanceId(), "db");
}
function resolveHomeAwarePath(value: string): string {
return path.resolve(expandHomePrefix(value));
}
function findConfigFileFromAncestors(startDir: string): string | null {
let currentDir = path.resolve(startDir);
while (true) {
const candidate = path.resolve(currentDir, ".paperclip", CONFIG_BASENAME);
if (existsSync(candidate)) return candidate;
const nextDir = path.resolve(currentDir, "..");
if (nextDir === currentDir) return null;
currentDir = nextDir;
}
}
function resolvePaperclipConfigPath(): string {
if (process.env.PAPERCLIP_CONFIG?.trim()) {
return path.resolve(process.env.PAPERCLIP_CONFIG.trim());
}
return findConfigFileFromAncestors(process.cwd()) ?? resolveDefaultConfigPath();
}
function resolvePaperclipEnvPath(configPath: string): string {
return path.resolve(path.dirname(configPath), ENV_BASENAME);
}
function parseEnvFile(contents: string): Record<string, string> {
const entries: Record<string, string> = {};
for (const rawLine of contents.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) continue;
const match = rawLine.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/);
if (!match) continue;
const [, key, rawValue] = match;
const value = rawValue.trim();
if (!value) {
entries[key] = "";
continue;
}
if (
(value.startsWith("\"") && value.endsWith("\"")) ||
(value.startsWith("'") && value.endsWith("'"))
) {
entries[key] = value.slice(1, -1);
continue;
}
entries[key] = value.replace(/\s+#.*$/, "").trim();
}
return entries;
}
function readEnvEntries(envPath: string): Record<string, string> {
if (!existsSync(envPath)) return {};
return parseEnvFile(readFileSync(envPath, "utf8"));
}
function migrateLegacyConfig(raw: unknown): PartialConfig | null {
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
const config = { ...(raw as Record<string, unknown>) };
const databaseRaw = config.database;
if (typeof databaseRaw !== "object" || databaseRaw === null || Array.isArray(databaseRaw)) {
return config;
}
const database = { ...(databaseRaw as Record<string, unknown>) };
if (database.mode === "pglite") {
database.mode = "embedded-postgres";
if (
typeof database.embeddedPostgresDataDir !== "string" &&
typeof database.pgliteDataDir === "string"
) {
database.embeddedPostgresDataDir = database.pgliteDataDir;
}
if (
typeof database.embeddedPostgresPort !== "number" &&
typeof database.pglitePort === "number" &&
Number.isFinite(database.pglitePort)
) {
database.embeddedPostgresPort = database.pglitePort;
}
}
config.database = database;
return config as PartialConfig;
}
function asPositiveInt(value: unknown): number | null {
if (typeof value !== "number" || !Number.isFinite(value)) return null;
const rounded = Math.trunc(value);
return rounded > 0 ? rounded : null;
}
function readConfig(configPath: string): PartialConfig | null {
if (!existsSync(configPath)) return null;
let parsed: unknown;
try {
parsed = JSON.parse(readFileSync(configPath, "utf8"));
} catch (err) {
throw new Error(
`Failed to parse config at ${configPath}: ${err instanceof Error ? err.message : String(err)}`,
);
}
const migrated = migrateLegacyConfig(parsed);
if (migrated === null || typeof migrated !== "object" || Array.isArray(migrated)) {
throw new Error(`Invalid config at ${configPath}: expected a JSON object`);
}
const database =
typeof migrated.database === "object" &&
migrated.database !== null &&
!Array.isArray(migrated.database)
? migrated.database
: undefined;
return {
database: database
? {
mode: database.mode === "postgres" ? "postgres" : "embedded-postgres",
connectionString:
typeof database.connectionString === "string" ? database.connectionString : undefined,
embeddedPostgresDataDir:
typeof database.embeddedPostgresDataDir === "string"
? database.embeddedPostgresDataDir
: undefined,
embeddedPostgresPort: asPositiveInt(database.embeddedPostgresPort) ?? undefined,
pgliteDataDir: typeof database.pgliteDataDir === "string" ? database.pgliteDataDir : undefined,
pglitePort: asPositiveInt(database.pglitePort) ?? undefined,
}
: undefined,
};
}
export function resolveDatabaseTarget(): ResolvedDatabaseTarget {
const configPath = resolvePaperclipConfigPath();
const envPath = resolvePaperclipEnvPath(configPath);
const envEntries = readEnvEntries(envPath);
const envUrl = process.env.DATABASE_URL?.trim();
if (envUrl) {
return {
mode: "postgres",
connectionString: envUrl,
source: "DATABASE_URL",
configPath,
envPath,
};
}
const fileEnvUrl = envEntries.DATABASE_URL?.trim();
if (fileEnvUrl) {
return {
mode: "postgres",
connectionString: fileEnvUrl,
source: "paperclip-env",
configPath,
envPath,
};
}
const config = readConfig(configPath);
const connectionString = config?.database?.connectionString?.trim();
if (config?.database?.mode === "postgres" && connectionString) {
return {
mode: "postgres",
connectionString,
source: "config.database.connectionString",
configPath,
envPath,
};
}
const port = config?.database?.embeddedPostgresPort ?? 54329;
const dataDir = resolveHomeAwarePath(
config?.database?.embeddedPostgresDataDir ?? resolveDefaultEmbeddedPostgresDir(),
);
return {
mode: "embedded-postgres",
dataDir,
port,
source: `embedded-postgres@${port}`,
configPath,
envPath,
};
}

View File

@@ -1,5 +1,5 @@
{
"extends": "../../tsconfig.json",
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"

View File

@@ -1,5 +1,12 @@
# @paperclipai/shared
## 0.3.0
### Minor Changes
- 6077ae6: Add support for Pi local adapter in constants and onboarding UI.
- Stable release preparation for 0.3.0
## 0.2.7
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@paperclipai/shared",
"version": "0.2.7",
"version": "0.3.0",
"type": "module",
"exports": {
".": "./src/index.ts",

View File

@@ -7,6 +7,8 @@ export interface ExecutionWorkspaceStrategy {
baseRef?: string | null;
branchTemplate?: string | null;
worktreeParentDir?: string | null;
provisionCommand?: string | null;
teardownCommand?: string | null;
}
export interface ProjectExecutionWorkspacePolicy {

View File

@@ -7,6 +7,8 @@ const executionWorkspaceStrategySchema = z
baseRef: z.string().optional().nullable(),
branchTemplate: z.string().optional().nullable(),
worktreeParentDir: z.string().optional().nullable(),
provisionCommand: z.string().optional().nullable(),
teardownCommand: z.string().optional().nullable(),
})
.strict();

View File

@@ -7,6 +7,8 @@ const executionWorkspaceStrategySchema = z
baseRef: z.string().optional().nullable(),
branchTemplate: z.string().optional().nullable(),
worktreeParentDir: z.string().optional().nullable(),
provisionCommand: z.string().optional().nullable(),
teardownCommand: z.string().optional().nullable(),
})
.strict();

View File

@@ -1,5 +1,5 @@
{
"extends": "../../tsconfig.json",
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"

View File

@@ -4,9 +4,9 @@
## Highlights
- **New adapters: Cursor, OpenCode, and Pi** — Paperclip now supports three additional local coding agents. Cursor and OpenCode integrate as first-class adapters with model discovery, run-log streaming, and skill injection. Pi adds a local RPC mode with cost tracking. All three appear in the onboarding wizard alongside Claude Code and Codex.
- **OpenClaw gateway adapter** — A new gateway-only OpenClaw flow replaces the legacy adapter. It uses strict SSE streaming, supports device-key pairing, and handles invite-based onboarding with join-token validation.
- **Inbox and unread semantics** — Issues now track per-user read state. Unread indicators appear in the inbox, dashboard, and browser tab (blue dot). The inbox badge includes join requests and approvals, and inbox ordering is alert-focused.
- **New adapters: Cursor, OpenCode, and Pi** — Paperclip now supports three additional local coding agents. Cursor and OpenCode integrate as first-class adapters with model discovery, run-log streaming, and skill injection. Pi adds a local RPC mode with cost tracking. All three appear in the onboarding wizard alongside Claude Code and Codex. ([#62](https://github.com/paperclipai/paperclip/pull/62), [#141](https://github.com/paperclipai/paperclip/pull/141), [#240](https://github.com/paperclipai/paperclip/pull/240), [#183](https://github.com/paperclipai/paperclip/pull/183), @aaaaron, @Konan69, @richardanaya)
- **OpenClaw gateway adapter** — A new gateway-only OpenClaw flow replaces the legacy adapter. It uses strict SSE streaming, supports device-key pairing, and handles invite-based onboarding with join-token validation. ([#270](https://github.com/paperclipai/paperclip/pull/270))
- **Inbox and unread semantics** — Issues now track per-user read state. Unread indicators appear in the inbox, dashboard, and browser tab (blue dot). The inbox badge includes join requests and approvals, and inbox ordering is alert-focused. ([#196](https://github.com/paperclipai/paperclip/pull/196), @hougangdev)
- **PWA support** — The UI ships as an installable Progressive Web App with a service worker and enhanced manifest. The service worker uses a network-first strategy to prevent stale content.
- **Agent creation wizard** — A new choice modal and full-page configuration flow make it easier to add agents. The sidebar AGENTS header now has a quick-add button.
@@ -19,29 +19,35 @@
- **Project status clickable** — The status chip in the project properties pane is now clickable for quick updates.
- **Scroll-to-bottom button** — Issue detail and run pages show a floating scroll-to-bottom button when you scroll up.
- **Database backup CLI** — `paperclipai db:backup` lets you snapshot the database on demand, with optional automatic scheduling.
- **Disable sign-up** — A new `auth.disableSignUp` config option (and `AUTH_DISABLE_SIGNUP` env var) lets operators lock registration.
- **Deduplicated shortnames** — Agent and project shortnames are now auto-deduplicated on create and update instead of rejecting duplicates.
- **Human-readable role labels** — The agent list and properties pane show friendly role names.
- **Disable sign-up** — A new `auth.disableSignUp` config option (and `AUTH_DISABLE_SIGNUP` env var) lets operators lock registration. ([#279](https://github.com/paperclipai/paperclip/pull/279), @JasonOA888)
- **Deduplicated shortnames** — Agent and project shortnames are now auto-deduplicated on create and update instead of rejecting duplicates. ([#264](https://github.com/paperclipai/paperclip/pull/264), @mvanhorn)
- **Human-readable role labels** — The agent list and properties pane show friendly role names. ([#263](https://github.com/paperclipai/paperclip/pull/263), @mvanhorn)
- **Assignee picker sorting** — Recent selections appear first, then alphabetical.
- **Mobile layout polish** — Unified GitHub-style issue rows across issues, inbox, and dashboard. Improved popover scrolling, command palette centering, and property toggles on mobile.
- **Mobile layout polish** — Unified GitHub-style issue rows across issues, inbox, and dashboard. Improved popover scrolling, command palette centering, and property toggles on mobile. ([#118](https://github.com/paperclipai/paperclip/pull/118), @MumuTW)
- **Invite UX improvements** — Invite links auto-copy to clipboard, snippet-only flow in settings, 10-minute invite TTL, and clearer network-host guidance.
- **Permalink anchors on comments** — Each comment has a stable anchor link and a GET-by-ID API endpoint.
- **Docker deployment hardening** — Authenticated deployment mode by default, named data volume, `PAPERCLIP_PUBLIC_URL` and `PAPERCLIP_ALLOWED_HOSTNAMES` exposed in compose files, health-check DB wait, and Node 24 base image.
- **Updated model lists** — Added `claude-sonnet-4-6`, `claude-haiku-4-6`, and `gpt-5.4` to adapter model constants.
- **Docker deployment hardening** — Authenticated deployment mode by default, named data volume, `PAPERCLIP_PUBLIC_URL` and `PAPERCLIP_ALLOWED_HOSTNAMES` exposed in compose files, health-check DB wait, and Node 24 base image. ([#400](https://github.com/paperclipai/paperclip/pull/400), [#283](https://github.com/paperclipai/paperclip/pull/283), [#284](https://github.com/paperclipai/paperclip/pull/284), @AiMagic5000, @mingfang)
- **Updated model lists** — Added `claude-sonnet-4-6`, `claude-haiku-4-6`, and `gpt-5.4` to adapter model constants. ([#293](https://github.com/paperclipai/paperclip/pull/293), [#110](https://github.com/paperclipai/paperclip/pull/110), @cpfarhood, @artokun)
- **Playwright e2e tests** — New end-to-end test suite covering the onboarding wizard flow.
## Fixes
- **Secret redaction in run logs** — Env vars sourced from secrets are now redacted by provenance, with consistent `secretKeys` tracking.
- **SPA catch-all 500s** — The server serves cached `index.html` in the catch-all route and uses `root` in `sendFile`, preventing 500 errors on dotfile paths and SPA refreshes.
- **Unmatched API routes return 404 JSON** — Previously fell through to the SPA handler.
- **Agent wake logic** — Agents wake when issues move out of backlog, skip self-wake on own comments, and skip wakeup for backlog-status changes. Pending-approval agents are excluded from heartbeat timers.
- **Run log fd leak** — Fixed a file-descriptor leak in log append that caused `spawn EBADF` errors.
- **Secret redaction in run logs** — Env vars sourced from secrets are now redacted by provenance, with consistent `secretKeys` tracking. ([#261](https://github.com/paperclipai/paperclip/pull/261), @mvanhorn)
- **SPA catch-all 500s** — The server serves cached `index.html` in the catch-all route and uses `root` in `sendFile`, preventing 500 errors on dotfile paths and SPA refreshes. ([#269](https://github.com/paperclipai/paperclip/pull/269), [#78](https://github.com/paperclipai/paperclip/pull/78), @mvanhorn, @MumuTW)
- **Unmatched API routes return 404 JSON** — Previously fell through to the SPA handler. ([#269](https://github.com/paperclipai/paperclip/pull/269), @mvanhorn)
- **Agent wake logic** — Agents wake when issues move out of backlog, skip self-wake on own comments, and skip wakeup for backlog-status changes. Pending-approval agents are excluded from heartbeat timers. ([#159](https://github.com/paperclipai/paperclip/pull/159), [#154](https://github.com/paperclipai/paperclip/pull/154), [#267](https://github.com/paperclipai/paperclip/pull/267), [#72](https://github.com/paperclipai/paperclip/pull/72), @Logesh-waran2003, @cschneid, @mvanhorn, @STRML)
- **Run log fd leak** — Fixed a file-descriptor leak in log append that caused `spawn EBADF` errors. ([#266](https://github.com/paperclipai/paperclip/pull/266), @mvanhorn)
- **500 error logging** — Error logs now include the actual error message and request context instead of generic pino-http output.
- **Boolean env parsing** — `parseBooleanFromEnv` no longer silently treats common truthy values as false.
- **Onboarding env defaults** — `onboard` now correctly derives secrets from env vars and reports ignored exposure settings in `local_trusted` mode.
- **Windows path compatibility** — Migration paths use `fileURLToPath` for Windows-safe resolution.
- **Secure cookies on HTTP** — Disabled secure cookie flag for plain HTTP deployments to prevent auth failures.
- **URL encoding** — `buildUrl` splits path and query to prevent `%3F` encoding issues.
- **Auth trusted origins** — Effective trusted origins and allowed hostnames are now applied correctly in public mode.
- **UI stability** — Fixed blank screen when prompt templates are emptied, search URL sync causing re-renders, issue title overflow in inbox, and sidebar badge counts including approvals.
- **Boolean env parsing** — `parseBooleanFromEnv` no longer silently treats common truthy values as false. ([#91](https://github.com/paperclipai/paperclip/pull/91), @zvictor)
- **Onboarding env defaults** — `onboard` now correctly derives secrets from env vars and reports ignored exposure settings in `local_trusted` mode. ([#91](https://github.com/paperclipai/paperclip/pull/91), @zvictor)
- **Windows path compatibility** — Migration paths use `fileURLToPath` for Windows-safe resolution. ([#265](https://github.com/paperclipai/paperclip/pull/265), [#413](https://github.com/paperclipai/paperclip/pull/413), @mvanhorn, @online5880)
- **Secure cookies on HTTP** — Disabled secure cookie flag for plain HTTP deployments to prevent auth failures. ([#376](https://github.com/paperclipai/paperclip/pull/376), @dalestubblefield)
- **URL encoding** — `buildUrl` splits path and query to prevent `%3F` encoding issues. ([#260](https://github.com/paperclipai/paperclip/pull/260), @mvanhorn)
- **Auth trusted origins** — Effective trusted origins and allowed hostnames are now applied correctly in public mode. ([#99](https://github.com/paperclipai/paperclip/pull/99), @zvictor)
- **UI stability** — Fixed blank screen when prompt templates are emptied, search URL sync causing re-renders, issue title overflow in inbox, and sidebar badge counts including approvals. ([#262](https://github.com/paperclipai/paperclip/pull/262), [#196](https://github.com/paperclipai/paperclip/pull/196), [#423](https://github.com/paperclipai/paperclip/pull/423), @mvanhorn, @hougangdev, @RememberV)
## Contributors
Thank you to everyone who contributed to this release!
@aaaaron, @AiMagic5000, @artokun, @cpfarhood, @cschneid, @dalestubblefield, @Dotta, @eltociear, @fahmmin, @gsxdsm, @hougangdev, @JasonOA888, @Konan69, @Logesh-waran2003, @mingfang, @MumuTW, @mvanhorn, @numman-ali, @online5880, @RememberV, @richardanaya, @STRML, @tylerwince, @zvictor

View File

@@ -2,7 +2,8 @@
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
PUBLISH_REMOTE="${PUBLISH_REMOTE:-public-gh}"
# shellcheck source=./release-lib.sh
. "$REPO_ROOT/scripts/release-lib.sh"
dry_run=false
version=""
@@ -17,7 +18,8 @@ Examples:
./scripts/create-github-release.sh 1.2.3 --dry-run
Notes:
- Run this after pushing the release commit and tag.
- Run this after pushing the stable release branch and tag.
- Defaults to git remote public-gh.
- If the release already exists, this script updates its title and notes.
EOF
}
@@ -52,12 +54,19 @@ fi
tag="v$version"
notes_file="$REPO_ROOT/releases/${tag}.md"
PUBLISH_REMOTE="${PUBLISH_REMOTE:-public-gh}"
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
@@ -69,7 +78,7 @@ if ! git -C "$REPO_ROOT" rev-parse "$tag" >/dev/null 2>&1; then
fi
if [ "$dry_run" = true ]; then
echo "[dry-run] gh release create $tag --title $tag --notes-file $notes_file"
echo "[dry-run] gh release create $tag -R $GITHUB_REPO --title $tag --notes-file $notes_file"
exit 0
fi
@@ -78,10 +87,10 @@ if ! git -C "$REPO_ROOT" ls-remote --exit-code --tags "$PUBLISH_REMOTE" "refs/ta
exit 1
fi
if gh release view "$tag" >/dev/null 2>&1; then
gh release edit "$tag" --title "$tag" --notes-file "$notes_file"
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" --title "$tag" --notes-file "$notes_file"
gh release create "$tag" -R "$GITHUB_REPO" --title "$tag" --notes-file "$notes_file"
echo "Created GitHub Release $tag"
fi

View File

@@ -1,5 +1,7 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import { createInterface } from "node:readline/promises";
import { stdin, stdout } from "node:process";
const mode = process.argv[2] === "watch" ? "watch" : "dev";
const cliArgs = process.argv.slice(3);
@@ -43,6 +45,121 @@ if (tailscaleAuth) {
}
const pnpmBin = process.platform === "win32" ? "pnpm.cmd" : "pnpm";
function formatPendingMigrationSummary(migrations) {
if (migrations.length === 0) return "none";
return migrations.length > 3
? `${migrations.slice(0, 3).join(", ")} (+${migrations.length - 3} more)`
: migrations.join(", ");
}
async function runPnpm(args, options = {}) {
return await new Promise((resolve, reject) => {
const child = spawn(pnpmBin, args, {
stdio: options.stdio ?? ["ignore", "pipe", "pipe"],
env: options.env ?? process.env,
shell: process.platform === "win32",
});
let stdoutBuffer = "";
let stderrBuffer = "";
if (child.stdout) {
child.stdout.on("data", (chunk) => {
stdoutBuffer += String(chunk);
});
}
if (child.stderr) {
child.stderr.on("data", (chunk) => {
stderrBuffer += String(chunk);
});
}
child.on("error", reject);
child.on("exit", (code, signal) => {
resolve({
code: code ?? 0,
signal,
stdout: stdoutBuffer,
stderr: stderrBuffer,
});
});
});
}
async function maybePreflightMigrations() {
if (mode !== "watch") return;
if (process.env.PAPERCLIP_MIGRATION_PROMPT === "never") return;
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);
process.exit(status.code);
}
let payload;
try {
payload = JSON.parse(status.stdout.trim());
} catch (error) {
process.stderr.write(status.stderr || status.stdout);
throw error;
}
if (payload.status !== "needsMigrations" || payload.pendingMigrations.length === 0) {
return;
}
const autoApply = process.env.PAPERCLIP_MIGRATION_AUTO_APPLY === "true";
let shouldApply = autoApply;
if (!autoApply) {
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(payload.pendingMigrations)}) now? (y/N): `,
)
)
.trim()
.toLowerCase();
shouldApply = answer === "y" || answer === "yes";
} finally {
prompt.close();
}
}
}
if (!shouldApply) 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) {
process.kill(process.pid, exit.signal);
return;
}
if (exit.code !== 0) {
process.exit(exit.code);
}
}
await maybePreflightMigrations();
if (mode === "watch") {
env.PAPERCLIP_MIGRATION_PROMPT = "never";
}
const serverScript = mode === "watch" ? "dev:watch" : "dev";
const child = spawn(
pnpmBin,
@@ -57,4 +174,3 @@ child.on("exit", (code, signal) => {
}
process.exit(code ?? 0);
});

View File

@@ -9,14 +9,199 @@ DATA_DIR="${DATA_DIR:-$REPO_ROOT/data/docker-onboard-smoke}"
HOST_UID="${HOST_UID:-$(id -u)}"
PAPERCLIP_DEPLOYMENT_MODE="${PAPERCLIP_DEPLOYMENT_MODE:-authenticated}"
PAPERCLIP_DEPLOYMENT_EXPOSURE="${PAPERCLIP_DEPLOYMENT_EXPOSURE:-private}"
DOCKER_TTY_ARGS=()
if [[ -t 0 && -t 1 ]]; then
DOCKER_TTY_ARGS=(-it)
fi
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=""
mkdir -p "$DATA_DIR"
cleanup() {
if [[ -n "$LOG_PID" ]]; then
kill "$LOG_PID" >/dev/null 2>&1 || true
fi
docker stop "$CONTAINER_NAME" >/dev/null 2>&1 || true
if [[ -n "$TMP_DIR" && -d "$TMP_DIR" ]]; then
rm -rf "$TMP_DIR"
fi
}
trap cleanup EXIT INT TERM
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
sleep "$sleep_seconds"
done
return 1
}
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" \
@@ -27,16 +212,38 @@ docker build \
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 " Data dir: $DATA_DIR"
echo " Deployment: $PAPERCLIP_DEPLOYMENT_MODE/$PAPERCLIP_DEPLOYMENT_EXPOSURE"
echo " Live output: onboard banner and server logs stream in this terminal (Ctrl+C to stop)"
docker run --rm \
"${DOCKER_TTY_ARGS[@]}" \
--name "${IMAGE_NAME//[^a-zA-Z0-9_.-]/-}" \
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"
"$IMAGE_NAME" >/dev/null
docker logs -f "$CONTAINER_NAME" &
LOG_PID=$!
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
wait "$LOG_PID"

View File

@@ -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#^\./##'
)

251
scripts/release-lib.sh Normal file
View File

@@ -0,0 +1,251 @@
#!/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 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
}
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
}
compute_bumped_version() {
node - "$1" "$2" <<'NODE'
const current = process.argv[2];
const bump = process.argv[3];
const match = current.match(/^(\d+)\.(\d+)\.(\d+)$/);
if (!match) {
throw new Error(`invalid semver version: ${current}`);
}
let [major, minor, patch] = match.slice(1).map(Number);
if (bump === 'patch') {
patch += 1;
} else if (bump === 'minor') {
minor += 1;
patch = 0;
} else if (bump === 'major') {
major += 1;
minor = 0;
patch = 0;
} else {
throw new Error(`unsupported bump type: ${bump}`);
}
process.stdout.write(`${major}.${minor}.${patch}`);
NODE
}
next_canary_version() {
local stable_version="$1"
local versions_json
versions_json="$(npm view paperclipai versions --json 2>/dev/null || echo '[]')"
node - "$stable_version" "$versions_json" <<'NODE'
const stable = process.argv[2];
const versionsArg = process.argv[3];
let versions = [];
try {
const parsed = JSON.parse(versionsArg);
versions = Array.isArray(parsed) ? parsed : [parsed];
} catch {
versions = [];
}
const pattern = new RegExp(`^${stable.replace(/\./g, '\\.')}-canary\\.(\\d+)$`);
let max = -1;
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_branch_name() {
printf 'release/%s\n' "$1"
}
release_notes_file() {
printf '%s/releases/v%s.md\n' "$REPO_ROOT" "$1"
}
default_release_worktree_path() {
local version="$1"
local parent_dir
local repo_name
parent_dir="$(cd "$REPO_ROOT/.." && pwd)"
repo_name="$(basename "$REPO_ROOT")"
printf '%s/%s-release-%s\n' "$parent_dir" "$repo_name" "$version"
}
git_current_branch() {
git -C "$REPO_ROOT" symbolic-ref --quiet --short HEAD 2>/dev/null || true
}
git_local_branch_exists() {
git -C "$REPO_ROOT" show-ref --verify --quiet "refs/heads/$1"
}
git_remote_branch_exists() {
git -C "$REPO_ROOT" ls-remote --exit-code --heads "$2" "refs/heads/$1" >/dev/null 2>&1
}
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
}
npm_version_exists() {
local version="$1"
local resolved
resolved="$(npm view "paperclipai@${version}" version 2>/dev/null || true)"
[ "$resolved" = "$version" ]
}
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
}
git_worktree_path_for_branch() {
local branch_ref="refs/heads/$1"
git -C "$REPO_ROOT" worktree list --porcelain | awk -v branch_ref="$branch_ref" '
$1 == "worktree" { path = substr($0, 10) }
$1 == "branch" && $2 == branch_ref { print path; exit }
'
}
path_is_worktree_for_branch() {
local path="$1"
local branch="$2"
local current_branch
[ -d "$path" ] || return 1
current_branch="$(git -C "$path" symbolic-ref --quiet --short HEAD 2>/dev/null || true)"
[ "$current_branch" = "$branch" ]
}
ensure_release_branch_for_version() {
local stable_version="$1"
local current_branch
local expected_branch
current_branch="$(git_current_branch)"
expected_branch="$(release_branch_name "$stable_version")"
if [ -z "$current_branch" ]; then
release_fail "release work must run from branch $expected_branch, but HEAD is detached."
fi
if [ "$current_branch" != "$expected_branch" ]; then
release_fail "release work must run from branch $expected_branch, but current branch is $current_branch."
fi
}
stable_release_exists_anywhere() {
local stable_version="$1"
local remote="$2"
local tag="v$stable_version"
git_local_tag_exists "$tag" || git_remote_tag_exists "$tag" "$remote" || npm_version_exists "$stable_version"
}
release_train_is_frozen() {
stable_release_exists_anywhere "$1" "$2"
}

View File

@@ -2,6 +2,8 @@
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
# shellcheck source=./release-lib.sh
. "$REPO_ROOT/scripts/release-lib.sh"
export GIT_PAGER=cat
channel=""
@@ -18,7 +20,9 @@ Examples:
What it does:
- verifies the git worktree is clean, including untracked files
- verifies you are on the matching release/X.Y.Z branch
- shows the last stable tag and the target version(s)
- shows the git/npm/GitHub release-train state
- shows commits since the last stable tag
- highlights migration/schema/breaking-change signals
- runs the verification gate:
@@ -63,79 +67,19 @@ if [[ ! "$bump_type" =~ ^(patch|minor|major)$ ]]; then
exit 1
fi
compute_bumped_version() {
node - "$1" "$2" <<'NODE'
const current = process.argv[2];
const bump = process.argv[3];
const match = current.match(/^(\d+)\.(\d+)\.(\d+)$/);
if (!match) {
throw new Error(`invalid semver version: ${current}`);
}
let [major, minor, patch] = match.slice(1).map(Number);
if (bump === 'patch') {
patch += 1;
} else if (bump === 'minor') {
minor += 1;
patch = 0;
} else if (bump === 'major') {
major += 1;
minor = 0;
patch = 0;
} else {
throw new Error(`unsupported bump type: ${bump}`);
}
process.stdout.write(`${major}.${minor}.${patch}`);
NODE
}
next_canary_version() {
local stable_version="$1"
local versions_json
versions_json="$(npm view paperclipai versions --json 2>/dev/null || echo '[]')"
node - "$stable_version" "$versions_json" <<'NODE'
const stable = process.argv[2];
const versionsArg = process.argv[3];
let versions = [];
try {
const parsed = JSON.parse(versionsArg);
versions = Array.isArray(parsed) ? parsed : [parsed];
} catch {
versions = [];
}
const pattern = new RegExp(`^${stable.replace(/\./g, '\\.')}-canary\\.(\\d+)$`);
let max = -1;
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
}
LAST_STABLE_TAG="$(git -C "$REPO_ROOT" tag --list 'v*' --sort=-version:refname | head -1)"
CURRENT_STABLE_VERSION="${LAST_STABLE_TAG#v}"
if [ -z "$CURRENT_STABLE_VERSION" ]; then
CURRENT_STABLE_VERSION="0.0.0"
fi
RELEASE_REMOTE="$(resolve_release_remote)"
fetch_release_remote "$RELEASE_REMOTE"
LAST_STABLE_TAG="$(get_last_stable_tag)"
CURRENT_STABLE_VERSION="$(get_current_stable_version)"
TARGET_STABLE_VERSION="$(compute_bumped_version "$CURRENT_STABLE_VERSION" "$bump_type")"
TARGET_CANARY_VERSION="$(next_canary_version "$TARGET_STABLE_VERSION")"
EXPECTED_RELEASE_BRANCH="$(release_branch_name "$TARGET_STABLE_VERSION")"
CURRENT_BRANCH="$(git_current_branch)"
RELEASE_TAG="v$TARGET_STABLE_VERSION"
NOTES_FILE="$(release_notes_file "$TARGET_STABLE_VERSION")"
if [ -n "$(git -C "$REPO_ROOT" status --porcelain)" ]; then
echo "Error: working tree is not clean. Commit, stash, or remove changes before releasing." >&2
exit 1
fi
require_clean_worktree
if [ "$TARGET_STABLE_VERSION" = "$CURRENT_STABLE_VERSION" ]; then
echo "Error: next stable version matches the current stable version." >&2
@@ -147,10 +91,41 @@ if [[ "$TARGET_CANARY_VERSION" == "${CURRENT_STABLE_VERSION}-canary."* ]]; then
exit 1
fi
ensure_release_branch_for_version "$TARGET_STABLE_VERSION"
REMOTE_BRANCH_EXISTS="no"
REMOTE_TAG_EXISTS="no"
LOCAL_TAG_EXISTS="no"
NPM_STABLE_EXISTS="no"
if git_remote_branch_exists "$EXPECTED_RELEASE_BRANCH" "$RELEASE_REMOTE"; then
REMOTE_BRANCH_EXISTS="yes"
fi
if git_local_tag_exists "$RELEASE_TAG"; then
LOCAL_TAG_EXISTS="yes"
fi
if git_remote_tag_exists "$RELEASE_TAG" "$RELEASE_REMOTE"; then
REMOTE_TAG_EXISTS="yes"
fi
if npm_version_exists "$TARGET_STABLE_VERSION"; then
NPM_STABLE_EXISTS="yes"
fi
if [ "$LOCAL_TAG_EXISTS" = "yes" ] || [ "$REMOTE_TAG_EXISTS" = "yes" ] || [ "$NPM_STABLE_EXISTS" = "yes" ]; then
echo "Error: release train $EXPECTED_RELEASE_BRANCH is frozen because $RELEASE_TAG already exists locally, remotely, or version $TARGET_STABLE_VERSION is already on npm." >&2
exit 1
fi
echo ""
echo "==> Release preflight"
echo " Remote: $RELEASE_REMOTE"
echo " Channel: $channel"
echo " Bump: $bump_type"
echo " Current branch: ${CURRENT_BRANCH:-<detached>}"
echo " Expected branch: $EXPECTED_RELEASE_BRANCH"
echo " Last stable tag: ${LAST_STABLE_TAG:-<none>}"
echo " Current stable version: $CURRENT_STABLE_VERSION"
echo " Next stable version: $TARGET_STABLE_VERSION"
@@ -162,6 +137,23 @@ fi
echo ""
echo "==> Working tree"
echo " ✓ Clean"
echo " ✓ Branch matches release train"
echo ""
echo "==> Release train state"
echo " Remote branch exists: $REMOTE_BRANCH_EXISTS"
echo " Local stable tag exists: $LOCAL_TAG_EXISTS"
echo " Remote stable tag exists: $REMOTE_TAG_EXISTS"
echo " Stable version on npm: $NPM_STABLE_EXISTS"
if [ -f "$NOTES_FILE" ]; then
echo " Release notes: present at $NOTES_FILE"
else
echo " Release notes: missing at $NOTES_FILE"
fi
if [ "$REMOTE_BRANCH_EXISTS" = "no" ]; then
echo " Warning: remote branch $EXPECTED_RELEASE_BRANCH does not exist on $RELEASE_REMOTE yet."
fi
echo ""
echo "==> Commits since last stable tag"
@@ -193,8 +185,10 @@ pnpm build
echo ""
echo "==> Release preflight summary"
echo " Remote: $RELEASE_REMOTE"
echo " Channel: $channel"
echo " Bump: $bump_type"
echo " Release branch: $EXPECTED_RELEASE_BRANCH"
echo " Last stable tag: ${LAST_STABLE_TAG:-<none>}"
echo " Current stable version: $CURRENT_STABLE_VERSION"
echo " Next stable version: $TARGET_STABLE_VERSION"

182
scripts/release-start.sh Executable file
View File

@@ -0,0 +1,182 @@
#!/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
push_branch=true
bump_type=""
worktree_path=""
usage() {
cat <<'EOF'
Usage:
./scripts/release-start.sh <patch|minor|major> [--dry-run] [--no-push] [--worktree-dir PATH]
Examples:
./scripts/release-start.sh patch
./scripts/release-start.sh minor --dry-run
./scripts/release-start.sh major --worktree-dir ../paperclip-release-1.0.0
What it does:
- fetches the release remote and tags
- computes the next stable version from the latest stable tag
- creates or resumes branch release/X.Y.Z
- creates or resumes a dedicated worktree for that branch
- pushes the release branch to the remote by default
Notes:
- Stable publishes freeze a release train. If vX.Y.Z already exists locally,
remotely, or on npm, this script refuses to reuse release/X.Y.Z.
- Use --no-push only if you intentionally do not want the release branch on
GitHub yet.
EOF
}
while [ $# -gt 0 ]; do
case "$1" in
--dry-run) dry_run=true ;;
--no-push) push_branch=false ;;
--worktree-dir)
shift
[ $# -gt 0 ] || release_fail "--worktree-dir requires a path."
worktree_path="$1"
;;
-h|--help)
usage
exit 0
;;
*)
if [ -n "$bump_type" ]; then
release_fail "only one bump type may be provided."
fi
bump_type="$1"
;;
esac
shift
done
if [[ ! "$bump_type" =~ ^(patch|minor|major)$ ]]; then
usage
exit 1
fi
release_remote="$(resolve_release_remote)"
fetch_release_remote "$release_remote"
last_stable_tag="$(get_last_stable_tag)"
current_stable_version="$(get_current_stable_version)"
target_stable_version="$(compute_bumped_version "$current_stable_version" "$bump_type")"
target_canary_version="$(next_canary_version "$target_stable_version")"
release_branch="$(release_branch_name "$target_stable_version")"
release_tag="v$target_stable_version"
if [ -z "$worktree_path" ]; then
worktree_path="$(default_release_worktree_path "$target_stable_version")"
fi
if stable_release_exists_anywhere "$target_stable_version" "$release_remote"; then
release_fail "release train $release_branch is frozen because $release_tag already exists locally, remotely, or version $target_stable_version is already on npm."
fi
branch_exists_local=false
branch_exists_remote=false
branch_worktree_path=""
created_worktree=false
created_branch=false
pushed_branch=false
if git_local_branch_exists "$release_branch"; then
branch_exists_local=true
fi
if git_remote_branch_exists "$release_branch" "$release_remote"; then
branch_exists_remote=true
fi
branch_worktree_path="$(git_worktree_path_for_branch "$release_branch")"
if [ -n "$branch_worktree_path" ]; then
worktree_path="$branch_worktree_path"
fi
if [ -e "$worktree_path" ] && ! path_is_worktree_for_branch "$worktree_path" "$release_branch"; then
release_fail "path $worktree_path already exists and is not a worktree for $release_branch."
fi
if [ -z "$branch_worktree_path" ]; then
if [ "$dry_run" = true ]; then
if [ "$branch_exists_local" = true ] || [ "$branch_exists_remote" = true ]; then
release_info "[dry-run] Would add worktree $worktree_path for existing branch $release_branch"
else
release_info "[dry-run] Would create branch $release_branch from $release_remote/master"
release_info "[dry-run] Would add worktree $worktree_path"
fi
else
if [ "$branch_exists_local" = true ]; then
git -C "$REPO_ROOT" worktree add "$worktree_path" "$release_branch"
elif [ "$branch_exists_remote" = true ]; then
git -C "$REPO_ROOT" branch --track "$release_branch" "$release_remote/$release_branch"
git -C "$REPO_ROOT" worktree add "$worktree_path" "$release_branch"
created_branch=true
else
git -C "$REPO_ROOT" worktree add -b "$release_branch" "$worktree_path" "$release_remote/master"
created_branch=true
fi
created_worktree=true
fi
fi
if [ "$dry_run" = false ] && [ "$push_branch" = true ] && [ "$branch_exists_remote" = false ]; then
git -C "$worktree_path" push -u "$release_remote" "$release_branch"
pushed_branch=true
fi
if [ "$dry_run" = false ] && [ "$branch_exists_remote" = true ]; then
git -C "$worktree_path" branch --set-upstream-to "$release_remote/$release_branch" "$release_branch" >/dev/null 2>&1 || true
fi
release_info ""
release_info "==> Release train"
release_info " Remote: $release_remote"
release_info " Last stable tag: ${last_stable_tag:-<none>}"
release_info " Current stable version: $current_stable_version"
release_info " Bump: $bump_type"
release_info " Target stable version: $target_stable_version"
release_info " Next canary version: $target_canary_version"
release_info " Branch: $release_branch"
release_info " Tag (reserved until stable publish): $release_tag"
release_info " Worktree: $worktree_path"
release_info " Release notes path: $worktree_path/releases/v${target_stable_version}.md"
release_info ""
release_info "==> Status"
if [ -n "$branch_worktree_path" ]; then
release_info " ✓ Reusing existing worktree for $release_branch"
elif [ "$dry_run" = true ]; then
release_info " ✓ Dry run only; no branch or worktree created"
else
[ "$created_branch" = true ] && release_info " ✓ Created branch $release_branch"
[ "$created_worktree" = true ] && release_info " ✓ Created worktree $worktree_path"
fi
if [ "$branch_exists_remote" = true ]; then
release_info " ✓ Remote branch already exists on $release_remote"
elif [ "$dry_run" = true ] && [ "$push_branch" = true ]; then
release_info " [dry-run] Would push $release_branch to $release_remote"
elif [ "$push_branch" = true ] && [ "$pushed_branch" = true ]; then
release_info " ✓ Pushed $release_branch to $release_remote"
elif [ "$push_branch" = false ]; then
release_warn "release branch was not pushed. Stable publish will later refuse until the branch exists on $release_remote."
fi
release_info ""
release_info "Next steps:"
release_info " cd $worktree_path"
release_info " Draft or update releases/v${target_stable_version}.md"
release_info " ./scripts/release-preflight.sh canary $bump_type"
release_info " ./scripts/release.sh $bump_type --canary"
release_info ""
release_info "Merge rule:"
release_info " Merge $release_branch back to master without squash or rebase so tag $release_tag remains reachable from master."

View File

@@ -15,10 +15,11 @@ set -euo pipefail
# npm dist-tag "canary". Stable releases publish 1.2.3 under "latest".
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
# shellcheck source=./release-lib.sh
. "$REPO_ROOT/scripts/release-lib.sh"
CLI_DIR="$REPO_ROOT/cli"
TEMP_CHANGESET_FILE="$REPO_ROOT/.changeset/release-bump.md"
TEMP_PRE_FILE="$REPO_ROOT/.changeset/pre.json"
PUBLISH_REMOTE="${PUBLISH_REMOTE:-public-gh}"
dry_run=false
canary=false
@@ -41,6 +42,7 @@ Notes:
- Canary publishes prerelease versions like 1.2.3-canary.0 under the npm
dist-tag "canary".
- Stable publishes 1.2.3 under the npm dist-tag "latest".
- Run this from branch release/X.Y.Z matching the computed target version.
- Dry runs leave the working tree clean.
EOF
}
@@ -73,15 +75,6 @@ if [[ ! "$bump_type" =~ ^(patch|minor|major)$ ]]; then
exit 1
fi
info() {
echo "$@"
}
fail() {
echo "Error: $*" >&2
exit 1
}
restore_publish_artifacts() {
if [ -f "$CLI_DIR/package.dev.json" ]; then
mv "$CLI_DIR/package.dev.json" "$CLI_DIR/package.json"
@@ -130,28 +123,22 @@ set_cleanup_trap() {
trap cleanup_release_state EXIT
}
require_clean_worktree() {
if [ -n "$(git -C "$REPO_ROOT" status --porcelain)" ]; then
fail "working tree is not clean. Commit, stash, or remove changes before releasing."
fi
}
require_npm_publish_auth() {
if [ "$dry_run" = true ]; then
return
fi
if npm whoami >/dev/null 2>&1; then
info " ✓ Logged in to npm as $(npm whoami)"
release_info " ✓ Logged in to npm as $(npm whoami)"
return
fi
if [ "${GITHUB_ACTIONS:-}" = "true" ]; then
info " ✓ npm publish auth will be provided by GitHub Actions trusted publishing"
release_info " ✓ npm publish auth will be provided by GitHub Actions trusted publishing"
return
fi
fail "npm publish auth is not available. Use 'npm login' locally or run from the GitHub release workflow."
release_fail "npm publish auth is not available. Use 'npm login' locally or run from the GitHub release workflow."
}
list_public_package_info() {
@@ -202,66 +189,6 @@ for (const [dir, name] of rows) {
NODE
}
compute_bumped_version() {
node - "$1" "$2" <<'NODE'
const current = process.argv[2];
const bump = process.argv[3];
const match = current.match(/^(\d+)\.(\d+)\.(\d+)$/);
if (!match) {
throw new Error(`invalid semver version: ${current}`);
}
let [major, minor, patch] = match.slice(1).map(Number);
if (bump === 'patch') {
patch += 1;
} else if (bump === 'minor') {
minor += 1;
patch = 0;
} else if (bump === 'major') {
major += 1;
minor = 0;
patch = 0;
} else {
throw new Error(`unsupported bump type: ${bump}`);
}
process.stdout.write(`${major}.${minor}.${patch}`);
NODE
}
next_canary_version() {
local stable_version="$1"
local versions_json
versions_json="$(npm view paperclipai versions --json 2>/dev/null || echo '[]')"
node - "$stable_version" "$versions_json" <<'NODE'
const stable = process.argv[2];
const versionsArg = process.argv[3];
let versions = [];
try {
const parsed = JSON.parse(versionsArg);
versions = Array.isArray(parsed) ? parsed : [parsed];
} catch {
versions = [];
}
const pattern = new RegExp(`^${stable.replace(/\./g, '\\.')}-canary\\.(\\d+)$`);
let max = -1;
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
}
replace_version_string() {
local from_version="$1"
local to_version="$2"
@@ -312,25 +239,55 @@ for (const relFile of extraFiles) {
NODE
}
LAST_STABLE_TAG="$(git -C "$REPO_ROOT" tag --list 'v*' --sort=-version:refname | head -1)"
CURRENT_STABLE_VERSION="${LAST_STABLE_TAG#v}"
if [ -z "$CURRENT_STABLE_VERSION" ]; then
CURRENT_STABLE_VERSION="0.0.0"
fi
PUBLISH_REMOTE="$(resolve_release_remote)"
fetch_release_remote "$PUBLISH_REMOTE"
LAST_STABLE_TAG="$(get_last_stable_tag)"
CURRENT_STABLE_VERSION="$(get_current_stable_version)"
TARGET_STABLE_VERSION="$(compute_bumped_version "$CURRENT_STABLE_VERSION" "$bump_type")"
TARGET_PUBLISH_VERSION="$TARGET_STABLE_VERSION"
CURRENT_BRANCH="$(git_current_branch)"
EXPECTED_RELEASE_BRANCH="$(release_branch_name "$TARGET_STABLE_VERSION")"
NOTES_FILE="$(release_notes_file "$TARGET_STABLE_VERSION")"
RELEASE_TAG="v$TARGET_STABLE_VERSION"
if [ "$canary" = true ]; then
TARGET_PUBLISH_VERSION="$(next_canary_version "$TARGET_STABLE_VERSION")"
fi
if [ "$TARGET_STABLE_VERSION" = "$CURRENT_STABLE_VERSION" ]; then
fail "next stable version matches the current stable version. Refusing to publish."
release_fail "next stable version matches the current stable version. Refusing to publish."
fi
if [[ "$TARGET_PUBLISH_VERSION" == "${CURRENT_STABLE_VERSION}-canary."* ]]; then
fail "canary versions must be derived from the next stable version, never ${CURRENT_STABLE_VERSION}-canary.N."
release_fail "canary versions must be derived from the next stable version, never ${CURRENT_STABLE_VERSION}-canary.N."
fi
require_clean_worktree
ensure_release_branch_for_version "$TARGET_STABLE_VERSION"
if git_local_tag_exists "$RELEASE_TAG" || git_remote_tag_exists "$RELEASE_TAG" "$PUBLISH_REMOTE"; then
release_fail "release train $EXPECTED_RELEASE_BRANCH is frozen because tag $RELEASE_TAG already exists locally or on $PUBLISH_REMOTE."
fi
if npm_version_exists "$TARGET_STABLE_VERSION"; then
release_fail "stable version $TARGET_STABLE_VERSION is already published on npm. Refusing to reuse release train $EXPECTED_RELEASE_BRANCH."
fi
if [ "$canary" = false ] && [ ! -f "$NOTES_FILE" ]; then
release_fail "stable release notes file is required at $NOTES_FILE before publishing stable."
fi
if [ "$canary" = true ] && [ ! -f "$NOTES_FILE" ]; then
release_warn "stable release notes file is missing at $NOTES_FILE. Draft it before you finalize stable."
fi
if ! git_remote_branch_exists "$EXPECTED_RELEASE_BRANCH" "$PUBLISH_REMOTE"; then
if [ "$canary" = false ] && [ "$dry_run" = false ]; then
release_fail "remote branch $EXPECTED_RELEASE_BRANCH does not exist on $PUBLISH_REMOTE. Run ./scripts/release-start.sh $bump_type first or push the branch before stable publish."
fi
release_warn "remote branch $EXPECTED_RELEASE_BRANCH does not exist on $PUBLISH_REMOTE yet."
fi
PUBLIC_PACKAGE_INFO="$(list_public_package_info)"
@@ -338,33 +295,36 @@ PUBLIC_PACKAGE_NAMES="$(printf '%s\n' "$PUBLIC_PACKAGE_INFO" | cut -f2)"
PUBLIC_PACKAGE_DIRS="$(printf '%s\n' "$PUBLIC_PACKAGE_INFO" | cut -f1)"
if [ -z "$PUBLIC_PACKAGE_INFO" ]; then
fail "no public packages were found in the workspace."
release_fail "no public packages were found in the workspace."
fi
info ""
info "==> Release plan"
info " Last stable tag: ${LAST_STABLE_TAG:-<none>}"
info " Current stable version: $CURRENT_STABLE_VERSION"
release_info ""
release_info "==> Release plan"
release_info " Remote: $PUBLISH_REMOTE"
release_info " Current branch: ${CURRENT_BRANCH:-<detached>}"
release_info " Expected branch: $EXPECTED_RELEASE_BRANCH"
release_info " Last stable tag: ${LAST_STABLE_TAG:-<none>}"
release_info " Current stable version: $CURRENT_STABLE_VERSION"
if [ "$canary" = true ]; then
info " Target stable version: $TARGET_STABLE_VERSION"
info " Canary version: $TARGET_PUBLISH_VERSION"
info " Guard: canary is derived from next stable version, not ${CURRENT_STABLE_VERSION}-canary.N"
release_info " Target stable version: $TARGET_STABLE_VERSION"
release_info " Canary version: $TARGET_PUBLISH_VERSION"
release_info " Guard: canary is derived from next stable version, not ${CURRENT_STABLE_VERSION}-canary.N"
else
info " Stable version: $TARGET_STABLE_VERSION"
release_info " Stable version: $TARGET_STABLE_VERSION"
fi
info ""
info "==> Step 1/7: Preflight checks..."
require_clean_worktree
info " ✓ Working tree is clean"
release_info ""
release_info "==> Step 1/7: Preflight checks..."
release_info " ✓ Working tree is clean"
release_info " ✓ Branch matches release train"
require_npm_publish_auth
if [ "$dry_run" = true ] || [ "$canary" = true ]; then
set_cleanup_trap
fi
info ""
info "==> Step 2/7: Creating release changeset..."
release_info ""
release_info "==> Step 2/7: Creating release changeset..."
{
echo "---"
while IFS= read -r pkg_name; do
@@ -379,10 +339,10 @@ info "==> Step 2/7: Creating release changeset..."
echo "Stable release preparation for $TARGET_STABLE_VERSION"
fi
} > "$TEMP_CHANGESET_FILE"
info " ✓ Created release changeset for $(printf '%s\n' "$PUBLIC_PACKAGE_NAMES" | sed '/^$/d' | wc -l | xargs) packages"
release_info " ✓ Created release changeset for $(printf '%s\n' "$PUBLIC_PACKAGE_NAMES" | sed '/^$/d' | wc -l | xargs) packages"
info ""
info "==> Step 3/7: Versioning packages..."
release_info ""
release_info "==> Step 3/7: Versioning packages..."
cd "$REPO_ROOT"
if [ "$canary" = true ]; then
npx changeset pre enter canary
@@ -398,12 +358,12 @@ fi
VERSION_IN_CLI_PACKAGE="$(node -e "console.log(require('$CLI_DIR/package.json').version)")"
if [ "$VERSION_IN_CLI_PACKAGE" != "$TARGET_PUBLISH_VERSION" ]; then
fail "versioning drift detected. Expected $TARGET_PUBLISH_VERSION but found $VERSION_IN_CLI_PACKAGE."
release_fail "versioning drift detected. Expected $TARGET_PUBLISH_VERSION but found $VERSION_IN_CLI_PACKAGE."
fi
info " ✓ Versioned workspace to $TARGET_PUBLISH_VERSION"
release_info " ✓ Versioned workspace to $TARGET_PUBLISH_VERSION"
info ""
info "==> Step 4/7: Building workspace artifacts..."
release_info ""
release_info "==> Step 4/7: Building workspace artifacts..."
cd "$REPO_ROOT"
pnpm build
bash "$REPO_ROOT/scripts/prepare-server-ui-dist.sh"
@@ -411,49 +371,49 @@ for pkg_dir in server packages/adapters/claude-local packages/adapters/codex-loc
rm -rf "$REPO_ROOT/$pkg_dir/skills"
cp -r "$REPO_ROOT/skills" "$REPO_ROOT/$pkg_dir/skills"
done
info " ✓ Workspace build complete"
release_info " ✓ Workspace build complete"
info ""
info "==> Step 5/7: Building publishable CLI bundle..."
release_info ""
release_info "==> Step 5/7: Building publishable CLI bundle..."
"$REPO_ROOT/scripts/build-npm.sh" --skip-checks
info " ✓ CLI bundle ready"
release_info " ✓ CLI bundle ready"
info ""
release_info ""
if [ "$dry_run" = true ]; then
info "==> Step 6/7: Previewing publish payloads (--dry-run)..."
release_info "==> Step 6/7: Previewing publish payloads (--dry-run)..."
while IFS= read -r pkg_dir; do
[ -z "$pkg_dir" ] && continue
info " --- $pkg_dir ---"
release_info " --- $pkg_dir ---"
cd "$REPO_ROOT/$pkg_dir"
npm pack --dry-run 2>&1 | tail -3
done <<< "$PUBLIC_PACKAGE_DIRS"
cd "$REPO_ROOT"
if [ "$canary" = true ]; then
info " [dry-run] Would publish ${TARGET_PUBLISH_VERSION} under dist-tag canary"
release_info " [dry-run] Would publish ${TARGET_PUBLISH_VERSION} under dist-tag canary"
else
info " [dry-run] Would publish ${TARGET_PUBLISH_VERSION} under dist-tag latest"
release_info " [dry-run] Would publish ${TARGET_PUBLISH_VERSION} under dist-tag latest"
fi
else
if [ "$canary" = true ]; then
info "==> Step 6/7: Publishing canary to npm..."
release_info "==> Step 6/7: Publishing canary to npm..."
npx changeset publish
info " ✓ Published ${TARGET_PUBLISH_VERSION} under dist-tag canary"
release_info " ✓ Published ${TARGET_PUBLISH_VERSION} under dist-tag canary"
else
info "==> Step 6/7: Publishing stable release to npm..."
release_info "==> Step 6/7: Publishing stable release to npm..."
npx changeset publish
info " ✓ Published ${TARGET_PUBLISH_VERSION} under dist-tag latest"
release_info " ✓ Published ${TARGET_PUBLISH_VERSION} under dist-tag latest"
fi
fi
info ""
release_info ""
if [ "$dry_run" = true ]; then
info "==> Step 7/7: Cleaning up dry-run state..."
info " ✓ Dry run leaves the working tree unchanged"
release_info "==> Step 7/7: Cleaning up dry-run state..."
release_info " ✓ Dry run leaves the working tree unchanged"
elif [ "$canary" = true ]; then
info "==> Step 7/7: Cleaning up canary state..."
info " ✓ Canary state will be discarded after publish"
release_info "==> Step 7/7: Cleaning up canary state..."
release_info " ✓ Canary state will be discarded after publish"
else
info "==> Step 7/7: Finalizing stable release commit..."
release_info "==> Step 7/7: Finalizing stable release commit..."
restore_publish_artifacts
git -C "$REPO_ROOT" add -u .changeset packages server cli
@@ -463,23 +423,24 @@ else
git -C "$REPO_ROOT" commit -m "chore: release v$TARGET_STABLE_VERSION"
git -C "$REPO_ROOT" tag "v$TARGET_STABLE_VERSION"
info " ✓ Created commit and tag v$TARGET_STABLE_VERSION"
release_info " ✓ Created commit and tag v$TARGET_STABLE_VERSION"
fi
info ""
release_info ""
if [ "$dry_run" = true ]; then
if [ "$canary" = true ]; then
info "Dry run complete for canary ${TARGET_PUBLISH_VERSION}."
release_info "Dry run complete for canary ${TARGET_PUBLISH_VERSION}."
else
info "Dry run complete for stable v${TARGET_STABLE_VERSION}."
release_info "Dry run complete for stable v${TARGET_STABLE_VERSION}."
fi
elif [ "$canary" = true ]; then
info "Published canary ${TARGET_PUBLISH_VERSION}."
info "Install with: npx paperclipai@canary onboard"
info "Stable version remains: $CURRENT_STABLE_VERSION"
release_info "Published canary ${TARGET_PUBLISH_VERSION}."
release_info "Install with: npx paperclipai@canary onboard"
release_info "Stable version remains: $CURRENT_STABLE_VERSION"
else
info "Published stable v${TARGET_STABLE_VERSION}."
info "Next steps:"
info " git push ${PUBLISH_REMOTE} HEAD:master --follow-tags"
info " ./scripts/create-github-release.sh $TARGET_STABLE_VERSION"
release_info "Published stable v${TARGET_STABLE_VERSION}."
release_info "Next steps:"
release_info " git push ${PUBLISH_REMOTE} HEAD --follow-tags"
release_info " ./scripts/create-github-release.sh $TARGET_STABLE_VERSION"
release_info " Open a PR from ${EXPECTED_RELEASE_BRANCH} to master and merge without squash or rebase"
fi

View File

@@ -1,5 +1,25 @@
# @paperclipai/server
## 0.3.0
### Minor Changes
- Stable release preparation for 0.3.0
### Patch Changes
- Updated dependencies [6077ae6]
- Updated dependencies
- @paperclipai/shared@0.3.0
- @paperclipai/adapter-utils@0.3.0
- @paperclipai/adapter-claude-local@0.3.0
- @paperclipai/adapter-codex-local@0.3.0
- @paperclipai/adapter-cursor-local@0.3.0
- @paperclipai/adapter-openclaw-gateway@0.3.0
- @paperclipai/adapter-opencode-local@0.3.0
- @paperclipai/adapter-pi-local@0.3.0
- @paperclipai/db@0.3.0
## 0.2.7
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@paperclipai/server",
"version": "0.2.7",
"version": "0.3.0",
"type": "module",
"exports": {
".": "./src/index.ts"
@@ -23,7 +23,7 @@
],
"scripts": {
"dev": "tsx src/index.ts",
"dev:watch": "PAPERCLIP_MIGRATION_PROMPT=never tsx watch --ignore ../ui/node_modules --ignore ../ui/.vite --ignore ../ui/dist src/index.ts",
"dev:watch": "cross-env PAPERCLIP_MIGRATION_PROMPT=never tsx watch --ignore ../ui/node_modules --ignore ../ui/.vite --ignore ../ui/dist src/index.ts",
"prepare:ui-dist": "bash ../scripts/prepare-server-ui-dist.sh",
"build": "tsc",
"prepack": "pnpm run prepare:ui-dist",
@@ -64,6 +64,7 @@
"@types/node": "^24.6.0",
"@types/supertest": "^6.0.2",
"@types/ws": "^8.18.1",
"cross-env": "^10.1.0",
"supertest": "^7.0.0",
"tsx": "^4.19.2",
"typescript": "^5.7.3",

View File

@@ -0,0 +1,110 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { approvalRoutes } from "../routes/approvals.js";
import { errorHandler } from "../middleware/index.js";
const mockApprovalService = vi.hoisted(() => ({
list: vi.fn(),
getById: vi.fn(),
create: vi.fn(),
approve: vi.fn(),
reject: vi.fn(),
requestRevision: vi.fn(),
resubmit: vi.fn(),
listComments: vi.fn(),
addComment: vi.fn(),
}));
const mockHeartbeatService = vi.hoisted(() => ({
wakeup: vi.fn(),
}));
const mockIssueApprovalService = vi.hoisted(() => ({
listIssuesForApproval: vi.fn(),
linkManyForApproval: vi.fn(),
}));
const mockSecretService = vi.hoisted(() => ({
normalizeHireApprovalPayloadForPersistence: vi.fn(),
}));
const mockLogActivity = vi.hoisted(() => vi.fn());
vi.mock("../services/index.js", () => ({
approvalService: () => mockApprovalService,
heartbeatService: () => mockHeartbeatService,
issueApprovalService: () => mockIssueApprovalService,
logActivity: mockLogActivity,
secretService: () => mockSecretService,
}));
function createApp() {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = {
type: "board",
userId: "user-1",
companyIds: ["company-1"],
source: "session",
isInstanceAdmin: false,
};
next();
});
app.use("/api", approvalRoutes({} as any));
app.use(errorHandler);
return app;
}
describe("approval routes idempotent retries", () => {
beforeEach(() => {
vi.clearAllMocks();
mockHeartbeatService.wakeup.mockResolvedValue({ id: "wake-1" });
mockIssueApprovalService.listIssuesForApproval.mockResolvedValue([{ id: "issue-1" }]);
mockLogActivity.mockResolvedValue(undefined);
});
it("does not emit duplicate approval side effects when approve is already resolved", async () => {
mockApprovalService.approve.mockResolvedValue({
approval: {
id: "approval-1",
companyId: "company-1",
type: "hire_agent",
status: "approved",
payload: {},
requestedByAgentId: "agent-1",
},
applied: false,
});
const res = await request(createApp())
.post("/api/approvals/approval-1/approve")
.send({});
expect(res.status).toBe(200);
expect(mockIssueApprovalService.listIssuesForApproval).not.toHaveBeenCalled();
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
expect(mockLogActivity).not.toHaveBeenCalled();
});
it("does not emit duplicate rejection logs when reject is already resolved", async () => {
mockApprovalService.reject.mockResolvedValue({
approval: {
id: "approval-1",
companyId: "company-1",
type: "hire_agent",
status: "rejected",
payload: {},
},
applied: false,
});
const res = await request(createApp())
.post("/api/approvals/approval-1/reject")
.send({});
expect(res.status).toBe(200);
expect(mockLogActivity).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,107 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { approvalService } from "../services/approvals.ts";
const mockAgentService = vi.hoisted(() => ({
activatePendingApproval: vi.fn(),
create: vi.fn(),
terminate: vi.fn(),
}));
const mockNotifyHireApproved = vi.hoisted(() => vi.fn());
vi.mock("../services/agents.js", () => ({
agentService: vi.fn(() => mockAgentService),
}));
vi.mock("../services/hire-hook.js", () => ({
notifyHireApproved: mockNotifyHireApproved,
}));
type ApprovalRecord = {
id: string;
companyId: string;
type: string;
status: string;
payload: Record<string, unknown>;
requestedByAgentId: string | null;
};
function createApproval(status: string): ApprovalRecord {
return {
id: "approval-1",
companyId: "company-1",
type: "hire_agent",
status,
payload: { agentId: "agent-1" },
requestedByAgentId: "requester-1",
};
}
function createDbStub(selectResults: ApprovalRecord[][], updateResults: ApprovalRecord[]) {
const pendingSelectResults = [...selectResults];
const selectWhere = vi.fn(async () => pendingSelectResults.shift() ?? []);
const from = vi.fn(() => ({ where: selectWhere }));
const select = vi.fn(() => ({ from }));
const returning = vi.fn(async () => updateResults);
const updateWhere = vi.fn(() => ({ returning }));
const set = vi.fn(() => ({ where: updateWhere }));
const update = vi.fn(() => ({ set }));
return {
db: { select, update },
selectWhere,
returning,
};
}
describe("approvalService resolution idempotency", () => {
beforeEach(() => {
vi.clearAllMocks();
mockAgentService.activatePendingApproval.mockResolvedValue(undefined);
mockAgentService.create.mockResolvedValue({ id: "agent-1" });
mockAgentService.terminate.mockResolvedValue(undefined);
mockNotifyHireApproved.mockResolvedValue(undefined);
});
it("treats repeated approve retries as no-ops after another worker resolves the approval", async () => {
const dbStub = createDbStub(
[[createApproval("pending")], [createApproval("approved")]],
[],
);
const svc = approvalService(dbStub.db as any);
const result = await svc.approve("approval-1", "board", "ship it");
expect(result.applied).toBe(false);
expect(result.approval.status).toBe("approved");
expect(mockAgentService.activatePendingApproval).not.toHaveBeenCalled();
expect(mockNotifyHireApproved).not.toHaveBeenCalled();
});
it("treats repeated reject retries as no-ops after another worker resolves the approval", async () => {
const dbStub = createDbStub(
[[createApproval("pending")], [createApproval("rejected")]],
[],
);
const svc = approvalService(dbStub.db as any);
const result = await svc.reject("approval-1", "board", "not now");
expect(result.applied).toBe(false);
expect(result.approval.status).toBe("rejected");
expect(mockAgentService.terminate).not.toHaveBeenCalled();
});
it("still performs side effects when the resolution update is newly applied", async () => {
const approved = createApproval("approved");
const dbStub = createDbStub([[createApproval("pending")]], [approved]);
const svc = approvalService(dbStub.db as any);
const result = await svc.approve("approval-1", "board", "ship it");
expect(result.applied).toBe(true);
expect(mockAgentService.activatePendingApproval).toHaveBeenCalledWith("agent-1");
expect(mockNotifyHireApproved).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,97 @@
import { describe, it, expect } from "vitest";
import {
parseAllowedTypes,
matchesContentType,
DEFAULT_ALLOWED_TYPES,
} from "../attachment-types.js";
describe("parseAllowedTypes", () => {
it("returns default image types when input is undefined", () => {
expect(parseAllowedTypes(undefined)).toEqual([...DEFAULT_ALLOWED_TYPES]);
});
it("returns default image types when input is empty string", () => {
expect(parseAllowedTypes("")).toEqual([...DEFAULT_ALLOWED_TYPES]);
});
it("parses comma-separated types", () => {
expect(parseAllowedTypes("image/*,application/pdf")).toEqual([
"image/*",
"application/pdf",
]);
});
it("trims whitespace", () => {
expect(parseAllowedTypes(" image/png , application/pdf ")).toEqual([
"image/png",
"application/pdf",
]);
});
it("lowercases entries", () => {
expect(parseAllowedTypes("Application/PDF")).toEqual(["application/pdf"]);
});
it("filters empty segments", () => {
expect(parseAllowedTypes("image/png,,application/pdf,")).toEqual([
"image/png",
"application/pdf",
]);
});
});
describe("matchesContentType", () => {
it("matches exact types", () => {
const patterns = ["application/pdf", "image/png"];
expect(matchesContentType("application/pdf", patterns)).toBe(true);
expect(matchesContentType("image/png", patterns)).toBe(true);
expect(matchesContentType("text/plain", patterns)).toBe(false);
});
it("matches /* wildcard patterns", () => {
const patterns = ["image/*"];
expect(matchesContentType("image/png", patterns)).toBe(true);
expect(matchesContentType("image/jpeg", patterns)).toBe(true);
expect(matchesContentType("image/svg+xml", patterns)).toBe(true);
expect(matchesContentType("application/pdf", patterns)).toBe(false);
});
it("matches .* wildcard patterns", () => {
const patterns = ["application/vnd.openxmlformats-officedocument.*"];
expect(
matchesContentType(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
patterns,
),
).toBe(true);
expect(
matchesContentType(
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
patterns,
),
).toBe(true);
expect(matchesContentType("application/pdf", patterns)).toBe(false);
});
it("is case-insensitive", () => {
const patterns = ["application/pdf"];
expect(matchesContentType("APPLICATION/PDF", patterns)).toBe(true);
expect(matchesContentType("Application/Pdf", patterns)).toBe(true);
});
it("combines exact and wildcard patterns", () => {
const patterns = ["image/*", "application/pdf", "text/*"];
expect(matchesContentType("image/webp", patterns)).toBe(true);
expect(matchesContentType("application/pdf", patterns)).toBe(true);
expect(matchesContentType("text/csv", patterns)).toBe(true);
expect(matchesContentType("application/zip", patterns)).toBe(false);
});
it("handles plain * as allow-all wildcard", () => {
const patterns = ["*"];
expect(matchesContentType("image/png", patterns)).toBe(true);
expect(matchesContentType("application/pdf", patterns)).toBe(true);
expect(matchesContentType("text/plain", patterns)).toBe(true);
expect(matchesContentType("application/zip", patterns)).toBe(true);
});
});

View File

@@ -4,6 +4,8 @@ import os from "node:os";
import path from "node:path";
import { testEnvironment } from "@paperclipai/adapter-codex-local/server";
const itWindows = process.platform === "win32" ? it : it.skip;
describe("codex_local environment diagnostics", () => {
it("creates a missing working directory when cwd is absolute", async () => {
const cwd = path.join(
@@ -29,4 +31,45 @@ describe("codex_local environment diagnostics", () => {
expect(stats.isDirectory()).toBe(true);
await fs.rm(path.dirname(cwd), { recursive: true, force: true });
});
itWindows("runs the hello probe when Codex is available via a Windows .cmd wrapper", async () => {
const root = path.join(
os.tmpdir(),
`paperclip-codex-local-probe-${Date.now()}-${Math.random().toString(16).slice(2)}`,
);
const binDir = path.join(root, "bin");
const cwd = path.join(root, "workspace");
const fakeCodex = path.join(binDir, "codex.cmd");
const script = [
"@echo off",
"echo {\"type\":\"thread.started\",\"thread_id\":\"test-thread\"}",
"echo {\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"hello\"}}",
"echo {\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":1,\"cached_input_tokens\":0,\"output_tokens\":1}}",
"exit /b 0",
"",
].join("\r\n");
try {
await fs.mkdir(binDir, { recursive: true });
await fs.writeFile(fakeCodex, script, "utf8");
const result = await testEnvironment({
companyId: "company-1",
adapterType: "codex_local",
config: {
command: "codex",
cwd,
env: {
OPENAI_API_KEY: "test-key",
PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}`,
},
},
});
expect(result.status).toBe("pass");
expect(result.checks.some((check) => check.code === "codex_hello_probe_passed")).toBe(true);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
});

View File

@@ -62,6 +62,7 @@ describe("execution workspace policy helpers", () => {
workspaceStrategy: {
type: "git_worktree",
baseRef: "origin/main",
provisionCommand: "bash ./scripts/provision-worktree.sh",
},
workspaceRuntime: {
services: [{ name: "web", command: "pnpm dev" }],
@@ -75,6 +76,7 @@ describe("execution workspace policy helpers", () => {
expect(result.workspaceStrategy).toEqual({
type: "git_worktree",
baseRef: "origin/main",
provisionCommand: "bash ./scripts/provision-worktree.sh",
});
expect(result.workspaceRuntime).toEqual({
services: [{ name: "web", command: "pnpm dev" }],
@@ -116,6 +118,8 @@ describe("execution workspace policy helpers", () => {
workspaceStrategy: {
type: "git_worktree",
worktreeParentDir: ".paperclip/worktrees",
provisionCommand: "bash ./scripts/provision-worktree.sh",
teardownCommand: "bash ./scripts/teardown-worktree.sh",
},
}),
).toEqual({
@@ -124,6 +128,8 @@ describe("execution workspace policy helpers", () => {
workspaceStrategy: {
type: "git_worktree",
worktreeParentDir: ".paperclip/worktrees",
provisionCommand: "bash ./scripts/provision-worktree.sh",
teardownCommand: "bash ./scripts/teardown-worktree.sh",
},
});
expect(

View File

@@ -125,6 +125,92 @@ describe("realizeExecutionWorkspace", () => {
expect(second.cwd).toBe(first.cwd);
expect(second.branchName).toBe(first.branchName);
});
it("runs a configured provision command inside the derived worktree", async () => {
const repoRoot = await createTempRepo();
await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true });
await fs.writeFile(
path.join(repoRoot, "scripts", "provision.sh"),
[
"#!/usr/bin/env bash",
"set -euo pipefail",
"printf '%s\\n' \"$PAPERCLIP_WORKSPACE_BRANCH\" > .paperclip-provision-branch",
"printf '%s\\n' \"$PAPERCLIP_WORKSPACE_BASE_CWD\" > .paperclip-provision-base",
"printf '%s\\n' \"$PAPERCLIP_WORKSPACE_CREATED\" > .paperclip-provision-created",
].join("\n"),
"utf8",
);
await runGit(repoRoot, ["add", "scripts/provision.sh"]);
await runGit(repoRoot, ["commit", "-m", "Add worktree provision script"]);
const workspace = await realizeExecutionWorkspace({
base: {
baseCwd: repoRoot,
source: "project_primary",
projectId: "project-1",
workspaceId: "workspace-1",
repoUrl: null,
repoRef: "HEAD",
},
config: {
workspaceStrategy: {
type: "git_worktree",
branchTemplate: "{{issue.identifier}}-{{slug}}",
provisionCommand: "bash ./scripts/provision.sh",
},
},
issue: {
id: "issue-1",
identifier: "PAP-448",
title: "Run provision command",
},
agent: {
id: "agent-1",
name: "Codex Coder",
companyId: "company-1",
},
});
await expect(fs.readFile(path.join(workspace.cwd, ".paperclip-provision-branch"), "utf8")).resolves.toBe(
"PAP-448-run-provision-command\n",
);
await expect(fs.readFile(path.join(workspace.cwd, ".paperclip-provision-base"), "utf8")).resolves.toBe(
`${repoRoot}\n`,
);
await expect(fs.readFile(path.join(workspace.cwd, ".paperclip-provision-created"), "utf8")).resolves.toBe(
"true\n",
);
const reused = await realizeExecutionWorkspace({
base: {
baseCwd: repoRoot,
source: "project_primary",
projectId: "project-1",
workspaceId: "workspace-1",
repoUrl: null,
repoRef: "HEAD",
},
config: {
workspaceStrategy: {
type: "git_worktree",
branchTemplate: "{{issue.identifier}}-{{slug}}",
provisionCommand: "bash ./scripts/provision.sh",
},
},
issue: {
id: "issue-1",
identifier: "PAP-448",
title: "Run provision command",
},
agent: {
id: "agent-1",
name: "Codex Coder",
companyId: "company-1",
},
});
await expect(fs.readFile(path.join(reused.cwd, ".paperclip-provision-created"), "utf8")).resolves.toBe("false\n");
});
});
describe("ensureRuntimeServicesForRun", () => {

View File

@@ -0,0 +1,68 @@
/**
* Shared attachment content-type configuration.
*
* By default only image types are allowed. Set the
* `PAPERCLIP_ALLOWED_ATTACHMENT_TYPES` environment variable to a
* comma-separated list of MIME types or wildcard patterns to expand the
* allowed set.
*
* Examples:
* PAPERCLIP_ALLOWED_ATTACHMENT_TYPES=image/*,application/pdf
* PAPERCLIP_ALLOWED_ATTACHMENT_TYPES=image/*,application/pdf,text/*
*
* Supported pattern syntax:
* - Exact types: "application/pdf"
* - Wildcards: "image/*" or "application/vnd.openxmlformats-officedocument.*"
*/
export const DEFAULT_ALLOWED_TYPES: readonly string[] = [
"image/png",
"image/jpeg",
"image/jpg",
"image/webp",
"image/gif",
];
/**
* Parse a comma-separated list of MIME type patterns into a normalised array.
* Returns the default image-only list when the input is empty or undefined.
*/
export function parseAllowedTypes(raw: string | undefined): string[] {
if (!raw) return [...DEFAULT_ALLOWED_TYPES];
const parsed = raw
.split(",")
.map((s) => s.trim().toLowerCase())
.filter((s) => s.length > 0);
return parsed.length > 0 ? parsed : [...DEFAULT_ALLOWED_TYPES];
}
/**
* Check whether `contentType` matches any entry in `allowedPatterns`.
*
* Supports exact matches ("application/pdf") and wildcard / prefix
* patterns ("image/*", "application/vnd.openxmlformats-officedocument.*").
*/
export function matchesContentType(contentType: string, allowedPatterns: string[]): boolean {
const ct = contentType.toLowerCase();
return allowedPatterns.some((pattern) => {
if (pattern === "*") return true;
if (pattern.endsWith("/*") || pattern.endsWith(".*")) {
return ct.startsWith(pattern.slice(0, -1));
}
return ct === pattern;
});
}
// ---------- Module-level singletons read once at startup ----------
const allowedPatterns: string[] = parseAllowedTypes(
process.env.PAPERCLIP_ALLOWED_ATTACHMENT_TYPES,
);
/** Convenience wrapper using the process-level allowed list. */
export function isAllowedContentType(contentType: string): boolean {
return matchesContentType(contentType, allowedPatterns);
}
export const MAX_ATTACHMENT_BYTES =
Number(process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES) || 10 * 1024 * 1024;

View File

@@ -121,85 +121,92 @@ export function approvalRoutes(db: Db) {
router.post("/approvals/:id/approve", validate(resolveApprovalSchema), async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
const approval = await svc.approve(id, req.body.decidedByUserId ?? "board", req.body.decisionNote);
const linkedIssues = await issueApprovalsSvc.listIssuesForApproval(approval.id);
const linkedIssueIds = linkedIssues.map((issue) => issue.id);
const primaryIssueId = linkedIssueIds[0] ?? null;
const { approval, applied } = await svc.approve(
id,
req.body.decidedByUserId ?? "board",
req.body.decisionNote,
);
await logActivity(db, {
companyId: approval.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "approval.approved",
entityType: "approval",
entityId: approval.id,
details: {
type: approval.type,
requestedByAgentId: approval.requestedByAgentId,
linkedIssueIds,
},
});
if (applied) {
const linkedIssues = await issueApprovalsSvc.listIssuesForApproval(approval.id);
const linkedIssueIds = linkedIssues.map((issue) => issue.id);
const primaryIssueId = linkedIssueIds[0] ?? null;
if (approval.requestedByAgentId) {
try {
const wakeRun = await heartbeat.wakeup(approval.requestedByAgentId, {
source: "automation",
triggerDetail: "system",
reason: "approval_approved",
payload: {
approvalId: approval.id,
approvalStatus: approval.status,
issueId: primaryIssueId,
issueIds: linkedIssueIds,
},
requestedByActorType: "user",
requestedByActorId: req.actor.userId ?? "board",
contextSnapshot: {
source: "approval.approved",
approvalId: approval.id,
approvalStatus: approval.status,
issueId: primaryIssueId,
issueIds: linkedIssueIds,
taskId: primaryIssueId,
wakeReason: "approval_approved",
},
});
await logActivity(db, {
companyId: approval.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "approval.approved",
entityType: "approval",
entityId: approval.id,
details: {
type: approval.type,
requestedByAgentId: approval.requestedByAgentId,
linkedIssueIds,
},
});
await logActivity(db, {
companyId: approval.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "approval.requester_wakeup_queued",
entityType: "approval",
entityId: approval.id,
details: {
requesterAgentId: approval.requestedByAgentId,
wakeRunId: wakeRun?.id ?? null,
linkedIssueIds,
},
});
} catch (err) {
logger.warn(
{
err,
approvalId: approval.id,
requestedByAgentId: approval.requestedByAgentId,
},
"failed to queue requester wakeup after approval",
);
await logActivity(db, {
companyId: approval.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "approval.requester_wakeup_failed",
entityType: "approval",
entityId: approval.id,
details: {
requesterAgentId: approval.requestedByAgentId,
linkedIssueIds,
error: err instanceof Error ? err.message : String(err),
},
});
if (approval.requestedByAgentId) {
try {
const wakeRun = await heartbeat.wakeup(approval.requestedByAgentId, {
source: "automation",
triggerDetail: "system",
reason: "approval_approved",
payload: {
approvalId: approval.id,
approvalStatus: approval.status,
issueId: primaryIssueId,
issueIds: linkedIssueIds,
},
requestedByActorType: "user",
requestedByActorId: req.actor.userId ?? "board",
contextSnapshot: {
source: "approval.approved",
approvalId: approval.id,
approvalStatus: approval.status,
issueId: primaryIssueId,
issueIds: linkedIssueIds,
taskId: primaryIssueId,
wakeReason: "approval_approved",
},
});
await logActivity(db, {
companyId: approval.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "approval.requester_wakeup_queued",
entityType: "approval",
entityId: approval.id,
details: {
requesterAgentId: approval.requestedByAgentId,
wakeRunId: wakeRun?.id ?? null,
linkedIssueIds,
},
});
} catch (err) {
logger.warn(
{
err,
approvalId: approval.id,
requestedByAgentId: approval.requestedByAgentId,
},
"failed to queue requester wakeup after approval",
);
await logActivity(db, {
companyId: approval.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "approval.requester_wakeup_failed",
entityType: "approval",
entityId: approval.id,
details: {
requesterAgentId: approval.requestedByAgentId,
linkedIssueIds,
error: err instanceof Error ? err.message : String(err),
},
});
}
}
}
@@ -209,17 +216,23 @@ export function approvalRoutes(db: Db) {
router.post("/approvals/:id/reject", validate(resolveApprovalSchema), async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
const approval = await svc.reject(id, req.body.decidedByUserId ?? "board", req.body.decisionNote);
const { approval, applied } = await svc.reject(
id,
req.body.decidedByUserId ?? "board",
req.body.decisionNote,
);
await logActivity(db, {
companyId: approval.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "approval.rejected",
entityType: "approval",
entityId: approval.id,
details: { type: approval.type },
});
if (applied) {
await logActivity(db, {
companyId: approval.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "approval.rejected",
entityType: "approval",
entityId: approval.id,
details: { type: approval.type },
});
}
res.json(redactApprovalPayload(approval));
});

View File

@@ -5,22 +5,14 @@ import { createAssetImageMetadataSchema } from "@paperclipai/shared";
import type { StorageService } from "../storage/types.js";
import { assetService, logActivity } from "../services/index.js";
import { assertCompanyAccess, getActorInfo } from "./authz.js";
const MAX_ASSET_IMAGE_BYTES = Number(process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES) || 10 * 1024 * 1024;
const ALLOWED_IMAGE_CONTENT_TYPES = new Set([
"image/png",
"image/jpeg",
"image/jpg",
"image/webp",
"image/gif",
]);
import { isAllowedContentType, MAX_ATTACHMENT_BYTES } from "../attachment-types.js";
export function assetRoutes(db: Db, storage: StorageService) {
const router = Router();
const svc = assetService(db);
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: MAX_ASSET_IMAGE_BYTES, files: 1 },
limits: { fileSize: MAX_ATTACHMENT_BYTES, files: 1 },
});
async function runSingleFileUpload(req: Request, res: Response) {
@@ -41,7 +33,7 @@ export function assetRoutes(db: Db, storage: StorageService) {
} catch (err) {
if (err instanceof multer.MulterError) {
if (err.code === "LIMIT_FILE_SIZE") {
res.status(422).json({ error: `Image exceeds ${MAX_ASSET_IMAGE_BYTES} bytes` });
res.status(422).json({ error: `File exceeds ${MAX_ATTACHMENT_BYTES} bytes` });
return;
}
res.status(400).json({ error: err.message });
@@ -57,8 +49,8 @@ export function assetRoutes(db: Db, storage: StorageService) {
}
const contentType = (file.mimetype || "").toLowerCase();
if (!ALLOWED_IMAGE_CONTENT_TYPES.has(contentType)) {
res.status(422).json({ error: `Unsupported image type: ${contentType || "unknown"}` });
if (!isAllowedContentType(contentType)) {
res.status(422).json({ error: `Unsupported file type: ${contentType || "unknown"}` });
return;
}
if (file.buffer.length <= 0) {

View File

@@ -1,7 +1,7 @@
import { Router } from "express";
import type { Db } from "@paperclipai/db";
import { count, sql } from "drizzle-orm";
import { instanceUserRoles } from "@paperclipai/db";
import { and, count, eq, gt, isNull, sql } from "drizzle-orm";
import { instanceUserRoles, invites } from "@paperclipai/db";
import type { DeploymentExposure, DeploymentMode } from "@paperclipai/shared";
export function healthRoutes(
@@ -27,6 +27,7 @@ export function healthRoutes(
}
let bootstrapStatus: "ready" | "bootstrap_pending" = "ready";
let bootstrapInviteActive = false;
if (opts.deploymentMode === "authenticated") {
const roleCount = await db
.select({ count: count() })
@@ -34,6 +35,23 @@ export function healthRoutes(
.where(sql`${instanceUserRoles.role} = 'instance_admin'`)
.then((rows) => Number(rows[0]?.count ?? 0));
bootstrapStatus = roleCount > 0 ? "ready" : "bootstrap_pending";
if (bootstrapStatus === "bootstrap_pending") {
const now = new Date();
const inviteCount = await db
.select({ count: count() })
.from(invites)
.where(
and(
eq(invites.inviteType, "bootstrap_ceo"),
isNull(invites.revokedAt),
isNull(invites.acceptedAt),
gt(invites.expiresAt, now),
),
)
.then((rows) => Number(rows[0]?.count ?? 0));
bootstrapInviteActive = inviteCount > 0;
}
}
res.json({
@@ -42,6 +60,7 @@ export function healthRoutes(
deploymentExposure: opts.deploymentExposure,
authReady: opts.authReady,
bootstrapStatus,
bootstrapInviteActive,
features: {
companyDeletionEnabled: opts.companyDeletionEnabled,
},

View File

@@ -26,15 +26,7 @@ import { logger } from "../middleware/logger.js";
import { forbidden, HttpError, unauthorized } from "../errors.js";
import { assertCompanyAccess, getActorInfo } from "./authz.js";
import { shouldWakeAssigneeOnCheckout } from "./issues-checkout-wakeup.js";
const MAX_ATTACHMENT_BYTES = Number(process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES) || 10 * 1024 * 1024;
const ALLOWED_ATTACHMENT_CONTENT_TYPES = new Set([
"image/png",
"image/jpeg",
"image/jpg",
"image/webp",
"image/gif",
]);
import { isAllowedContentType, MAX_ATTACHMENT_BYTES } from "../attachment-types.js";
export function issueRoutes(db: Db, storage: StorageService) {
const router = Router();
@@ -230,6 +222,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
touchedByUserId,
unreadForUserId,
projectId: req.query.projectId as string | undefined,
parentId: req.query.parentId as string | undefined,
labelId: req.query.labelId as string | undefined,
q: req.query.q as string | undefined,
});
@@ -1067,7 +1060,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
return;
}
const contentType = (file.mimetype || "").toLowerCase();
if (!ALLOWED_ATTACHMENT_CONTENT_TYPES.has(contentType)) {
if (!isAllowedContentType(contentType)) {
res.status(422).json({ error: `Unsupported attachment type: ${contentType || "unknown"}` });
return;
}

View File

@@ -1,4 +1,4 @@
import { and, asc, eq } from "drizzle-orm";
import { and, asc, eq, inArray } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { approvalComments, approvals } from "@paperclipai/db";
import { notFound, unprocessable } from "../errors.js";
@@ -8,6 +8,9 @@ import { notifyHireApproved } from "./hire-hook.js";
export function approvalService(db: Db) {
const agentsSvc = agentService(db);
const canResolveStatuses = new Set(["pending", "revision_requested"]);
const resolvableStatuses = Array.from(canResolveStatuses);
type ApprovalRecord = typeof approvals.$inferSelect;
type ResolutionResult = { approval: ApprovalRecord; applied: boolean };
async function getExistingApproval(id: string) {
const existing = await db
@@ -19,6 +22,50 @@ export function approvalService(db: Db) {
return existing;
}
async function resolveApproval(
id: string,
targetStatus: "approved" | "rejected",
decidedByUserId: string,
decisionNote: string | null | undefined,
): Promise<ResolutionResult> {
const existing = await getExistingApproval(id);
if (!canResolveStatuses.has(existing.status)) {
if (existing.status === targetStatus) {
return { approval: existing, applied: false };
}
throw unprocessable(
`Only pending or revision requested approvals can be ${targetStatus === "approved" ? "approved" : "rejected"}`,
);
}
const now = new Date();
const updated = await db
.update(approvals)
.set({
status: targetStatus,
decidedByUserId,
decisionNote: decisionNote ?? null,
decidedAt: now,
updatedAt: now,
})
.where(and(eq(approvals.id, id), inArray(approvals.status, resolvableStatuses)))
.returning()
.then((rows) => rows[0] ?? null);
if (updated) {
return { approval: updated, applied: true };
}
const latest = await getExistingApproval(id);
if (latest.status === targetStatus) {
return { approval: latest, applied: false };
}
throw unprocessable(
`Only pending or revision requested approvals can be ${targetStatus === "approved" ? "approved" : "rejected"}`,
);
}
return {
list: (companyId: string, status?: string) => {
const conditions = [eq(approvals.companyId, companyId)];
@@ -41,27 +88,16 @@ export function approvalService(db: Db) {
.then((rows) => rows[0]),
approve: async (id: string, decidedByUserId: string, decisionNote?: string | null) => {
const existing = await getExistingApproval(id);
if (!canResolveStatuses.has(existing.status)) {
throw unprocessable("Only pending or revision requested approvals can be approved");
}
const now = new Date();
const updated = await db
.update(approvals)
.set({
status: "approved",
decidedByUserId,
decisionNote: decisionNote ?? null,
decidedAt: now,
updatedAt: now,
})
.where(eq(approvals.id, id))
.returning()
.then((rows) => rows[0]);
const { approval: updated, applied } = await resolveApproval(
id,
"approved",
decidedByUserId,
decisionNote,
);
let hireApprovedAgentId: string | null = null;
if (updated.type === "hire_agent") {
const now = new Date();
if (applied && updated.type === "hire_agent") {
const payload = updated.payload as Record<string, unknown>;
const payloadAgentId = typeof payload.agentId === "string" ? payload.agentId : null;
if (payloadAgentId) {
@@ -103,30 +139,18 @@ export function approvalService(db: Db) {
}
}
return updated;
return { approval: updated, applied };
},
reject: async (id: string, decidedByUserId: string, decisionNote?: string | null) => {
const existing = await getExistingApproval(id);
if (!canResolveStatuses.has(existing.status)) {
throw unprocessable("Only pending or revision requested approvals can be rejected");
}
const { approval: updated, applied } = await resolveApproval(
id,
"rejected",
decidedByUserId,
decisionNote,
);
const now = new Date();
const updated = await db
.update(approvals)
.set({
status: "rejected",
decidedByUserId,
decisionNote: decisionNote ?? null,
decidedAt: now,
updatedAt: now,
})
.where(eq(approvals.id, id))
.returning()
.then((rows) => rows[0]);
if (updated.type === "hire_agent") {
if (applied && updated.type === "hire_agent") {
const payload = updated.payload as Record<string, unknown>;
const payloadAgentId = typeof payload.agentId === "string" ? payload.agentId : null;
if (payloadAgentId) {
@@ -134,7 +158,7 @@ export function approvalService(db: Db) {
}
}
return updated;
return { approval: updated, applied };
},
requestRevision: async (id: string, decidedByUserId: string, decisionNote?: string | null) => {

View File

@@ -24,6 +24,8 @@ function parseExecutionWorkspaceStrategy(raw: unknown): ExecutionWorkspaceStrate
...(typeof parsed.baseRef === "string" ? { baseRef: parsed.baseRef } : {}),
...(typeof parsed.branchTemplate === "string" ? { branchTemplate: parsed.branchTemplate } : {}),
...(typeof parsed.worktreeParentDir === "string" ? { worktreeParentDir: parsed.worktreeParentDir } : {}),
...(typeof parsed.provisionCommand === "string" ? { provisionCommand: parsed.provisionCommand } : {}),
...(typeof parsed.teardownCommand === "string" ? { teardownCommand: parsed.teardownCommand } : {}),
};
}

View File

@@ -57,6 +57,7 @@ export interface IssueFilters {
touchedByUserId?: string;
unreadForUserId?: string;
projectId?: string;
parentId?: string;
labelId?: string;
q?: string;
}
@@ -462,6 +463,7 @@ export function issueService(db: Db) {
conditions.push(unreadForUserCondition(companyId, unreadForUserId));
}
if (filters?.projectId) conditions.push(eq(issues.projectId, filters.projectId));
if (filters?.parentId) conditions.push(eq(issues.parentId, filters.parentId));
if (filters?.labelId) {
const labeledIssueIds = await db
.select({ issueId: issueLabels.issueId })

View File

@@ -236,6 +236,100 @@ async function directoryExists(value: string) {
return fs.stat(value).then((stats) => stats.isDirectory()).catch(() => false);
}
function buildWorkspaceCommandEnv(input: {
base: ExecutionWorkspaceInput;
repoRoot: string;
worktreePath: string;
branchName: string;
issue: ExecutionWorkspaceIssueRef | null;
agent: ExecutionWorkspaceAgentRef;
created: boolean;
}) {
const env: NodeJS.ProcessEnv = { ...process.env };
env.PAPERCLIP_WORKSPACE_CWD = input.worktreePath;
env.PAPERCLIP_WORKSPACE_PATH = input.worktreePath;
env.PAPERCLIP_WORKSPACE_WORKTREE_PATH = input.worktreePath;
env.PAPERCLIP_WORKSPACE_BRANCH = input.branchName;
env.PAPERCLIP_WORKSPACE_BASE_CWD = input.base.baseCwd;
env.PAPERCLIP_WORKSPACE_REPO_ROOT = input.repoRoot;
env.PAPERCLIP_WORKSPACE_SOURCE = input.base.source;
env.PAPERCLIP_WORKSPACE_REPO_REF = input.base.repoRef ?? "";
env.PAPERCLIP_WORKSPACE_REPO_URL = input.base.repoUrl ?? "";
env.PAPERCLIP_WORKSPACE_CREATED = input.created ? "true" : "false";
env.PAPERCLIP_PROJECT_ID = input.base.projectId ?? "";
env.PAPERCLIP_PROJECT_WORKSPACE_ID = input.base.workspaceId ?? "";
env.PAPERCLIP_AGENT_ID = input.agent.id;
env.PAPERCLIP_AGENT_NAME = input.agent.name;
env.PAPERCLIP_COMPANY_ID = input.agent.companyId;
env.PAPERCLIP_ISSUE_ID = input.issue?.id ?? "";
env.PAPERCLIP_ISSUE_IDENTIFIER = input.issue?.identifier ?? "";
env.PAPERCLIP_ISSUE_TITLE = input.issue?.title ?? "";
return env;
}
async function runWorkspaceCommand(input: {
command: string;
cwd: string;
env: NodeJS.ProcessEnv;
label: string;
}) {
const shell = process.env.SHELL?.trim() || "/bin/sh";
const proc = await new Promise<{ stdout: string; stderr: string; code: number | null }>((resolve, reject) => {
const child = spawn(shell, ["-c", input.command], {
cwd: input.cwd,
env: input.env,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout?.on("data", (chunk) => {
stdout += String(chunk);
});
child.stderr?.on("data", (chunk) => {
stderr += String(chunk);
});
child.on("error", reject);
child.on("close", (code) => resolve({ stdout, stderr, code }));
});
if (proc.code === 0) return;
const details = [proc.stderr.trim(), proc.stdout.trim()].filter(Boolean).join("\n");
throw new Error(
details.length > 0
? `${input.label} failed: ${details}`
: `${input.label} failed with exit code ${proc.code ?? -1}`,
);
}
async function provisionExecutionWorktree(input: {
strategy: Record<string, unknown>;
base: ExecutionWorkspaceInput;
repoRoot: string;
worktreePath: string;
branchName: string;
issue: ExecutionWorkspaceIssueRef | null;
agent: ExecutionWorkspaceAgentRef;
created: boolean;
}) {
const provisionCommand = asString(input.strategy.provisionCommand, "").trim();
if (!provisionCommand) return;
await runWorkspaceCommand({
command: provisionCommand,
cwd: input.worktreePath,
env: buildWorkspaceCommandEnv({
base: input.base,
repoRoot: input.repoRoot,
worktreePath: input.worktreePath,
branchName: input.branchName,
issue: input.issue,
agent: input.agent,
created: input.created,
}),
label: `Execution workspace provision command "${provisionCommand}"`,
});
}
export async function realizeExecutionWorkspace(input: {
base: ExecutionWorkspaceInput;
config: Record<string, unknown>;
@@ -278,6 +372,16 @@ export async function realizeExecutionWorkspace(input: {
if (existingWorktree) {
const existingGitDir = await runGit(["rev-parse", "--git-dir"], worktreePath).catch(() => null);
if (existingGitDir) {
await provisionExecutionWorktree({
strategy: rawStrategy,
base: input.base,
repoRoot,
worktreePath,
branchName,
issue: input.issue,
agent: input.agent,
created: false,
});
return {
...input.base,
strategy: "git_worktree",
@@ -292,6 +396,16 @@ export async function realizeExecutionWorkspace(input: {
}
await runGit(["worktree", "add", "-B", branchName, worktreePath, baseRef], repoRoot);
await provisionExecutionWorktree({
strategy: rawStrategy,
base: input.base,
repoRoot,
worktreePath,
branchName,
issue: input.issue,
agent: input.agent,
created: true,
});
return {
...input.base,

View File

@@ -1,5 +1,5 @@
{
"extends": "../tsconfig.json",
"extends": "../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"

202
skills/pr-report/SKILL.md Normal file
View File

@@ -0,0 +1,202 @@
---
name: pr-report
description: >
Review a pull request or contribution deeply, explain it tutorial-style for a
maintainer, and produce a polished report artifact such as HTML or Markdown.
Use when asked to analyze a PR, explain a contributor's design decisions,
compare it with similar systems, or prepare a merge recommendation.
---
# PR Report Skill
Produce a maintainer-grade review of a PR, branch, or large contribution.
Default posture:
- understand the change before judging it
- explain the system as built, not just the diff
- separate architectural problems from product-scope objections
- make a concrete recommendation, not a vague impression
## When to Use
Use this skill when the user asks for things like:
- "review this PR deeply"
- "explain this contribution to me"
- "make me a report or webpage for this PR"
- "compare this design to similar systems"
- "should I merge this?"
## Outputs
Common outputs:
- standalone HTML report in `tmp/reports/...`
- Markdown report in `report/` or another requested folder
- short maintainer summary in chat
If the user asks for a webpage, build a polished standalone HTML artifact with
clear sections and readable visual hierarchy.
Resources bundled with this skill:
- `references/style-guide.md` for visual direction and report presentation rules
- `assets/html-report-starter.html` for a reusable standalone HTML/CSS starter
## Workflow
### 1. Acquire and frame the target
Work from local code when possible, not just the GitHub PR page.
Gather:
- target branch or worktree
- diff size and changed subsystems
- relevant repo docs, specs, and invariants
- contributor intent if it is documented in PR text or design docs
Start by answering: what is this change *trying* to become?
### 2. Build a mental model of the system
Do not stop at file-by-file notes. Reconstruct the design:
- what new runtime or contract exists
- which layers changed: db, shared types, server, UI, CLI, docs
- lifecycle: install, startup, execution, UI, failure, disablement
- trust boundary: what code runs where, under what authority
For large contributions, include a tutorial-style section that teaches the
system from first principles.
### 3. Review like a maintainer
Findings come first. Order by severity.
Prioritize:
- behavioral regressions
- trust or security gaps
- misleading abstractions
- lifecycle and operational risks
- coupling that will be hard to unwind
- missing tests or unverifiable claims
Always cite concrete file references when possible.
### 4. Distinguish the objection type
Be explicit about whether a concern is:
- product direction
- architecture
- implementation quality
- rollout strategy
- documentation honesty
Do not hide an architectural objection inside a scope objection.
### 5. Compare to external precedents when needed
If the contribution introduces a framework or platform concept, compare it to
similar open-source systems.
When comparing:
- prefer official docs or source
- focus on extension boundaries, context passing, trust model, and UI ownership
- extract lessons, not just similarities
Good comparison questions:
- Who owns lifecycle?
- Who owns UI composition?
- Is context explicit or ambient?
- Are plugins trusted code or sandboxed code?
- Are extension points named and typed?
### 6. Make the recommendation actionable
Do not stop at "merge" or "do not merge."
Choose one:
- merge as-is
- merge after specific redesign
- salvage specific pieces
- keep as design research
If rejecting or narrowing, say what should be kept.
Useful recommendation buckets:
- keep the protocol/type model
- redesign the UI boundary
- narrow the initial surface area
- defer third-party execution
- ship a host-owned extension-point model first
### 7. Build the artifact
Suggested report structure:
1. Executive summary
2. What the PR actually adds
3. Tutorial: how the system works
4. Strengths
5. Main findings
6. Comparisons
7. Recommendation
For HTML reports:
- use intentional typography and color
- make navigation easy for long reports
- favor strong section headings and small reference labels
- avoid generic dashboard styling
Before building from scratch, read `references/style-guide.md`.
If a fast polished starter is helpful, begin from `assets/html-report-starter.html`
and replace the placeholder content with the actual report.
### 8. Verify before handoff
Check:
- artifact path exists
- findings still match the actual code
- any requested forbidden strings are absent from generated output
- if tests were not run, say so explicitly
## Review Heuristics
### Plugin and platform work
Watch closely for:
- docs claiming sandboxing while runtime executes trusted host processes
- module-global state used to smuggle React context
- hidden dependence on render order
- plugins reaching into host internals instead of using explicit APIs
- "capabilities" that are really policy labels on top of fully trusted code
### Good signs
- typed contracts shared across layers
- explicit extension points
- host-owned lifecycle
- honest trust model
- narrow first rollout with room to grow
## Final Response
In chat, summarize:
- where the report is
- your overall call
- the top one or two reasons
- whether verification or tests were skipped
Keep the chat summary shorter than the report itself.

View File

@@ -0,0 +1,426 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>PR Report Starter</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=Newsreader:opsz,wght@6..72,500;6..72,700&display=swap"
rel="stylesheet"
/>
<style>
:root {
--bg: #f4efe5;
--paper: rgba(255, 251, 244, 0.88);
--paper-strong: #fffaf1;
--ink: #1f1b17;
--muted: #6a6257;
--line: rgba(31, 27, 23, 0.12);
--accent: #9c4729;
--accent-soft: rgba(156, 71, 41, 0.1);
--good: #2f6a42;
--warn: #946200;
--bad: #8c2f25;
--shadow: 0 22px 60px rgba(52, 37, 19, 0.1);
--radius: 20px;
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
margin: 0;
color: var(--ink);
font-family: "IBM Plex Sans", sans-serif;
background:
radial-gradient(circle at top left, rgba(156, 71, 41, 0.12), transparent 34rem),
radial-gradient(circle at top right, rgba(47, 106, 66, 0.08), transparent 28rem),
linear-gradient(180deg, #efe6d6 0%, var(--bg) 48%, #ece5d8 100%);
}
.shell {
width: min(1360px, calc(100vw - 32px));
margin: 24px auto;
display: grid;
grid-template-columns: 280px minmax(0, 1fr);
gap: 24px;
}
.panel {
background: var(--paper);
backdrop-filter: blur(12px);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.nav {
position: sticky;
top: 20px;
align-self: start;
padding: 22px;
}
.eyebrow {
letter-spacing: 0.12em;
text-transform: uppercase;
font-size: 11px;
font-weight: 700;
color: var(--accent);
}
.nav h1,
.hero h1,
h2,
h3 {
font-family: "Newsreader", serif;
line-height: 0.96;
margin: 0;
}
.nav h1 {
font-size: 2rem;
margin-top: 10px;
}
.nav p {
color: var(--muted);
font-size: 0.95rem;
line-height: 1.5;
}
.nav ul {
list-style: none;
padding: 0;
margin: 18px 0 0;
display: grid;
gap: 10px;
}
.nav a {
display: block;
color: var(--ink);
text-decoration: none;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid transparent;
background: rgba(255, 255, 255, 0.35);
}
.nav a:hover {
border-color: var(--line);
background: rgba(255, 255, 255, 0.75);
}
.meta-block {
margin-top: 20px;
padding-top: 18px;
border-top: 1px solid var(--line);
color: var(--muted);
font-size: 0.86rem;
line-height: 1.5;
}
main {
display: grid;
gap: 24px;
}
section {
padding: 26px 28px 28px;
}
.hero {
padding: 28px;
overflow: hidden;
position: relative;
}
.hero::after {
content: "";
position: absolute;
inset: auto -3rem -6rem auto;
width: 18rem;
height: 18rem;
border-radius: 50%;
background: radial-gradient(circle, rgba(156, 71, 41, 0.14), transparent 68%);
pointer-events: none;
}
.hero h1 {
font-size: clamp(2.6rem, 5vw, 4.6rem);
max-width: 12ch;
margin-top: 12px;
}
.lede {
margin-top: 16px;
max-width: 70ch;
font-size: 1.05rem;
line-height: 1.65;
color: #2b2723;
}
.hero-grid,
.card-grid,
.two-col {
display: grid;
gap: 14px;
}
.hero-grid {
margin-top: 24px;
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.card-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.two-col {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.metric,
.card,
.finding {
padding: 18px;
background: rgba(255, 255, 255, 0.68);
border: 1px solid var(--line);
border-radius: 18px;
}
.metric .label {
color: var(--muted);
font-size: 0.82rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.metric .value {
margin-top: 8px;
font-size: 1.45rem;
font-weight: 700;
}
h2 {
font-size: 2rem;
margin-bottom: 16px;
}
h3 {
font-size: 1.3rem;
margin-bottom: 10px;
}
p {
margin: 0 0 14px;
line-height: 1.65;
}
ul,
ol {
margin: 0;
padding-left: 20px;
line-height: 1.65;
}
li + li {
margin-top: 8px;
}
.badge-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 18px 0 8px;
}
.badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: 999px;
font-size: 0.82rem;
font-weight: 700;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.68);
}
.badge.good {
color: var(--good);
}
.badge.warn {
color: var(--warn);
}
.badge.bad {
color: var(--bad);
}
.quote {
margin-top: 18px;
padding: 18px;
border-left: 4px solid var(--accent);
border-radius: 14px;
background: var(--accent-soft);
}
.severity {
display: inline-flex;
margin-bottom: 12px;
padding: 6px 10px;
border-radius: 999px;
font-size: 0.78rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.severity.high {
background: rgba(140, 47, 37, 0.12);
color: var(--bad);
}
.severity.medium {
background: rgba(148, 98, 0, 0.12);
color: var(--warn);
}
.severity.low {
background: rgba(47, 106, 66, 0.12);
color: var(--good);
}
.ref {
color: var(--muted);
font-size: 0.82rem;
line-height: 1.5;
}
@media (max-width: 980px) {
.shell {
grid-template-columns: 1fr;
}
.nav {
position: static;
}
.hero-grid,
.card-grid,
.two-col {
grid-template-columns: 1fr;
}
.hero h1 {
max-width: 100%;
}
}
</style>
</head>
<body>
<div class="shell">
<aside class="panel nav">
<div class="eyebrow">Maintainer Report</div>
<h1>Report Title</h1>
<p>Replace this with a concise description of what the report covers.</p>
<ul>
<li><a href="#summary">Summary</a></li>
<li><a href="#tutorial">Tutorial</a></li>
<li><a href="#findings">Findings</a></li>
<li><a href="#recommendation">Recommendation</a></li>
</ul>
<div class="meta-block">
Replace with project metadata, review date, or scope notes.
</div>
</aside>
<main>
<section class="panel hero" id="summary">
<div class="eyebrow">Executive Summary</div>
<h1>Use the hero for the clearest one-line judgment.</h1>
<p class="lede">
Replace this with the short explanation of what the contribution does, why it matters,
and what the core maintainer question is.
</p>
<div class="badge-row">
<span class="badge good">Strength</span>
<span class="badge warn">Tradeoff</span>
<span class="badge bad">Risk</span>
</div>
<div class="hero-grid">
<div class="metric">
<div class="label">Overall Call</div>
<div class="value">Placeholder</div>
</div>
<div class="metric">
<div class="label">Main Concern</div>
<div class="value">Placeholder</div>
</div>
<div class="metric">
<div class="label">Best Part</div>
<div class="value">Placeholder</div>
</div>
<div class="metric">
<div class="label">Weakest Part</div>
<div class="value">Placeholder</div>
</div>
</div>
<div class="quote">
Use this block for the thesis, a sharp takeaway, or a key cited point.
</div>
</section>
<section class="panel" id="tutorial">
<h2>Tutorial Section</h2>
<div class="two-col">
<div class="card">
<h3>Concept Card</h3>
<p>Use cards for mental models, subsystems, or comparison slices.</p>
<div class="ref">path/to/file.ts:10</div>
</div>
<div class="card">
<h3>Second Card</h3>
<p>Keep cards fairly dense. This template is about style, not fixed structure.</p>
<div class="ref">path/to/file.ts:20</div>
</div>
</div>
</section>
<section class="panel" id="findings">
<h2>Findings</h2>
<article class="finding">
<div class="severity high">High</div>
<h3>Finding Title</h3>
<p>Use findings for the sharpest judgment calls and risks.</p>
<div class="ref">path/to/file.ts:30</div>
</article>
</section>
<section class="panel" id="recommendation">
<h2>Recommendation</h2>
<div class="card-grid">
<div class="card">
<h3>Path Forward</h3>
<p>Use this area for merge guidance, salvage plan, or rollout advice.</p>
</div>
<div class="card">
<h3>What To Keep</h3>
<p>Call out the parts worth preserving even if the whole proposal should not land.</p>
</div>
</div>
</section>
</main>
</div>
</body>
</html>

View File

@@ -0,0 +1,149 @@
# PR Report Style Guide
Use this guide when the user wants a report artifact, especially a webpage.
## Goal
Make the report feel like an editorial review, not an internal admin dashboard.
The page should make a long technical argument easy to scan without looking
generic or overdesigned.
## Visual Direction
Preferred tone:
- editorial
- warm
- serious
- high-contrast
- handcrafted, not corporate SaaS
Avoid:
- default app-shell layouts
- purple gradients on white
- generic card dashboards
- cramped pages with weak hierarchy
- novelty fonts that hurt readability
## Typography
Recommended pattern:
- one expressive serif or display face for major headings
- one sturdy sans-serif for body copy and UI labels
Good combinations:
- Newsreader + IBM Plex Sans
- Source Serif 4 + Instrument Sans
- Fraunces + Public Sans
- Libre Baskerville + Work Sans
Rules:
- headings should feel deliberate and large
- body copy should stay comfortable for long reading
- reference labels and badges should use smaller dense sans text
## Layout
Recommended structure:
- a sticky side or top navigation for long reports
- one strong hero summary at the top
- panel or paper-like sections for each major topic
- multi-column card grids for comparisons and strengths
- single-column body text for findings and recommendations
Use generous spacing. Long-form technical reports need breathing room.
## Color
Prefer muted paper-like backgrounds with one warm accent and one cool counterweight.
Suggested token categories:
- `--bg`
- `--paper`
- `--ink`
- `--muted`
- `--line`
- `--accent`
- `--good`
- `--warn`
- `--bad`
The accent should highlight navigation, badges, and important labels. Do not
let accent colors dominate body text.
## Useful UI Elements
Include small reusable styles for:
- summary metrics
- badges
- quotes or callouts
- finding cards
- severity labels
- reference labels
- comparison cards
- responsive two-column sections
## Motion
Keep motion restrained.
Good:
- soft fade/slide-in on first load
- hover response on nav items or cards
Bad:
- constant animation
- floating blobs
- decorative motion with no reading benefit
## Content Presentation
Even when the user wants design polish, clarity stays primary.
Good structure for long reports:
1. executive summary
2. what changed
3. tutorial explanation
4. strengths
5. findings
6. comparisons
7. recommendation
The exact headings can change. The important thing is to separate explanation
from judgment.
## References
Reference labels should be visually quiet but easy to spot.
Good pattern:
- small muted text
- monospace or compact sans
- keep them close to the paragraph they support
## Starter Usage
If you need a fast polished base, start from:
- `assets/html-report-starter.html`
Customize:
- fonts
- color tokens
- hero copy
- section ordering
- card density
Do not preserve the placeholder sections if they do not fit the actual report.

View File

@@ -106,6 +106,25 @@ Guidelines:
- keep highlights short and concrete
- spell out upgrade actions for breaking changes
### Inline PR and contributor attribution
When a bullet item clearly maps to a merged pull request, add inline attribution at the
end of the entry in this format:
```
- **Feature name** — Description. ([#123](https://github.com/paperclipai/paperclip/pull/123), @contributor1, @contributor2)
```
Rules:
- Only add a PR link when you can confidently trace the bullet to a specific merged PR.
Use merge commit messages (`Merge pull request #N from user/branch`) to map PRs.
- List the contributor(s) who authored the PR. Use GitHub usernames, not real names or emails.
- If multiple PRs contributed to a single bullet, list them all: `([#10](url), [#12](url), @user1, @user2)`.
- If you cannot determine the PR number or contributor with confidence, omit the attribution
parenthetical — do not guess.
- Core maintainer commits that don't have an external PR can omit the parenthetical.
## Step 5 — Write the File
Template:
@@ -124,10 +143,29 @@ Template:
## Fixes
## Upgrade Guide
## Contributors
Thank you to everyone who contributed to this release!
@username1, @username2, @username3
```
Omit empty sections except `Highlights`, `Improvements`, and `Fixes`, which should usually exist.
The `Contributors` section should always be included. List every person who authored
commits in the release range, @-mentioning them by their **GitHub username** (not their
real name or email). To find GitHub usernames:
1. Extract usernames from merge commit messages: `git log v{last}..HEAD --oneline --merges` — the branch prefix (e.g. `from username/branch`) gives the GitHub username.
2. For noreply emails like `user@users.noreply.github.com`, the username is the part before `@`.
3. For contributors whose username is ambiguous, check `gh api users/{guess}` or the PR page.
**Never expose contributor email addresses.** Use `@username` only.
Exclude bot accounts (e.g. `lockfile-bot`, `dependabot`) from the list. List contributors
in alphabetical order by GitHub username (case-insensitive).
## Step 6 — Review Before Release
Before handing it off:

View File

@@ -13,10 +13,11 @@ Run the full Paperclip release as a maintainer workflow, not just an npm publish
This skill coordinates:
- stable changelog drafting via `release-changelog`
- release-train setup via `scripts/release-start.sh`
- prerelease canary publishing via `scripts/release.sh --canary`
- Docker smoke testing via `scripts/docker-onboard-smoke.sh`
- stable publishing via `scripts/release.sh`
- pushing the release commit and tag
- pushing the stable branch commit and tag
- GitHub Release creation via `scripts/create-github-release.sh`
- website / announcement follow-up tasks
@@ -36,7 +37,7 @@ Before proceeding, verify all of the following:
2. The repo working tree is clean, including untracked files.
3. There are commits since the last stable tag.
4. The release SHA has passed the verification gate or is about to.
5. If package manifests changed, the CI-owned `pnpm-lock.yaml` refresh is already merged on `master`.
5. If package manifests changed, the CI-owned `pnpm-lock.yaml` refresh is already merged on `master` before the release branch is cut.
6. npm publish rights are available locally, or the GitHub release workflow is being used with trusted publishing.
7. If running through Paperclip, you have issue context for status updates and follow-up task creation.
@@ -55,13 +56,15 @@ Collect these inputs up front:
Paperclip now uses this release model:
1. Draft the **stable** changelog as `releases/vX.Y.Z.md`
2. Publish one or more **prerelease canaries** such as `X.Y.Z-canary.0`
3. Smoke test the canary via Docker
4. Publish the stable version `X.Y.Z`
5. Push the release commit and tag
6. Create the GitHub Release
7. Complete website and announcement surfaces
1. Start or resume `release/X.Y.Z`
2. Draft the **stable** changelog as `releases/vX.Y.Z.md`
3. Publish one or more **prerelease canaries** such as `X.Y.Z-canary.0`
4. Smoke test the canary via Docker
5. Publish the stable version `X.Y.Z`
6. Push the stable branch commit and tag
7. Create the GitHub Release
8. Merge `release/X.Y.Z` back to `master` without squash or rebase
9. Complete website and announcement surfaces
Critical consequence:
@@ -70,7 +73,13 @@ Critical consequence:
## Step 1 — Decide the Stable Version
Run release preflight first:
Start the release train first:
```bash
./scripts/release-start.sh {patch|minor|major}
```
Then run release preflight:
```bash
./scripts/release-preflight.sh canary {patch|minor|major}
@@ -125,7 +134,7 @@ The GitHub Actions release workflow installs with `pnpm install --frozen-lockfil
## Step 4 — Publish a Canary
Run:
Run from the `release/X.Y.Z` branch:
```bash
./scripts/release.sh {patch|minor|major} --canary --dry-run
@@ -203,12 +212,14 @@ Stable publish does **not** push the release for you.
After stable publish succeeds:
```bash
git push public-gh HEAD:master --follow-tags
git push public-gh HEAD --follow-tags
./scripts/create-github-release.sh X.Y.Z
```
Use the stable changelog file as the GitHub Release notes source.
Then open the PR from `release/X.Y.Z` back to `master` and merge without squash or rebase.
## Step 8 — Finish the Other Surfaces
Create or verify follow-up work for:

18
tsconfig.base.json Normal file
View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2023",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "dist",
"rootDir": "src",
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true
}
}

View File

@@ -1,18 +1,18 @@
{
"compilerOptions": {
"target": "ES2023",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "dist",
"rootDir": "src",
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true
}
"extends": "./tsconfig.base.json",
"files": [],
"references": [
{ "path": "./packages/adapter-utils" },
{ "path": "./packages/shared" },
{ "path": "./packages/db" },
{ "path": "./packages/adapters/claude-local" },
{ "path": "./packages/adapters/codex-local" },
{ "path": "./packages/adapters/cursor-local" },
{ "path": "./packages/adapters/openclaw-gateway" },
{ "path": "./packages/adapters/opencode-local" },
{ "path": "./packages/adapters/pi-local" },
{ "path": "./server" },
{ "path": "./ui" },
{ "path": "./cli" }
]
}

View File

@@ -32,14 +32,15 @@ import { queryKeys } from "./lib/queryKeys";
import { useCompany } from "./context/CompanyContext";
import { useDialog } from "./context/DialogContext";
function BootstrapPendingPage() {
function BootstrapPendingPage({ hasActiveInvite = false }: { hasActiveInvite?: boolean }) {
return (
<div className="mx-auto max-w-xl py-10">
<div className="rounded-lg border border-border bg-card p-6">
<h1 className="text-xl font-semibold">Instance setup required</h1>
<p className="mt-2 text-sm text-muted-foreground">
No instance admin exists yet. Run this command in your Paperclip environment to generate
the first admin invite URL:
{hasActiveInvite
? "No instance admin exists yet. A bootstrap invite is already active. Check your Paperclip startup logs for the first admin invite URL, or run this command to rotate it:"
: "No instance admin exists yet. Run this command in your Paperclip environment to generate the first admin invite URL:"}
</p>
<pre className="mt-4 overflow-x-auto rounded-md border border-border bg-muted/30 p-3 text-xs">
{`pnpm paperclipai auth bootstrap-ceo`}
@@ -55,6 +56,15 @@ function CloudAccessGate() {
queryKey: queryKeys.health,
queryFn: () => healthApi.get(),
retry: false,
refetchInterval: (query) => {
const data = query.state.data as
| { deploymentMode?: "local_trusted" | "authenticated"; bootstrapStatus?: "ready" | "bootstrap_pending" }
| undefined;
return data?.deploymentMode === "authenticated" && data.bootstrapStatus === "bootstrap_pending"
? 2000
: false;
},
refetchIntervalInBackground: true,
});
const isAuthenticatedMode = healthQuery.data?.deploymentMode === "authenticated";
@@ -78,7 +88,7 @@ function CloudAccessGate() {
}
if (isAuthenticatedMode && healthQuery.data?.bootstrapStatus === "bootstrap_pending") {
return <BootstrapPendingPage />;
return <BootstrapPendingPage hasActiveInvite={healthQuery.data.bootstrapInviteActive} />;
}
if (isAuthenticatedMode && !sessionQuery.data) {

View File

@@ -2,6 +2,9 @@ import { useEffect, useState } from "react";
import type { AdapterConfigFieldsProps } from "./types";
import { Field, help } from "../components/agent-config-primitives";
// TODO(issue-worktree-support): re-enable this UI once the workflow is ready to ship.
const SHOW_EXPERIMENTAL_ISSUE_WORKTREE_UI = false;
const inputClass =
"w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40";
@@ -57,6 +60,10 @@ export function RuntimeServicesJsonField({
config,
mark,
}: JsonFieldProps) {
if (!SHOW_EXPERIMENTAL_ISSUE_WORKTREE_UI) {
return null;
}
const existing = formatJsonObject(config.workspaceRuntime);
const [draft, setDraft] = useState(existing);

View File

@@ -4,6 +4,7 @@ export type HealthStatus = {
deploymentExposure?: "private" | "public";
authReady?: boolean;
bootstrapStatus?: "ready" | "bootstrap_pending";
bootstrapInviteActive?: boolean;
features?: {
companyDeletionEnabled?: boolean;
};

View File

@@ -20,6 +20,9 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
import { User, Hexagon, ArrowUpRight, Tag, Plus, Trash2 } from "lucide-react";
import { AgentIcon } from "./AgentIconPicker";
// TODO(issue-worktree-support): re-enable this UI once the workflow is ready to ship.
const SHOW_EXPERIMENTAL_ISSUE_WORKTREE_UI = false;
interface IssuePropertiesProps {
issue: Issue;
onUpdate: (data: Record<string, unknown>) => void;
@@ -179,7 +182,9 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
const currentProject = issue.projectId
? orderedProjects.find((project) => project.id === issue.projectId) ?? null
: null;
const currentProjectExecutionWorkspacePolicy = currentProject?.executionWorkspacePolicy ?? null;
const currentProjectExecutionWorkspacePolicy = SHOW_EXPERIMENTAL_ISSUE_WORKTREE_UI
? currentProject?.executionWorkspacePolicy ?? null
: null;
const currentProjectSupportsExecutionWorkspace = Boolean(currentProjectExecutionWorkspacePolicy?.enabled);
const usesIsolatedExecutionWorkspace = issue.executionWorkspaceSettings?.mode === "isolated"
? true
@@ -435,7 +440,7 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
onClick={() => {
onUpdate({
projectId: p.id,
executionWorkspaceSettings: p.executionWorkspacePolicy?.enabled
executionWorkspaceSettings: SHOW_EXPERIMENTAL_ISSUE_WORKTREE_UI && p.executionWorkspacePolicy?.enabled
? { mode: p.executionWorkspacePolicy.defaultMode === "isolated" ? "isolated" : "project_primary" }
: null,
});

View File

@@ -44,6 +44,8 @@ import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySel
const DRAFT_KEY = "paperclip:issue-draft";
const DEBOUNCE_MS = 800;
// TODO(issue-worktree-support): re-enable this UI once the workflow is ready to ship.
const SHOW_EXPERIMENTAL_ISSUE_WORKTREE_UI = false;
/** Return black or white hex based on background luminance (WCAG perceptual weights). */
function getContrastTextColor(hexColor: string): string {
@@ -426,7 +428,9 @@ export function NewIssueDialog() {
chrome: assigneeChrome,
});
const selectedProject = orderedProjects.find((project) => project.id === projectId);
const executionWorkspacePolicy = selectedProject?.executionWorkspacePolicy;
const executionWorkspacePolicy = SHOW_EXPERIMENTAL_ISSUE_WORKTREE_UI
? selectedProject?.executionWorkspacePolicy
: null;
const executionWorkspaceSettings = executionWorkspacePolicy?.enabled
? {
mode: useIsolatedExecutionWorkspace ? "isolated" : "project_primary",
@@ -472,7 +476,9 @@ export function NewIssueDialog() {
const currentPriority = priorities.find((p) => p.value === priority);
const currentAssignee = (agents ?? []).find((a) => a.id === assigneeId);
const currentProject = orderedProjects.find((project) => project.id === projectId);
const currentProjectExecutionWorkspacePolicy = currentProject?.executionWorkspacePolicy ?? null;
const currentProjectExecutionWorkspacePolicy = SHOW_EXPERIMENTAL_ISSUE_WORKTREE_UI
? currentProject?.executionWorkspacePolicy ?? null
: null;
const currentProjectSupportsExecutionWorkspace = Boolean(currentProjectExecutionWorkspacePolicy?.enabled);
const assigneeOptionsTitle =
assigneeAdapterType === "claude_local"
@@ -514,7 +520,7 @@ export function NewIssueDialog() {
const handleProjectChange = useCallback((nextProjectId: string) => {
setProjectId(nextProjectId);
const nextProject = orderedProjects.find((project) => project.id === nextProjectId);
const policy = nextProject?.executionWorkspacePolicy;
const policy = SHOW_EXPERIMENTAL_ISSUE_WORKTREE_UI ? nextProject?.executionWorkspacePolicy : null;
executionWorkspaceDefaultProjectId.current = nextProjectId || null;
setUseIsolatedExecutionWorkspace(Boolean(policy?.enabled && policy.defaultMode === "isolated"));
}, [orderedProjects]);
@@ -527,7 +533,11 @@ export function NewIssueDialog() {
if (!project) return;
executionWorkspaceDefaultProjectId.current = projectId;
setUseIsolatedExecutionWorkspace(
Boolean(project.executionWorkspacePolicy?.enabled && project.executionWorkspacePolicy.defaultMode === "isolated"),
Boolean(
SHOW_EXPERIMENTAL_ISSUE_WORKTREE_UI &&
project.executionWorkspacePolicy?.enabled &&
project.executionWorkspacePolicy.defaultMode === "isolated",
),
);
}, [newIssueOpen, orderedProjects, projectId]);
const modelOverrideOptions = useMemo<InlineEntityOption[]>(

View File

@@ -500,10 +500,6 @@ export function OnboardingWizard() {
setLoading(false);
reset();
closeOnboarding();
if (createdCompanyPrefix && createdIssueRef) {
navigate(`/${createdCompanyPrefix}/issues/${createdIssueRef}`);
return;
}
if (createdCompanyPrefix) {
navigate(`/${createdCompanyPrefix}/dashboard`);
return;

View File

@@ -26,6 +26,9 @@ const PROJECT_STATUSES = [
{ value: "cancelled", label: "Cancelled" },
];
// TODO(issue-worktree-support): re-enable this UI once the workflow is ready to ship.
const SHOW_EXPERIMENTAL_ISSUE_WORKTREE_UI = false;
interface ProjectPropertiesProps {
project: Project;
onUpdate?: (data: Record<string, unknown>) => void;
@@ -43,7 +46,9 @@ export type ProjectConfigFieldKey =
| "execution_workspace_default_mode"
| "execution_workspace_base_ref"
| "execution_workspace_branch_template"
| "execution_workspace_worktree_parent_dir";
| "execution_workspace_worktree_parent_dir"
| "execution_workspace_provision_command"
| "execution_workspace_teardown_command";
const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__";
@@ -705,6 +710,8 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
)}
</div>
{SHOW_EXPERIMENTAL_ISSUE_WORKTREE_UI && (
<>
<Separator className="my-4" />
<div className="py-1.5 space-y-2">
@@ -885,8 +892,57 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
placeholder=".paperclip/worktrees"
/>
</div>
<div>
<div className="mb-1 flex items-center gap-1.5">
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<span>Provision command</span>
<SaveIndicator state={fieldState("execution_workspace_provision_command")} />
</label>
</div>
<DraftInput
value={executionWorkspaceStrategy.provisionCommand ?? ""}
onCommit={(value) =>
commitField("execution_workspace_provision_command", {
...updateExecutionWorkspacePolicy({
workspaceStrategy: {
...executionWorkspaceStrategy,
type: "git_worktree",
provisionCommand: value || null,
},
})!,
})}
immediate
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs font-mono outline-none"
placeholder="bash ./scripts/provision-worktree.sh"
/>
</div>
<div>
<div className="mb-1 flex items-center gap-1.5">
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<span>Teardown command</span>
<SaveIndicator state={fieldState("execution_workspace_teardown_command")} />
</label>
</div>
<DraftInput
value={executionWorkspaceStrategy.teardownCommand ?? ""}
onCommit={(value) =>
commitField("execution_workspace_teardown_command", {
...updateExecutionWorkspacePolicy({
workspaceStrategy: {
...executionWorkspaceStrategy,
type: "git_worktree",
teardownCommand: value || null,
},
})!,
})}
immediate
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs font-mono outline-none"
placeholder="bash ./scripts/teardown-worktree.sh"
/>
</div>
<p className="text-[11px] text-muted-foreground">
Runtime services stay under Paperclip control and are not configured here yet.
Provision runs inside the derived worktree before agent execution. Teardown is stored here for
future cleanup flows.
</p>
</div>
)}
@@ -894,6 +950,8 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
)}
</div>
</div>
</>
)}
</div>
</div>