From 27d30260f6000a11c3e2930892ae51311abfdfe9 Mon Sep 17 00:00:00 2001 From: Brian Genisio Date: Thu, 1 Oct 2026 11:25:39 -0400 Subject: [PATCH] Add npm run pack for a minimized release tarball Match the email-simulator approach: bundle client and server with esbuild so releases ship without node_modules. Co-authored-by: Cursor --- .github/workflows/release.yml | 25 ++---- .gitignore | 2 + README.md | 7 ++ package.json | 3 +- scripts/pack-dist.mjs | 154 ++++++++++++++++++++++++++++++++++ 5 files changed, 172 insertions(+), 19 deletions(-) create mode 100644 scripts/pack-dist.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 168bf2e..1ecd28d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,7 +1,7 @@ # Attach a deployable tarball to a GitHub release. -# The archive includes node_modules, the design-system submodule, and the -# built client (public/app.bundle.js) so a new environment can unpack, -# add a local .env, and start with `node server.js`. +# `npm run pack` builds a minified client + single-file server bundle (no +# node_modules) into dist.tar.gz. Unpack, add a local .env (and optionally +# session.config.json), then start with `node server.js`. name: Build and Release @@ -32,22 +32,11 @@ jobs: - name: Install dependencies run: npm ci - - name: Build frontend bundle - run: npm run build + - name: Run tests + run: npm test - - name: Archive release package - run: | - tar -czf /tmp/dist.tar.gz \ - --exclude='.git' \ - --exclude='.github' \ - --exclude='.env' \ - --exclude='session.config.json' \ - --exclude='eval-session.json' \ - --exclude='chat-sessions.json' \ - --exclude='chat-config.json' \ - --exclude='public/app.js' \ - . - mv /tmp/dist.tar.gz dist.tar.gz + - name: Pack release + run: npm run pack - name: Upload build artifact (for workflow logs) uses: actions/upload-artifact@v7 diff --git a/.gitignore b/.gitignore index 4788fdb..a7d462b 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,8 @@ session.config.json eval-session.json .codesignal/report.md .agent-build +dist +dist.tar.gz # Local course context for the agent only — do not commit Courses/ diff --git a/README.md b/README.md index 5cc20b0..d7c5ad7 100644 --- a/README.md +++ b/README.md @@ -114,8 +114,15 @@ Open http://localhost:3000 ```bash npm test +npm run pack # client + server bundles → dist/ and dist.tar.gz ``` +`.github/workflows/release.yml` runs tests, then `npm run pack`: a minified client +bundle, a single-file server bundle (Express + LLM SDKs inlined — no +`node_modules`), and the static files the server serves. Extract `dist.tar.gz` +and run `node server.js`. Supply `session.config.json` and `.env` at runtime. + + ## Stack - Node.js + Express diff --git a/package.json b/package.json index 53896d8..3402ec1 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "start:prod": "node server.js", "dev": "npm run build && concurrently -k -n client,server -c blue,green \"npm:build:watch\" \"node --watch server.js\"", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "pack": "node scripts/pack-dist.mjs" }, "dependencies": { "@anthropic-ai/sdk": "^0.123.0", diff --git a/scripts/pack-dist.mjs b/scripts/pack-dist.mjs new file mode 100644 index 0000000..a9028e9 --- /dev/null +++ b/scripts/pack-dist.mjs @@ -0,0 +1,154 @@ +#!/usr/bin/env node +/** + * Builds a runnable release tree into dist/ and archives it as dist.tar.gz. + * + * The tarball is the client bundle + a single-file server bundle + the static + * files Express serves. No node_modules. Extract and `node server.js`. + * + * Usage: + * node scripts/pack-dist.mjs + */ + +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import * as esbuild from 'esbuild'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, '..'); +const DIST = path.join(ROOT, 'dist'); +const TAR = path.join(ROOT, 'dist.tar.gz'); + +function copy(src, dest, filter) { + fs.cpSync(src, dest, { recursive: true, filter }); +} + +function bytes(file) { + return fs.statSync(file).size; +} + +function formatSize(n) { + if (n >= 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`; + if (n >= 1024) return `${(n / 1024).toFixed(0)} KB`; + return `${n} B`; +} + +function distSize(rel) { + const full = path.join(DIST, rel); + if (!fs.existsSync(full)) return 0; + const st = fs.statSync(full); + if (!st.isDirectory()) return st.size; + let total = 0; + for (const entry of fs.readdirSync(full, { withFileTypes: true })) { + const child = path.join(rel, entry.name); + total += distSize(child); + } + return total; +} + +fs.rmSync(DIST, { recursive: true, force: true }); +fs.mkdirSync(path.join(DIST, 'public'), { recursive: true }); + +await esbuild.build({ + absWorkingDir: ROOT, + entryPoints: ['public/app.js'], + bundle: true, + outfile: 'dist/public/app.bundle.js', + format: 'esm', + platform: 'browser', + minify: true, + logLevel: 'warning', +}); + +await esbuild.build({ + absWorkingDir: ROOT, + entryPoints: ['server.js'], + bundle: true, + outfile: 'dist/server.js', + format: 'esm', + platform: 'node', + minify: true, + banner: { + js: "import { createRequire } from 'module'; const require = createRequire(import.meta.url);", + }, + logLevel: 'warning', +}); + +copy(path.join(ROOT, 'public/index.html'), path.join(DIST, 'public/index.html')); +copy(path.join(ROOT, 'public/app.css'), path.join(DIST, 'public/app.css')); +copy(path.join(ROOT, 'public/Images'), path.join(DIST, 'public/Images')); +copy(path.join(ROOT, 'design-system'), path.join(DIST, 'design-system'), (src) => { + const rel = path.relative(path.join(ROOT, 'design-system'), src); + return !rel.split(path.sep).includes('.git'); +}); +// Bundled server resolves import.meta.url to dist/, so keep the prompt beside it. +copy( + path.join(ROOT, 'lib/eval-system-prompt.md'), + path.join(DIST, 'eval-system-prompt.md'), +); +copy(path.join(ROOT, '.env.example'), path.join(DIST, '.env.example')); +copy( + path.join(ROOT, 'session.config.example.json'), + path.join(DIST, 'session.config.example.json'), +); + +const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')); +fs.writeFileSync( + path.join(DIST, 'package.json'), + `${JSON.stringify( + { + name: pkg.name, + version: pkg.version, + private: true, + type: 'module', + scripts: { + start: 'node server.js', + }, + }, + null, + 2, + )}\n`, +); + +for (const rel of [ + 'public/app.bundle.js', + 'public/app.css', + 'public/index.html', + 'server.js', + 'eval-system-prompt.md', +]) { + if (!fs.existsSync(path.join(DIST, rel))) throw new Error(`missing ${rel} in dist/`); +} + +process.env.NODE_ENV = 'test'; +const { app } = await import(pathToFileURL(path.join(DIST, 'server.js')).href); +const server = await new Promise((resolve, reject) => { + const s = app.listen(0, '127.0.0.1', () => resolve(s)); + s.on('error', reject); +}); +try { + const port = server.address().port; + const health = await fetch(`http://127.0.0.1:${port}/api/session-config`); + if (!health.ok) throw new Error(`session-config check failed (${health.status})`); + await health.json(); +} finally { + server.closeAllConnections?.(); + await new Promise((resolve) => server.close(resolve)); +} + +if (fs.existsSync(TAR)) fs.unlinkSync(TAR); +execFileSync('tar', ['-czf', TAR, '-C', DIST, '.'], { cwd: ROOT }); + +const entries = [ + 'server.js', + 'public/app.bundle.js', + 'public', + 'design-system', + 'eval-system-prompt.md', +]; +console.log('Packed dist/ (no node_modules):'); +for (const rel of entries) { + console.log(` ${rel.padEnd(24)} ${formatSize(distSize(rel))}`); +} +console.log(` ${'dist.tar.gz'.padEnd(24)} ${formatSize(bytes(TAR))}`);