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:
@@ -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}`);
|
||||
@@ -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.');
|
||||
}
|
||||
Reference in New Issue
Block a user