Extract API, migrations and portable NAS deployment

This commit is contained in:
tomchukk committed 2026-09-07 19:34:49 +03:00
commit 673255fa4f
57 files changed
+3799

No files matched your search

+57
View File
@@ -0,0 +1,57 @@
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.');