various fixes. added version#s and added a playable browser build of the solitaire game (including deploy to filebrowser script)

This commit is contained in:
Jesse
2026-07-31 22:03:13 -04:00
parent 160190da3f
commit 2eca9de09f
16 changed files with 2399 additions and 401 deletions
+155
View File
@@ -0,0 +1,155 @@
/**
* 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/<dir>/ X-Auth: <jwt> -> create a directory
* POST /api/resources/<file>?override=true X-Auth: <jwt>, 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<string, string> = {
'.html': 'text/html',
'.js': 'text/javascript',
'.css': 'text/css',
'.json': 'application/json',
'.txt': 'text/plain',
};
async function login(): Promise<string> {
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<void> {
// 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<void> {
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.');
}