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:
Dotta
2026-03-03 09:25:10 -06:00
parent b6c321f30a
commit 4c6fe04700
10 changed files with 375 additions and 3 deletions

67
scripts/build-npm.sh Executable file
View 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"

View 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.");
}

View 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
View 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"