diff --git a/package.json b/package.json index fd192b1..4b437ce 100644 --- a/package.json +++ b/package.json @@ -28,8 +28,8 @@ "start": "npm run dev --workspace=@page-agent/website", "dev:ext": "npm run dev -w @page-agent/ext", "dev:demo": "npm run dev:demo --workspace=page-agent", - "build": "npm run build:libs && npm run build:website", - "build:libs": "npm run build --workspaces --if-present", + "build": "node scripts/build.js", + "build:libs": "node scripts/build-libs.js", "build:website": "npm run build:website --workspace=@page-agent/website", "build:ext": "npm run zip -w @page-agent/ext", "version": "node scripts/sync-version.js", diff --git a/scripts/build-libs.js b/scripts/build-libs.js new file mode 100644 index 0000000..4ad78fb --- /dev/null +++ b/scripts/build-libs.js @@ -0,0 +1,25 @@ +#!/usr/bin/env node +/** + * Equivalent to: npm run build --workspaces --if-present + * + * Reads the workspace list from root package.json, filters to those with a + * "build" script, and runs them all concurrently via parallelTask. + */ +import { readFileSync } from 'fs' +import { dirname, join } from 'path' +import { fileURLToPath } from 'url' + +import { parallelTask } from './parallel-task.js' + +const rootDir = join(dirname(fileURLToPath(import.meta.url)), '..') +const rootPkg = JSON.parse(readFileSync(join(rootDir, 'package.json'), 'utf-8')) + +const tasks = rootPkg.workspaces + .map((ws) => { + const dir = join(rootDir, ws) + const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf-8')) + return pkg.scripts?.build ? { label: pkg.name, command: 'npm run build', cwd: dir } : null + }) + .filter(Boolean) + +await parallelTask(tasks, { timeoutMs: 120_000 }) diff --git a/scripts/build.js b/scripts/build.js new file mode 100644 index 0000000..81e26d3 --- /dev/null +++ b/scripts/build.js @@ -0,0 +1,45 @@ +#!/usr/bin/env node +/** + * Full build pipeline. Equivalent to: + * npm run cleanup && npm run build --workspaces --if-present + * && npm run build:website -w @page-agent/website + * && npm run zip -w @page-agent/ext + * + * 1. cleanup + * 2. build everything in parallel (libs + website + extension) + */ +import chalk from 'chalk' +import { execSync } from 'child_process' +import { readFileSync } from 'fs' +import { dirname, join } from 'path' +import { fileURLToPath } from 'url' + +import { parallelTask } from './parallel-task.js' + +const rootDir = join(dirname(fileURLToPath(import.meta.url)), '..') +const rootPkg = JSON.parse(readFileSync(join(rootDir, 'package.json'), 'utf-8')) + +// Step 1: cleanup +console.log(chalk.bgBlue.white.bold(' ▸ cleanup ')) +execSync('npm run cleanup', { cwd: rootDir, stdio: 'inherit' }) + +// Step 2: build all in parallel +console.log(chalk.bgBlue.white.bold(' ▸ build ')) +const tasks = rootPkg.workspaces + .map((ws) => { + const dir = join(rootDir, ws) + const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf-8')) + return pkg.scripts?.build ? { label: pkg.name, command: 'npm run build', cwd: dir } : null + }) + .filter(Boolean) + +tasks.push( + { + label: '@page-agent/website', + command: 'npm run build:website', + cwd: join(rootDir, 'packages/website'), + }, + { label: '@page-agent/ext', command: 'npm run zip', cwd: join(rootDir, 'packages/extension') } +) + +await parallelTask(tasks, { timeoutMs: 120_000 }) diff --git a/scripts/parallel-task.js b/scripts/parallel-task.js new file mode 100644 index 0000000..2cb3248 --- /dev/null +++ b/scripts/parallel-task.js @@ -0,0 +1,115 @@ +import chalk from 'chalk' +import { spawn } from 'child_process' + +/** + * Run multiple shell commands in parallel with progress reporting. + * + * @param {{ label: string, command: string, cwd?: string }[]} tasks + * @param {{ timeoutMs?: number }} options - Default timeout 30s per task + * @returns {Promise} Rejects (process.exit) if any task fails + */ +export async function parallelTask(tasks, options = {}) { + const { timeoutMs = 30_000 } = options + const total = tasks.length + + const bgColors = [ + chalk.bgCyan, + chalk.bgMagenta, + chalk.bgBlue, + chalk.bgYellow, + chalk.bgGreenBright, + ] + const fgOnBg = [chalk.black, chalk.white, chalk.white, chalk.black, chalk.black] + + let done = 0 + let failed = 0 + + const spinner = ['◐', '◓', '◑', '◒'] + let tick = 0 + + const printProgress = () => { + const running = total - done - failed + const s = spinner[tick++ % spinner.length] + const status = failed + ? `${running} running, ${done} done, ${chalk.bgRed.white.bold(` ${failed} failed `)}` + : `${running} running, ${done} done` + process.stderr.write(`\r${chalk.bgCyan.black.bold(` ${s} ${done}/${total} `)} ${status} `) + } + + const timer = setInterval(printProgress, 1000) + printProgress() + + /** @type {{ label: string, output: string, exitCode: number | null, timedOut: boolean }[]} */ + const results = await Promise.all( + tasks.map( + (task) => + new Promise((resolve) => { + const chunks = /** @type {Buffer[]} */ ([]) + + const child = spawn('sh', ['-c', task.command], { + cwd: task.cwd, + env: { ...process.env, FORCE_COLOR: '1', NO_COLOR: '' }, + stdio: ['ignore', 'pipe', 'pipe'], + }) + + child.stdout.on('data', (d) => chunks.push(d)) + child.stderr.on('data', (d) => chunks.push(d)) + + const timeout = setTimeout(() => { + child.kill('SIGTERM') + }, timeoutMs) + + child.on('close', (code, signal) => { + clearTimeout(timeout) + const timedOut = signal === 'SIGTERM' + const exitCode = timedOut ? 1 : code + + if (exitCode === 0) done++ + else failed++ + + resolve({ + label: task.label, + output: Buffer.concat(chunks).toString(), + exitCode, + timedOut, + }) + }) + }) + ) + ) + + clearInterval(timer) + process.stderr.write('\r\x1b[K') + + const failedTasks = /** @type {typeof results} */ ([]) + + for (let i = 0; i < results.length; i++) { + const r = results[i] + if (r.exitCode !== 0) { + failedTasks.push(r) + continue + } + const bg = bgColors[i % bgColors.length] + const fg = fgOnBg[i % fgOnBg.length] + const banner = bg(fg.bold(` ✔ ${r.label} `)) + console.log(`\n${banner}`) + if (r.output.trim()) process.stdout.write(r.output) + } + + if (failedTasks.length) { + for (const r of failedTasks) { + const banner = chalk.bgRed( + chalk.white.bold(` ✘ ${r.label} ${r.timedOut ? '· timed out' : '· failed'} `) + ) + console.log(`\n${banner}`) + if (r.output.trim()) process.stdout.write(r.output) + } + const summary = failedTasks.map((t) => t.label).join(', ') + console.error( + `\n${chalk.bgRed.white.bold(` ✘ ${failedTasks.length}/${total} failed: ${summary} `)}` + ) + process.exit(1) + } + + console.log(`\n${chalk.bgGreen.black.bold(` ✔ All ${total} tasks completed `)}`) +}