/** * Build the solitaire site and push it to a FileBrowser instance. * * REWRITTEN FOR **FileBrowser Quantum** (2026-08-23). The host was upgraded from File Browser v2.63 * to the Quantum fork, whose API is different in three ways at once, and every deploy failed with * `login failed: 404 404 page not found` — the old `/api/login` simply is not there any more. * * Read from the running instance's own bundle rather than guessed, the same way the v2.63 version * was (`/public/static/assets/index-*.js`, gzipped — pipe it through `gunzip` before grepping), and * each path confirmed against the live host by the response code: an endpoint that exists answers a * bad password with **401**, one that does not answers **404**. * * POST /api/auth/login?username=&recaptcha= * headers X-Password: , X-Secret: -> sets a session COOKIE * GET /api/settings/sources -> the named sources * POST /api/resources?path=

&source=&isDir=true -> create a directory * POST /api/resources?path=

&source=&override=true body=bytes -> upload * * THREE THINGS MOVED, and each would break on its own: * 1. AUTH IS A COOKIE, not an `X-Auth: ` header. Login returns no usable token in its body; * the session arrives in `Set-Cookie` and every later request has to carry it back. * 2. THE PASSWORD IS A HEADER, `X-Password`, URL-encoded — not a JSON body field. * 3. THE PATH IS A QUERY PARAMETER, `?path=`, not part of the URL, and every resource call also * needs a **`source`** naming which configured store to write to. Quantum throws "no source * provided" without it. `FB_SOURCE` names it; left unset, the sole configured source is used, * and if there is more than one this stops and lists them rather than guessing. * * 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_SOURCE which configured source to write to; discovered automatically when there is one * FB_OTP the one-time code, if the account has two-factor enabled * FB_INSECURE set to 1 for a self-signed certificate (usual for a .local StartOS host) * SITE_URL default https://65.78.82.12:54697/ — the public address Start9 Pages serves at * --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)), '..'); /** * Where Start9 Pages actually publishes the site — the address a player types, as opposed to the * File Browser folder the files are uploaded INTO. The two are unrelated and the deploy output used * to print only the second, which is the one nobody wants. * * Provisional: this is the playtesting host and it will change. Override with SITE_URL. */ const SITE_URL = process.env['SITE_URL'] ?? 'https://65.78.82.12:54697/'; 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 OTP = process.env['FB_OTP'] ?? ''; const SOURCE_ENV = process.env['FB_SOURCE'] ?? ''; 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', }; /** * Log in and return the session cookie every later request must carry. * * The password goes in a HEADER and URL-encoded, which is Quantum's own client does * (`X-Password: encodeURIComponent(password)`). The body carries nothing useful on success — the * session is in `Set-Cookie`, so a deploy that ignored the cookie would authenticate and then be * rejected by every upload. */ async function login(): Promise { const url = `${URL_BASE}/api/auth/login?username=${encodeURIComponent(USER)}&recaptcha=`; const res = await fetch(url, { method: 'POST', headers: { 'X-Password': encodeURIComponent(PASS), 'X-Secret': OTP }, }); const body = await res.text(); if (!res.ok) { // 401 here is a wrong username/password; 404 would mean this build has moved the API again. throw new Error(`login failed: ${res.status} ${body || res.statusText}`); } const cookies = res.headers.getSetCookie(); if (cookies.length === 0) throw new Error('login succeeded but set no session cookie'); return cookies.map((c) => c.split(';')[0]).join('; '); } /** * WHICH STORE TO WRITE TO. Quantum can serve several named sources and refuses any resource call * that does not name one ("no source provided"), which is the parameter the v2.63 API had no * concept of. One configured source is the normal case and is used without asking; more than one is * ambiguous, and guessing would silently deploy the site into the wrong store. */ async function resolveSource(cookie: string): Promise { if (SOURCE_ENV) return SOURCE_ENV; const res = await fetch(`${URL_BASE}/api/settings/sources`, { headers: { cookie } }); if (!res.ok) throw new Error(`could not list sources: ${res.status} ${await res.text()}`); const names = Object.keys((await res.json()) ?? {}); if (names.length === 1) return names[0]!; if (names.length === 0) throw new Error('the server reports no sources at all'); throw new Error(`several sources configured (${names.join(', ')}) — pick one with FB_SOURCE=`); } function resourceUrl(source: string, path: string, extra: Record): string { const params = new URLSearchParams({ path, source, ...extra }); return `${URL_BASE}/api/resources?${params}`; } async function makeDir(cookie: string, source: string, path: string): Promise { const res = await fetch(resourceUrl(source, path, { isDir: 'true' }), { method: 'POST', headers: { cookie }, }); if (res.ok) return; /** * "Already there" is the normal case on every deploy after the first, and Quantum is not * consistent about which code it reports it with. So the two failures worth stopping for are * named — a rejected session, and a server that broke — and every other 4xx is treated as the * directory already existing. A directory that genuinely is not there fails loudly at the upload * a moment later, which is a better place to find out than a guess here. */ const fatal = res.status === 401 || res.status === 403 || res.status >= 500; if (fatal) throw new Error(`could not create ${path}: ${res.status} ${await res.text()}`); } async function upload( cookie: string, source: string, localPath: string, remotePath: string, ): Promise { const bytes = readFileSync(localPath); const ext = remotePath.slice(remotePath.lastIndexOf('.')); const res = await fetch(resourceUrl(source, remotePath, { override: 'true' }), { method: 'POST', headers: { cookie, '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()}`); } // --------------------------------------------------------------------------- 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 cookie = await login(); const source = await resolveSource(cookie); console.log(`logged in — writing to source "${source}"`); await makeDir(cookie, source, DEST); for (const d of dirs) await makeDir(cookie, source, `${DEST}/${d}`); let done = 0; for (const f of files) { await upload(cookie, source, join(dist, f), `${DEST}/${f}`); done++; console.log(` [${String(done).padStart(2)}/${files.length}] ${f}`); } console.log(`\nDeployed to FileBrowser at: ${URL_BASE}${DEST}`); console.log(`Start9 Pages serves this website at: ${SITE_URL}`); }