#!/usr/bin/env ts-node /** * scripts/pack.ts * * Pack script for Pisces. * Run with: npm run pack * * Produces a distributable Pi package archive at: * dist/pisces-.tar.gz * * What it does: * 1. Runs validate.ts — aborts if validation fails * 2. Compiles TypeScript via tsc * 3. Assembles the package manifest into dist/ * 4. Copies all non-TS assets (skills, templates, config, system.md, README) * 5. Rewrites pi-package.yaml paths to point at compiled JS in dist/ * 6. Creates a versioned .tar.gz archive * 7. Prints install command and checksum */ import * as fs from "fs"; import * as path from "path"; import * as crypto from "crypto"; import { execSync, spawnSync } from "child_process"; // ─── Config ──────────────────────────────────────────────────────────────── // ─── Project Root Resolution ─────────────────────────────────────────────── // __dirname and __filename are unreliable under ts-node with a custom // --project flag. Walk up from cwd until we find package.json. function findProjectRoot(): string { let dir = process.cwd(); let parent = path.dirname(dir); while (parent !== dir) { if (fs.existsSync(path.join(dir, "package.json"))) { return dir; } dir = parent; parent = path.dirname(dir); } // Reached filesystem root — check it too, then fall back to cwd return fs.existsSync(path.join(dir, "package.json")) ? dir : process.cwd(); } const ROOT = findProjectRoot(); const DIST = path.join(ROOT, "dist"); const PACK_DIR = path.join(DIST, "pack"); // ─── Helpers ─────────────────────────────────────────────────────────────── function log(msg: string): void { console.log(msg); } function step(n: number, total: number, msg: string): void { console.log(`\n[${n}/${total}] ${msg}`); } function abort(msg: string): never { console.error(`\n❌ Pack aborted: ${msg}`); process.exit(1); } function exec(cmd: string, cwd = ROOT): void { try { execSync(cmd, { cwd, stdio: "inherit" }); } catch { abort(`Command failed: ${cmd}`); } } function ensureDir(dir: string): void { fs.mkdirSync(dir, { recursive: true }); } function copyFile(src: string, dest: string): void { ensureDir(path.dirname(dest)); fs.copyFileSync(src, dest); } function copyDir(src: string, dest: string, ext?: string): void { if (!fs.existsSync(src)) return; ensureDir(dest); for (const entry of fs.readdirSync(src, { withFileTypes: true })) { const srcPath = path.join(src, entry.name); const destPath = path.join(dest, entry.name); if (entry.isDirectory()) { copyDir(srcPath, destPath, ext); } else if (!ext || entry.name.endsWith(ext)) { copyFile(srcPath, destPath); } } } function readJson(filePath: string): T { return JSON.parse(fs.readFileSync(filePath, "utf-8")) as T; } function writeJson(filePath: string, data: unknown): void { fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n"); } // ─── Step 1: Validate ────────────────────────────────────────────────────── function stepValidate(): void { step(1, 8, "Running validation checks..."); exec("npx ts-node scripts/validate.ts"); log(" ✅ Validation passed"); } // ─── Step 2: Compile TypeScript ──────────────────────────────────────────── function stepCompile(): void { step(2, 8, "Compiling TypeScript..."); // Clean previous dist if (fs.existsSync(DIST)) { fs.rmSync(DIST, { recursive: true, force: true }); } ensureDir(DIST); exec("npx tsc --project tsconfig.json"); log(" ✅ TypeScript compiled to dist/"); } // ─── Step 3: Assemble pack directory ────────────────────────────────────── function stepAssemble(): void { step(3, 8, "Assembling package contents..."); ensureDir(PACK_DIR); // Root assets for (const file of ["SYSTEM.md", "README.md", "CHANGELOG.md", "LICENSE"]) { const src = path.join(ROOT, file); if (fs.existsSync(src)) { copyFile(src, path.join(PACK_DIR, file)); log(` Copied ${file}`); } } // Compiled JS from dist/ (excluding the pack/ subdirectory itself) const compiledFiles = fs.readdirSync(DIST).filter( (f) => f !== "pack" && (f.endsWith(".js") || f.endsWith(".d.ts")) ); for (const f of compiledFiles) { copyFile(path.join(DIST, f), path.join(PACK_DIR, f)); } // Extensions compiled output — .js only; .d.ts files are not needed at runtime // and Pi will try to load any .ts-suffixed file it finds in the directory. const extDistDir = path.join(DIST, "extensions"); if (fs.existsSync(extDistDir)) { copyDir(extDistDir, path.join(PACK_DIR, "extensions"), ".js"); log(" Copied extensions/"); } // Skills (markdown — no compilation) copyDir(path.join(ROOT, "src", "skills"), path.join(PACK_DIR, "skills"), ".md"); log(" Copied skills/"); // Templates copyDir(path.join(ROOT, "src", "templates"), path.join(PACK_DIR, "templates"), ".md"); log(" Copied templates/"); // Themes (JSON — no compilation) copyDir(path.join(ROOT, "src", "themes"), path.join(PACK_DIR, "themes"), ".json"); log(" Copied themes/"); // Config copyDir(path.join(ROOT, "config"), path.join(PACK_DIR, "config")); log(" Copied config/"); // Public assets (logos, icons) const publicDir = path.join(ROOT, "public"); if (fs.existsSync(publicDir)) { copyDir(publicDir, path.join(PACK_DIR, "public")); log(" Copied public/"); } log(` ✅ Package contents assembled in dist/pack/`); } // ─── Step 4: Rewrite pi-package.yaml ────────────────────────────────────── /** * Reads pi-package.yaml, rewrites all `path:` values to point at the * compiled artefacts in the pack layout (JS for extensions, md for skills). */ function stepRewriteManifest(): void { step(4, 8, "Writing pi-package.yaml for distribution..."); const src = fs.readFileSync(path.join(ROOT, "pi-package.yaml"), "utf-8"); // Rewrite extension paths to compiled JS equivalents const rewritten = src .replace(/path:\s*src\/extensions\/(\S+)\.ts/g, "path: extensions/$1.js") .replace(/path:\s*src\/(\S+)\.ts/g, "path: $1.js") .replace(/path:\s*src\/skills\//g, "path: skills/") .replace(/path:\s*src\/templates\//g, "path: templates/") .replace(/path:\s*config\//g, "path: config/"); fs.writeFileSync(path.join(PACK_DIR, "pi-package.yaml"), rewritten); log(" ✅ pi-package.yaml written with compiled paths"); } // ─── Step 5: Write package manifests ────────────────────────────────────── interface RootPackageJson { name: string; version: string; description: string; keywords?: string[]; author?: string; license?: string; main?: string; types?: string; engines?: Record; repository?: unknown; bugs?: unknown; homepage?: string; peerDependencies?: Record; pi?: { extensions?: string[] }; } function rewriteExtensionPath(p: string): string { return p .replace(/^\.\/src\/extensions\/(\S+)\.ts$/, "./extensions/$1.js") .replace(/^\.\/src\/(\S+)\.ts$/, "./$1.js"); } function stepWritePackageMeta(pkg: RootPackageJson): void { step(5, 8, "Writing package metadata..."); // pisces.meta.json — Pi runtime internal metadata const meta = { name: "pisces", version: pkg.version, built_at: new Date().toISOString(), entry: "index.js", node_minimum: "18.0.0", }; writeJson(path.join(PACK_DIR, "pisces.meta.json"), meta); // package.json — required for npm publish from dist/pack/ const publishPkg: Record = { name: pkg.name, version: pkg.version, description: pkg.description, keywords: pkg.keywords, author: pkg.author, license: pkg.license, main: "index.js", types: "index.d.ts", engines: pkg.engines, repository: pkg.repository, bugs: pkg.bugs, homepage: pkg.homepage, }; if (pkg.peerDependencies) { publishPkg.peerDependencies = pkg.peerDependencies; } // Explicit allowlist — prevents source maps, type declarations, and any extra build artifacts // from appearing on the npm package page or being downloaded by installers. publishPkg.files = [ "index.js", "index.d.ts", "postinstall.js", "extensions/", "skills/", "templates/", "themes/", "public/", "config/", "pi-package.yaml", "pisces.meta.json", "SYSTEM.md", "README.md", "CHANGELOG.md", "LICENSE", ]; // postinstall copies pisces-editorial-noir.json to ~/.pi/agent/themes/ for persistent theme support. publishPkg.scripts = { postinstall: "node postinstall.js" }; // Derive pi.extensions from package.json, rewriting src/*.ts → *.js paths. // skills intentionally omitted — workspace-gate returns skillPaths only inside a .pisces workspace. const srcExtensions = pkg.pi?.extensions ?? []; publishPkg.pi = { extensions: srcExtensions.map(rewriteExtensionPath), }; writeJson(path.join(PACK_DIR, "package.json"), publishPkg); log(` ✅ Metadata written (version: ${pkg.version})`); } // ─── Step 6: Create .tar.gz archive ─────────────────────────────────────── /** * Creates a deterministic .tar.gz from the pack directory. * Uses Node's built-in zlib — no external archiver dependency. */ function stepArchive(version: string): string { step(6, 8, "Creating archive..."); const archiveName = `pisces-${version}.tar.gz`; const archivePath = path.join(DIST, archiveName); const result = spawnSync("tar", ["-czf", archivePath, "-C", PACK_DIR, "."], { cwd: ROOT }); if (result.status !== 0) { abort("Could not create tar.gz archive. Ensure tar is available on your system."); } log(` ✅ Archive created: dist/${archiveName}`); return archivePath; } // ─── Step 6b: Verify archive contents ───────────────────────────────────── const REQUIRED_ARCHIVE_ENTRIES = [ "./pi-package.yaml", "./package.json", "./index.js", "./SYSTEM.md", "./config/defaults.json", "./skills/attempt/SKILL.md", ]; function stepVerifyArchive(archivePath: string): void { step(7, 8, "Verifying archive contents..."); const listResult = spawnSync("tar", ["-tzf", archivePath], { encoding: "utf-8" }); if (listResult.status !== 0) { abort("Could not list archive contents for verification."); } const listing = listResult.stdout as string; const entries = new Set(listing.split("\n").map((l) => l.trim()).filter(Boolean)); const missing = REQUIRED_ARCHIVE_ENTRIES.filter((e) => !entries.has(e)); if (missing.length > 0) { abort(`Archive is missing required entries:\n ${missing.join("\n ")}`); } // Verify the version in the packed package.json matches the source version const packedPkg = readJson<{ version: string }>(path.join(PACK_DIR, "package.json")); const sourcePkg = readJson<{ version: string }>(path.join(ROOT, "package.json")); if (packedPkg.version !== sourcePkg.version) { abort( `Version mismatch: dist/pack/package.json is ${packedPkg.version} but source package.json is ${sourcePkg.version}` ); } log(` ✅ Archive verified: ${entries.size} entries, version ${sourcePkg.version} consistent`); } // ─── Step 7: Print summary ──────────────────────────────────────────────── function stepSummary(archivePath: string, version: string): void { step(8, 8, "Computing checksum and printing summary..."); const archiveBuffer = fs.readFileSync(archivePath); const sha256 = crypto.createHash("sha256").update(archiveBuffer).digest("hex"); const sizeKb = (archiveBuffer.length / 1024).toFixed(1); const archiveName = path.basename(archivePath); console.log("\n┌─────────────────────────────────────────────────────┐"); console.log("│ 🐠 Pisces Package Ready │"); console.log("└─────────────────────────────────────────────────────┘\n"); console.log(` Package: ${archiveName}`); console.log(` Version: ${version}`); console.log(` Size: ${sizeKb} KB`); console.log(` SHA-256: ${sha256}`); console.log(` Location: dist/${archiveName}`); console.log(`\n To install locally:`); console.log(` pi install ./dist/${archiveName}`); console.log(`\n To publish to npm registry:`); console.log(` pnpm publish --no-git-checks`); console.log(`\n To install from npm (after publish):`); console.log(` pi install npm:@aethrekh/pisces\n`); } // ─── Main ────────────────────────────────────────────────────────────────── function run(): void { console.log("🐠 Building Pisces package..."); const pkg = readJson(path.join(ROOT, "package.json")); const version = pkg.version; log(` Building version: ${version}`); stepValidate(); stepCompile(); stepAssemble(); stepRewriteManifest(); stepWritePackageMeta(pkg); const archivePath = stepArchive(version); stepVerifyArchive(archivePath); stepSummary(archivePath, version); } run();