156 lines
6.0 KiB
TypeScript
156 lines
6.0 KiB
TypeScript
/**
|
|
* 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.');
|
|
}
|