/** * Build the solitaire site and push it to a File Browser instance. * * Written against File Browser v2.63's REST API, read from its own bundle rather than guessed: * * POST /api/login {username, password, recaptcha} -> JWT as plain text * POST /api/resources// X-Auth: -> create a directory * POST /api/resources/?override=true X-Auth: , body = bytes -> upload * * File Browser is the STORE, not the server — Start9 Pages serves the uploaded folder as the site. * So the job here is simply to land the built files in the right folder, intact. * * CREDENTIALS COME FROM THE ENVIRONMENT and are never written anywhere. Putting a password in a * repo is how it ends up in a commit, and this repo is going to be pushed. * * FB_USER=jesse FB_PASS='…' npm run deploy:web * * Optional: * FB_URL default https://phoenix.local:58157 * FB_DEST default websites/stationmaster — the folder Start9 Pages serves from * FB_INSECURE set to 1 for a self-signed certificate (usual for a .local StartOS host) * --dry-run list what would be sent, contact nothing */ import { execFileSync } from 'node:child_process'; import { readFileSync, readdirSync, statSync } from 'node:fs'; import { dirname, join, posix, relative, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; const root = join(dirname(fileURLToPath(import.meta.url)), '..'); const dist = join(root, 'dist'); const URL_BASE = (process.env['FB_URL'] ?? 'https://phoenix.local:58157').replace(/\/+$/, ''); const DEST = `/${(process.env['FB_DEST'] ?? 'websites/stationmaster').replace(/^\/+|\/+$/g, '')}`; const USER = process.env['FB_USER'] ?? ''; const PASS = process.env['FB_PASS'] ?? ''; const DRY = process.argv.includes('--dry-run'); /** * A .local StartOS host presents a certificate the system store does not know. Disabling * verification is opt-in and announced rather than silent: it is the right call on a LAN box you * own and the wrong one everywhere else, and that judgment is not the script's to make quietly. */ if (process.env['FB_INSECURE'] === '1') { process.env['NODE_TLS_REJECT_UNAUTHORIZED'] = '0'; console.warn('! TLS verification disabled (FB_INSECURE=1)'); } /** Every file in `dir`, as paths relative to it, with POSIX separators. */ function walk(dir: string, base = dir): string[] { const out: string[] = []; for (const entry of readdirSync(dir)) { const full = join(dir, entry); if (statSync(full).isDirectory()) out.push(...walk(full, base)); else out.push(relative(base, full).split(sep).join('/')); } return out.sort(); } const CONTENT_TYPES: Record = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.txt': 'text/plain', }; async function login(): Promise { const res = await fetch(`${URL_BASE}/api/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: USER, password: PASS, recaptcha: '' }), }); const body = await res.text(); if (!res.ok) throw new Error(`login failed: ${res.status} ${body || res.statusText}`); if (!body.trim()) throw new Error('login returned an empty token'); return body.trim(); } async function makeDir(jwt: string, path: string): Promise { // Trailing slash is what marks a directory in this API. A 409 means it already exists, which is // the normal case on every deploy after the first. const res = await fetch(`${URL_BASE}/api/resources${encodePath(path)}/`, { method: 'POST', headers: { 'X-Auth': jwt }, }); if (!res.ok && res.status !== 409) { throw new Error(`could not create ${path}: ${res.status} ${await res.text()}`); } } async function upload(jwt: string, localPath: string, remotePath: string): Promise { const bytes = readFileSync(localPath); const ext = remotePath.slice(remotePath.lastIndexOf('.')); const res = await fetch(`${URL_BASE}/api/resources${encodePath(remotePath)}?override=true`, { method: 'POST', headers: { 'X-Auth': jwt, 'Content-Type': CONTENT_TYPES[ext] ?? 'application/octet-stream', 'Content-Length': String(bytes.byteLength), }, body: new Uint8Array(bytes), }); if (!res.ok) throw new Error(`upload ${remotePath} failed: ${res.status} ${await res.text()}`); } /** Encode each segment but keep the separators, so a path stays a path. */ function encodePath(p: string): string { return p.split('/').map(encodeURIComponent).join('/'); } // --------------------------------------------------------------------------- console.log('building…'); execFileSync('node', ['scripts/build-web.ts'], { cwd: root, stdio: 'inherit' }); const files = walk(dist); if (files.length === 0) throw new Error('dist/ is empty — nothing to deploy'); const dirs = [...new Set(files.map((f) => posix.dirname(f)).filter((d) => d !== '.'))].sort(); const total = files.reduce((n, f) => n + statSync(join(dist, f)).size, 0); console.log(`\n${files.length} files, ${(total / 1024).toFixed(0)} KB`); console.log(` from ${dist}`); console.log(` to ${URL_BASE}${DEST}\n`); if (DRY) { for (const d of dirs) console.log(` dir ${DEST}/${d}`); for (const f of files) console.log(` file ${DEST}/${f}`); console.log('\ndry run — nothing was sent'); } else { if (!USER || !PASS) { throw new Error( 'set FB_USER and FB_PASS.\n' + " e.g. FB_USER=me FB_PASS='…' FB_INSECURE=1 npm run deploy:web\n" + ' or run with --dry-run to see what would be sent', ); } const jwt = await login(); console.log('logged in'); await makeDir(jwt, DEST); for (const d of dirs) await makeDir(jwt, `${DEST}/${d}`); let done = 0; for (const f of files) { await upload(jwt, join(dist, f), `${DEST}/${f}`); done++; console.log(` [${String(done).padStart(2)}/${files.length}] ${f}`); } console.log(`\ndeployed to ${URL_BASE}${DEST}`); console.log('Start9 Pages serves these files as the site; File Browser is only the store.'); }