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

+9
View File
@@ -0,0 +1,9 @@
import { spawnSync } from 'node:child_process';
export function compose(args) {
const engine = ['docker', 'podman'].find(command => spawnSync(command, ['version'], { stdio: 'ignore' }).status === 0);
if (!engine) throw new Error('Start Docker Desktop or Podman, then retry.');
const result = spawnSync(engine, ['compose', '--env-file', '.env', '-f', 'infra/compose.yaml', ...args], { stdio: 'inherit' });
if (result.error) throw result.error;
if (result.status !== 0) throw new Error(`Compose failed (${result.status})`);
}
if (process.argv[1]?.replaceAll('\\', '/').endsWith('/compose.mjs')) compose(process.argv.slice(2));
+9
View File
@@ -0,0 +1,9 @@
import { spawnSync } from 'node:child_process';
import { compose } from './compose.mjs';
// The fixed, separate database is never dropped. No credentials are passed on the command line.
compose(['exec', '-T', 'db', 'sh', '-c', `export MYSQL_PWD="$MARIADB_ROOT_PASSWORD"; mariadb -u root -e "CREATE DATABASE IF NOT EXISTS model_delivery_test CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; GRANT ALL PRIVILEGES ON model_delivery_test.* TO '$MARIADB_USER'@'%';"`]);
for (const script of ['db:migrate', 'db:seed']) {
// pnpm exposes its JS entry point to scripts; invoking Node avoids Windows shell quoting.
const result = spawnSync(process.execPath, [process.env.npm_execpath, script], { stdio: 'inherit', env: { ...process.env, DB_NAME: 'model_delivery_test' } });
if (result.status !== 0) process.exit(result.status ?? 1);
}
+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.');