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
+91
View File
@@ -0,0 +1,91 @@
/**
* Build the static solitaire site into `dist/`.
*
* Everything here is BUILD-time. The output is plain files on disk — upload them anywhere that
* serves static content and the game runs entirely in the visitor's browser. No server, no API, no
* network call at runtime.
*
* The one thing a browser cannot do is run TypeScript: Node 22's type-stripping is a Node feature.
* So `tsc` emits ES2022 modules, rewriting the `.ts` import extensions the engine uses into `.js`.
*/
import { execFileSync } from 'node:child_process';
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const dist = join(root, 'dist');
rmSync(dist, { recursive: true, force: true });
mkdirSync(dist, { recursive: true });
execFileSync(
'npx',
[
'tsc',
'--ignoreConfig',
'src/web/main.ts',
'--outDir', dist,
'--rootDir', 'src',
'--target', 'es2022',
'--module', 'es2022',
'--moduleResolution', 'bundler',
'--lib', 'es2022,dom',
'--types', '',
'--strict',
'--allowImportingTsExtensions',
'--rewriteRelativeImportExtensions',
],
{ cwd: root, stdio: 'inherit' },
);
/**
* Stamp the build into the page.
*
* Once this is published, "what is actually deployed?" stops being answerable by looking at the
* source. The stamp is written into index.html at copy time rather than generated into `src/`, so
* no generated file has to be ignored, imported, or kept in step.
*
* `-dirty` marks a build made from an uncommitted tree — the honest state of most test deploys, and
* exactly the thing you want to know when a fix appears to have had no effect.
*/
function buildStamp(): string {
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { version: string };
let git = 'nogit';
try {
const sha = execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: root })
.toString()
.trim();
const dirty =
execFileSync('git', ['status', '--porcelain'], { cwd: root }).toString().trim().length > 0;
git = sha + (dirty ? '-dirty' : '');
} catch {
// A build from a tarball with no git history is still a valid build.
}
const when = new Date().toISOString().replace('T', ' ').slice(0, 16);
return `v${pkg.version} · ${git} · ${when}Z`;
}
const stamp = buildStamp();
const page = readFileSync(join(root, 'src/web/index.html'), 'utf8').replaceAll('__BUILD__', stamp);
writeFileSync(join(dist, 'index.html'), page);
// A tiny note for whoever unzips this later and wonders what it needs.
writeFileSync(
join(dist, 'README.txt'),
[
'Station Master — solitaire, static build.',
'',
'Upload the contents of this folder to any static host and open index.html.',
'There is no server component. The game runs entirely in the browser and saves',
'to localStorage. Add ?seed=1234 to the URL for a reproducible deal.',
'',
'Note: ES modules require the files to be SERVED over http(s). Opening',
'index.html directly from the filesystem will be blocked by the browser.',
'To try it locally: npx http-server dist (or any static file server)',
'',
].join('\n'),
);
console.log(`built -> ${dist}\n ${stamp}`);
+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.');
}