import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { spawnSync } from 'node:child_process'; const apiRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const webRoot = resolve(process.argv[2] ?? join(apiRoot, '../delivery-app-web')); const output = join(apiRoot, '.image'); if (existsSync(output)) throw new Error('Existing .image directory: move it aside before staging a new release.'); if (JSON.parse(readFileSync(join(webRoot, 'package.json'), 'utf8')).name !== '@model/web') { throw new Error('Expected the delivery-app-web checkout as the argument.'); } if (!process.env.npm_execpath) throw new Error('Run through pnpm image:stage.'); function pnpm(cwd, args) { const result = spawnSync(process.execPath, [process.env.npm_execpath, ...args], { cwd, stdio: 'inherit' }); if (result.error) throw result.error; if (result.status !== 0) throw new Error('Staging command failed. No image was built.'); } pnpm(apiRoot, ['build']); pnpm(webRoot, ['build']); mkdirSync(output); // A hoisted deployment contains real directories. Windows junctions from the // default isolated linker become dangling links when copied into a Linux image. pnpm(apiRoot, ['--config.node-linker=hoisted', '--filter', '@model/api', 'deploy', '--legacy', '--prod', join(output, 'api')]); // Legacy hoisted deploy may resolve again. Refuse any version that wasn't in the // workstation's locked install, so staging cannot silently ship untested updates. function packagesIn(modules, visit) { for (const entry of readdirSync(modules, { withFileTypes: true })) { if (entry.name.startsWith('.')) continue; const path = join(modules, entry.name); if (entry.name.startsWith('@')) { packagesIn(path, visit); continue; } if (!existsSync(join(path, 'package.json'))) continue; const pkg = JSON.parse(readFileSync(join(path, 'package.json'), 'utf8')); visit(`${pkg.name}@${pkg.version}`); if (existsSync(join(path, 'node_modules'))) packagesIn(join(path, 'node_modules'), visit); } } const testedPackages = new Set(); const virtualStore = join(apiRoot, 'node_modules/.pnpm'); for (const entry of readdirSync(virtualStore, { withFileTypes: true })) { if (entry.isDirectory() && existsSync(join(virtualStore, entry.name, 'node_modules'))) { packagesIn(join(virtualStore, entry.name, 'node_modules'), name => testedPackages.add(name)); } } packagesIn(join(output, 'api/node_modules'), name => { if (!testedPackages.has(name)) throw new Error(`Deployment resolved an untested dependency: ${name}. Review and update the locked install before staging again.`); }); cpSync(join(webRoot, 'dist'), join(output, 'web'), { recursive: true }); function revision(cwd) { const result = spawnSync('git', ['rev-parse', 'HEAD'], { cwd, encoding: 'utf8' }); return result.status === 0 ? result.stdout.trim() : 'uncommitted'; } writeFileSync(join(output, 'release.json'), JSON.stringify({ builtAt: new Date().toISOString(), api: revision(apiRoot), web: revision(webRoot), shared: JSON.parse(readFileSync(join(apiRoot, 'package.json'), 'utf8')).dependencies['@model/shared'], }, null, 2) + '\n'); console.log('Staged production API dependencies and PWA assets in .image/. Build the standalone Dockerfile next.');