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 <noreply@anthropic.com>
This commit is contained in:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
51
cli/esbuild.config.mjs
Normal file
51
cli/esbuild.config.mjs
Normal file
@@ -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,
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -8,6 +8,9 @@ importers:
|
||||
|
||||
.:
|
||||
devDependencies:
|
||||
esbuild:
|
||||
specifier: ^0.27.3
|
||||
version: 0.27.3
|
||||
typescript:
|
||||
specifier: ^5.7.3
|
||||
version: 5.9.3
|
||||
|
||||
67
scripts/build-npm.sh
Executable file
67
scripts/build-npm.sh
Executable file
@@ -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"
|
||||
67
scripts/check-forbidden-tokens.mjs
Normal file
67
scripts/check-forbidden-tokens.mjs
Normal file
@@ -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.");
|
||||
}
|
||||
97
scripts/generate-npm-package-json.mjs
Normal file
97
scripts/generate-npm-package-json.mjs
Normal file
@@ -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}`);
|
||||
71
scripts/version-bump.sh
Executable file
71
scripts/version-bump.sh
Executable file
@@ -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 <patch|minor|major|X.Y.Z>"
|
||||
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"
|
||||
Reference in New Issue
Block a user