From 4c6fe04700000c98b69a2180dc6feb1ac2a77f50 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 3 Mar 2026 09:25:10 -0600 Subject: [PATCH] feat: add npm build process, version bump, and forbidden token enforcement - Add esbuild config to bundle CLI with all workspace code for npm publishing - Add build-npm.sh script that runs forbidden token check, type-check, esbuild bundle, and generates publishable package.json - Add generate-npm-package-json.mjs to resolve workspace:* refs to actual npm dependencies for publishing - Add version-bump.sh for patch/minor/major/explicit version bumping - Add check-forbidden-tokens.mjs that scans codebase for forbidden tokens (mirrors git hook logic, safe if token list is missing) - Add esbuild as dev dependency - Add build:npm, version:bump, check:tokens scripts to root package.json - Update .gitignore for build artifacts Co-Authored-By: Claude Opus 4.6 --- .gitignore | 6 +- README.md | 4 +- cli/esbuild.config.mjs | 51 ++++++++++++++ cli/package.json | 8 ++- package.json | 4 ++ pnpm-lock.yaml | 3 + scripts/build-npm.sh | 67 ++++++++++++++++++ scripts/check-forbidden-tokens.mjs | 67 ++++++++++++++++++ scripts/generate-npm-package-json.mjs | 97 +++++++++++++++++++++++++++ scripts/version-bump.sh | 71 ++++++++++++++++++++ 10 files changed, 375 insertions(+), 3 deletions(-) create mode 100644 cli/esbuild.config.mjs create mode 100755 scripts/build-npm.sh create mode 100644 scripts/check-forbidden-tokens.mjs create mode 100644 scripts/generate-npm-package-json.mjs create mode 100755 scripts/version-bump.sh diff --git a/.gitignore b/.gitignore index 49ce927e..93483d61 100644 --- a/.gitignore +++ b/.gitignore @@ -12,8 +12,9 @@ data/ tmp-* cli/tmp/ -# Scratch/seed scripts +# Scratch/seed scripts (but not scripts/ dir) check-*.mjs +!scripts/check-*.mjs new-agent*.json newcompany.json seed-*.mjs @@ -21,6 +22,9 @@ server/check-*.mjs server/seed-*.mjs packages/db/seed-*.mjs +# npm publish build artifacts +cli/package.dev.json + # Build artifacts in src directories server/src/**/*.js server/src/**/*.js.map diff --git a/README.md b/README.md index 6bebbce9..c4818f38 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,9 @@ ## What is Paperclip? -### **If OpenClaw is an _employee_, Paperclip is the _company_** +# Open-source orchestration for zero-human companies + +**If OpenClaw is an _employee_, Paperclip is the _company_** Paperclip is a Node.js server and React UI that orchestrates a team of AI agents to run a business. Bring your own agents, assign goals, and track your agents' work and costs from one dashboard. diff --git a/cli/esbuild.config.mjs b/cli/esbuild.config.mjs new file mode 100644 index 00000000..884dda41 --- /dev/null +++ b/cli/esbuild.config.mjs @@ -0,0 +1,51 @@ +/** + * esbuild configuration for building the paperclipai CLI for npm. + * + * Bundles all workspace packages (@paperclipai/*) into a single file. + * External npm packages remain as regular dependencies. + */ + +import { readFileSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(__dirname, ".."); + +// Workspace packages whose code should be bundled into the CLI +const workspacePaths = [ + "cli", + "server", + "packages/db", + "packages/shared", + "packages/adapter-utils", + "packages/adapters/claude-local", + "packages/adapters/codex-local", + "packages/adapters/openclaw", +]; + +// Collect all external (non-workspace) npm package names +const externals = new Set(); +for (const p of workspacePaths) { + const pkg = JSON.parse(readFileSync(resolve(repoRoot, p, "package.json"), "utf8")); + for (const name of Object.keys(pkg.dependencies || {})) { + if (!name.startsWith("@paperclipai/")) externals.add(name); + } + for (const name of Object.keys(pkg.optionalDependencies || {})) { + externals.add(name); + } +} + +/** @type {import('esbuild').BuildOptions} */ +export default { + entryPoints: ["src/index.ts"], + bundle: true, + platform: "node", + target: "node20", + format: "esm", + outfile: "dist/index.js", + banner: { js: "#!/usr/bin/env node" }, + external: [...externals].sort(), + treeShaking: true, + sourcemap: true, +}; diff --git a/cli/package.json b/cli/package.json index 1fd8e99f..d6bec81d 100644 --- a/cli/package.json +++ b/cli/package.json @@ -6,7 +6,13 @@ "bin": { "paperclipai": "./dist/index.js" }, - "keywords": ["paperclip", "ai", "agents", "orchestration", "cli"], + "keywords": [ + "paperclip", + "ai", + "agents", + "orchestration", + "cli" + ], "license": "MIT", "repository": { "type": "git", diff --git a/package.json b/package.json index a5f4ecd3..5dd6037b 100644 --- a/package.json +++ b/package.json @@ -16,9 +16,13 @@ "secrets:migrate-inline-env": "tsx scripts/migrate-inline-env-secrets.ts", "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", + "version:bump": "./scripts/version-bump.sh", + "check:tokens": "node scripts/check-forbidden-tokens.mjs", "docs:dev": "cd docs && npx mintlify dev" }, "devDependencies": { + "esbuild": "^0.27.3", "typescript": "^5.7.3", "vitest": "^3.0.5" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ddaa4c5b..543fa0d0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: devDependencies: + esbuild: + specifier: ^0.27.3 + version: 0.27.3 typescript: specifier: ^5.7.3 version: 5.9.3 diff --git a/scripts/build-npm.sh b/scripts/build-npm.sh new file mode 100755 index 00000000..e041b6d4 --- /dev/null +++ b/scripts/build-npm.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +set -euo pipefail + +# build-npm.sh — Build the paperclipai CLI package for npm publishing. +# +# Uses esbuild to bundle all workspace code into a single file, +# keeping external npm dependencies as regular package dependencies. +# +# Usage: +# ./scripts/build-npm.sh # full build +# ./scripts/build-npm.sh --skip-checks # skip forbidden-token check (CI without token list) + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +CLI_DIR="$REPO_ROOT/cli" +DIST_DIR="$CLI_DIR/dist" + +skip_checks=false +for arg in "$@"; do + case "$arg" in + --skip-checks) skip_checks=true ;; + esac +done + +echo "==> Building paperclipai for npm" + +# ── Step 1: Forbidden token check ────────────────────────────────────────────── +if [ "$skip_checks" = false ]; then + echo " [1/5] Running forbidden token check..." + node "$REPO_ROOT/scripts/check-forbidden-tokens.mjs" +else + echo " [1/5] Skipping forbidden token check (--skip-checks)" +fi + +# ── Step 2: TypeScript type-check ────────────────────────────────────────────── +echo " [2/5] Type-checking..." +cd "$REPO_ROOT" +pnpm -r typecheck + +# ── Step 3: Bundle CLI with esbuild ──────────────────────────────────────────── +echo " [3/5] Bundling CLI with esbuild..." +cd "$CLI_DIR" +rm -rf dist + +node --input-type=module -e " +import esbuild from 'esbuild'; +import config from './esbuild.config.mjs'; +await esbuild.build(config); +" + +chmod +x dist/index.js + +# ── Step 4: Back up dev package.json, generate publishable one ───────────────── +echo " [4/5] Generating publishable package.json..." +cp "$CLI_DIR/package.json" "$CLI_DIR/package.dev.json" +node "$REPO_ROOT/scripts/generate-npm-package-json.mjs" + +# ── Step 5: Summary ─────────────────────────────────────────────────────────── +BUNDLE_SIZE=$(wc -c < "$DIST_DIR/index.js" | xargs) +echo " [5/5] Build verification..." +echo "" +echo "Build complete." +echo " Bundle: cli/dist/index.js (${BUNDLE_SIZE} bytes)" +echo " Source map: cli/dist/index.js.map" +echo "" +echo "To preview: cd cli && npm pack --dry-run" +echo "To publish: cd cli && npm publish --access public" +echo "To restore: mv cli/package.dev.json cli/package.json" diff --git a/scripts/check-forbidden-tokens.mjs b/scripts/check-forbidden-tokens.mjs new file mode 100644 index 00000000..e94fd485 --- /dev/null +++ b/scripts/check-forbidden-tokens.mjs @@ -0,0 +1,67 @@ +#!/usr/bin/env node +/** + * check-forbidden-tokens.mjs + * + * Scans the codebase for forbidden tokens before publishing to npm. + * Mirrors the git pre-commit hook logic, but runs against the full + * working tree (not just staged changes). + * + * Token list: .git/hooks/forbidden-tokens.txt (one per line, # comments ok). + * If the file is missing, the check passes silently — other developers + * on the project won't have this list, and that's fine. + */ + +import { execSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const repoRoot = execSync("git rev-parse --show-toplevel", { encoding: "utf8" }).trim(); +const gitDir = execSync("git rev-parse --git-dir", { encoding: "utf8", cwd: repoRoot }).trim(); +const tokensFile = resolve(repoRoot, gitDir, "hooks/forbidden-tokens.txt"); + +if (!existsSync(tokensFile)) { + console.log(" ℹ Forbidden tokens list not found — skipping check."); + process.exit(0); +} + +const tokens = readFileSync(tokensFile, "utf8") + .split("\n") + .map((l) => l.trim()) + .filter((l) => l && !l.startsWith("#")); + +if (tokens.length === 0) { + console.log(" ℹ Forbidden tokens list is empty — skipping check."); + process.exit(0); +} + +// Use git grep to search tracked files only (avoids node_modules, dist, etc.) +let found = false; + +for (const token of tokens) { + try { + const result = execSync( + `git grep -in --no-color -- ${JSON.stringify(token)} -- ':!pnpm-lock.yaml' ':!.git'`, + { encoding: "utf8", cwd: repoRoot, stdio: ["pipe", "pipe", "pipe"] }, + ); + if (result.trim()) { + if (!found) { + console.error("ERROR: Forbidden tokens found in tracked files:\n"); + } + found = true; + // Print matches but DO NOT print which token was matched (avoids leaking the list) + const lines = result.trim().split("\n"); + for (const line of lines) { + console.error(` ${line}`); + } + } + } catch { + // git grep returns exit code 1 when no matches — that's fine + } +} + +if (found) { + console.error("\nBuild blocked. Remove the forbidden token(s) before publishing."); + process.exit(1); +} else { + console.log(" ✓ No forbidden tokens found."); +} diff --git a/scripts/generate-npm-package-json.mjs b/scripts/generate-npm-package-json.mjs new file mode 100644 index 00000000..caf9e109 --- /dev/null +++ b/scripts/generate-npm-package-json.mjs @@ -0,0 +1,97 @@ +#!/usr/bin/env node +/** + * generate-npm-package-json.mjs + * + * Reads the dev package.json (which has workspace:* refs) and produces + * a publishable package.json in cli/ with: + * - workspace:* dependencies removed + * - all external dependencies from workspace packages inlined + * - proper metadata for npm + * + * Reads from cli/package.dev.json if it exists (build already ran), + * otherwise from cli/package.json. + */ + +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(__dirname, ".."); + +function readPkg(relativePath) { + return JSON.parse(readFileSync(resolve(repoRoot, relativePath, "package.json"), "utf8")); +} + +// Read all workspace packages that the CLI depends on +const workspacePaths = [ + "cli", + "server", + "packages/db", + "packages/shared", + "packages/adapter-utils", + "packages/adapters/claude-local", + "packages/adapters/codex-local", + "packages/adapters/openclaw", +]; + +// Collect all external dependencies from all workspace packages +const allDeps = {}; +const allOptionalDeps = {}; + +for (const pkgPath of workspacePaths) { + const pkg = readPkg(pkgPath); + const deps = pkg.dependencies || {}; + const optDeps = pkg.optionalDependencies || {}; + + for (const [name, version] of Object.entries(deps)) { + if (name.startsWith("@paperclipai/")) continue; // skip workspace refs + // Keep the more specific (pinned) version if conflict + if (!allDeps[name] || !version.startsWith("^")) { + allDeps[name] = version; + } + } + + for (const [name, version] of Object.entries(optDeps)) { + allOptionalDeps[name] = version; + } +} + +// Sort alphabetically +const sortedDeps = Object.fromEntries(Object.entries(allDeps).sort(([a], [b]) => a.localeCompare(b))); +const sortedOptDeps = Object.fromEntries( + Object.entries(allOptionalDeps).sort(([a], [b]) => a.localeCompare(b)), +); + +// Read the CLI package metadata — prefer the dev backup if it exists +const devPkgPath = resolve(repoRoot, "cli/package.dev.json"); +const cliPkg = existsSync(devPkgPath) + ? JSON.parse(readFileSync(devPkgPath, "utf8")) + : readPkg("cli"); + +// Build the publishable package.json +const publishPkg = { + name: cliPkg.name, + version: cliPkg.version, + description: cliPkg.description, + type: cliPkg.type, + bin: cliPkg.bin, + keywords: cliPkg.keywords, + license: cliPkg.license, + repository: cliPkg.repository, + homepage: cliPkg.homepage, + files: cliPkg.files, + engines: { node: ">=20" }, + dependencies: sortedDeps, +}; + +if (Object.keys(sortedOptDeps).length > 0) { + publishPkg.optionalDependencies = sortedOptDeps; +} + +const output = JSON.stringify(publishPkg, null, 2) + "\n"; +const outPath = resolve(repoRoot, "cli/package.json"); +writeFileSync(outPath, output); + +console.log(` ✓ Generated publishable package.json (${Object.keys(sortedDeps).length} deps)`); +console.log(` Version: ${cliPkg.version}`); diff --git a/scripts/version-bump.sh b/scripts/version-bump.sh new file mode 100755 index 00000000..d8b55e3a --- /dev/null +++ b/scripts/version-bump.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +set -euo pipefail + +# version-bump.sh — Bump the version of the paperclipai CLI package. +# +# Usage: +# ./scripts/version-bump.sh patch # 0.1.0 → 0.1.1 +# ./scripts/version-bump.sh minor # 0.1.0 → 0.2.0 +# ./scripts/version-bump.sh major # 0.1.0 → 1.0.0 +# ./scripts/version-bump.sh 1.2.3 # set explicit version +# +# Updates version in: +# - cli/package.json (source of truth) +# - cli/src/index.ts (commander .version()) + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +CLI_PKG="$REPO_ROOT/cli/package.json" +CLI_INDEX="$REPO_ROOT/cli/src/index.ts" + +if [ $# -lt 1 ]; then + echo "Usage: $0 " + exit 1 +fi + +BUMP_TYPE="$1" + +# Read current version +CURRENT=$(node -e "console.log(require('$CLI_PKG').version)") + +# Calculate new version +case "$BUMP_TYPE" in + patch|minor|major) + IFS='.' read -r major minor patch <<< "$CURRENT" + case "$BUMP_TYPE" in + patch) patch=$((patch + 1)) ;; + minor) minor=$((minor + 1)); patch=0 ;; + major) major=$((major + 1)); minor=0; patch=0 ;; + esac + NEW_VERSION="$major.$minor.$patch" + ;; + *) + # Validate explicit version format + if ! echo "$BUMP_TYPE" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "Error: Invalid version format '$BUMP_TYPE'. Expected X.Y.Z" + exit 1 + fi + NEW_VERSION="$BUMP_TYPE" + ;; +esac + +echo "Bumping version: $CURRENT → $NEW_VERSION" + +# Update cli/package.json +node -e " +const fs = require('fs'); +const pkg = JSON.parse(fs.readFileSync('$CLI_PKG', 'utf8')); +pkg.version = '$NEW_VERSION'; +fs.writeFileSync('$CLI_PKG', JSON.stringify(pkg, null, 2) + '\n'); +" +echo " ✓ Updated cli/package.json" + +# Update cli/src/index.ts — the .version("X.Y.Z") call +sed -i '' "s/\.version(\"$CURRENT\")/\.version(\"$NEW_VERSION\")/" "$CLI_INDEX" +echo " ✓ Updated cli/src/index.ts" + +echo "" +echo "Version bumped to $NEW_VERSION" +echo "Run ./scripts/build-npm.sh to build, then commit and tag:" +echo " git add cli/package.json cli/src/index.ts" +echo " git commit -m \"chore: bump version to $NEW_VERSION\"" +echo " git tag v$NEW_VERSION"