fuzzyworld-tools / files / lib / deploy.mjs
deploy.mjs
| 1 | // fw deploy: build an app from its canonical repo and publish it to apps.fuzzyworld.net/<app>/. |
| 2 | // |
| 3 | // fw deploy build branch `main` of dh:git/<repo>.git (a fresh clone) |
| 4 | // fw deploy --ref <branch> build another branch or tag |
| 5 | // fw deploy --as <app> publish under another path (another copy of the same repo, e.g. a `-next` build of main) |
| 6 | // fw deploy --here build the current directory as it is (uncommitted work included) |
| 7 | // fw deploy --dry-run build and check, but don't upload |
| 8 | // |
| 9 | // Configuration comes from package.json of the *built* source: |
| 10 | // "fuzzyworld": { |
| 11 | // "app": "my-game", // URL path: apps.fuzzyworld.net/my-game/ (required) |
| 12 | // "title": "My Game", // shown on the apps index |
| 13 | // "description": "...", // shown on the apps index |
| 14 | // "repo": "my-game", // repo name on DreamHost, if different from app |
| 15 | // "prebuild": ["pnpm gen-assets"], // shell commands before checks and build |
| 16 | // "checks": ["typecheck", "test"], // package scripts that must pass (default: those of typecheck/lint/test that exist) |
| 17 | // "build": "make public", // build command (default: `<pm> run build`) |
| 18 | // "out": "dist", // output directory (default: dist, else public, else build) |
| 19 | // "keep": ["app.apk"] // files already on the server that a deploy must not delete or replace |
| 20 | // "extra": ["sim/*.js"], // build-output files that every later deploy must keep (uploaded, updated, never deleted) |
| 21 | // "replay": "replay.html?report={record}" // viewer for bug-report records: an issue page links here (page of this app, relative) |
| 22 | // } |
| 23 | import { spawnSync, execFileSync } from 'node:child_process'; |
| 24 | import fs from 'node:fs'; |
| 25 | import os from 'node:os'; |
| 26 | import path from 'node:path'; |
| 27 | import { fileURLToPath } from 'node:url'; |
| 28 | |
| 29 | const HOST = process.env.FW_HOST || 'dh'; |
| 30 | const APPS_URL = 'https://apps.fuzzyworld.net'; |
| 31 | const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; |
| 32 | |
| 33 | class DeployError extends Error {} |
| 34 | |
| 35 | // Throws instead of exiting, so the temp clone is always removed (see `finally` in deploy()). |
| 36 | function die(msg) { |
| 37 | throw new DeployError(msg); |
| 38 | } |
| 39 | |
| 40 | function step(msg) { |
| 41 | console.log(`\n▶ ${msg}`); |
| 42 | } |
| 43 | |
| 44 | function run(cmd, args, opts = {}) { |
| 45 | const r = spawnSync(cmd, args, { stdio: 'inherit', ...opts }); |
| 46 | if (r.status !== 0) die(`failed: ${cmd} ${args.join(' ')}`); |
| 47 | } |
| 48 | |
| 49 | function shell(command, opts) { |
| 50 | run('/bin/sh', ['-c', command], opts); |
| 51 | } |
| 52 | |
| 53 | function git(args, cwd) { |
| 54 | return execFileSync('git', args, { cwd, encoding: 'utf8' }).trim(); |
| 55 | } |
| 56 | |
| 57 | function parseArgs(argv) { |
| 58 | const o = { ref: 'main', here: false, dryRun: false, as: '' }; |
| 59 | for (let i = 0; i < argv.length; i++) { |
| 60 | const a = argv[i]; |
| 61 | if (a === '--here') o.here = true; |
| 62 | else if (a === '--dry-run') o.dryRun = true; |
| 63 | else if (a === '--as') o.as = argv[++i] || die('--as needs a value'); |
| 64 | else if (a === '--ref') o.ref = argv[++i] || die('--ref needs a value'); |
| 65 | else die(`unknown option ${a}`); |
| 66 | } |
| 67 | if (o.as && !NAME_RE.test(o.as)) die(`invalid --as name ${JSON.stringify(o.as)}`); |
| 68 | return o; |
| 69 | } |
| 70 | |
| 71 | // A relative path or glob inside the app folder: no leading slash, no "..", no empty segments. |
| 72 | function checkPath(k, key, file) { |
| 73 | if (typeof k !== 'string' || !k || k.startsWith('/') || k.split('/').some((p) => p === '..' || p === '')) { |
| 74 | die(`invalid fuzzyworld.${key} entry ${JSON.stringify(k)} in ${file}`); |
| 75 | } |
| 76 | } |
| 77 | |
| 78 | // The replay template becomes a link on issue pages, so it must stay a page of this app. |
| 79 | export function checkReplay(t, file = 'package.json') { |
| 80 | const head = typeof t === 'string' ? t.split('?')[0] : ''; |
| 81 | if (typeof t !== 'string' || t.length > 200 || !t.includes('{record}') || t.startsWith('/') || t.includes('//') || |
| 82 | t.includes('..') || t.includes('\\') || head.includes(':') || head.includes('%') || /[\s\x00-\x1f\x7f]/.test(head)) { |
| 83 | die(`invalid fuzzyworld.replay ${JSON.stringify(t)} in ${file} (a relative page of the app, containing {record})`); |
| 84 | } |
| 85 | } |
| 86 | |
| 87 | // `keep` paths are excluded: rsync neither uploads nor deletes them. `extra` paths get a "protect" filter: |
| 88 | // they are uploaded and updated as usual, but --delete never removes them (old rules versions stay). |
| 89 | export function rsyncArgs(cfg, out, dest) { |
| 90 | const keep = (cfg.keep || []).flatMap((k) => ['--exclude', `/${k}`]); |
| 91 | const extra = (cfg.extra || []).flatMap((g) => ['--filter', `P /${g}`]); |
| 92 | return ['-rlptz', '--delete', ...keep, ...extra, `${out}/`, dest]; |
| 93 | } |
| 94 | |
| 95 | export function readConfig(dir) { |
| 96 | const file = path.join(dir, 'package.json'); |
| 97 | if (!fs.existsSync(file)) die(`no package.json in ${dir}`); |
| 98 | const pkg = JSON.parse(fs.readFileSync(file, 'utf8')); |
| 99 | const cfg = pkg.fuzzyworld; |
| 100 | if (!cfg) die(`no "fuzzyworld" section in ${file}`); |
| 101 | if (!cfg.app || !NAME_RE.test(cfg.app)) die(`invalid or missing fuzzyworld.app in ${file}`); |
| 102 | if (cfg.repo && !NAME_RE.test(cfg.repo)) die(`invalid fuzzyworld.repo in ${file}`); |
| 103 | for (const k of cfg.keep || []) checkPath(k, 'keep', file); |
| 104 | for (const g of cfg.extra || []) { |
| 105 | checkPath(g, 'extra', file); |
| 106 | if ((cfg.keep || []).includes(g)) die(`fuzzyworld.extra entry ${JSON.stringify(g)} is also in keep (keep files are never uploaded)`); |
| 107 | } |
| 108 | if (cfg.replay !== undefined) checkReplay(cfg.replay, file); |
| 109 | return { pkg, cfg }; |
| 110 | } |
| 111 | |
| 112 | function packageManager(dir) { |
| 113 | if (fs.existsSync(path.join(dir, 'pnpm-lock.yaml'))) return { pm: 'pnpm', install: ['install', '--frozen-lockfile'] }; |
| 114 | if (fs.existsSync(path.join(dir, 'yarn.lock'))) return { pm: 'yarn', install: ['install', '--frozen-lockfile'] }; |
| 115 | if (fs.existsSync(path.join(dir, 'package-lock.json'))) return { pm: 'npm', install: ['ci'] }; |
| 116 | return { pm: 'npm', install: ['install'] }; |
| 117 | } |
| 118 | |
| 119 | // ---------------------------------------------------------------- output checks |
| 120 | |
| 121 | function walk(dir, out = []) { |
| 122 | for (const e of fs.readdirSync(dir, { withFileTypes: true })) { |
| 123 | const p = path.join(dir, e.name); |
| 124 | if (e.isDirectory()) walk(p, out); |
| 125 | else out.push(p); |
| 126 | } |
| 127 | return out; |
| 128 | } |
| 129 | |
| 130 | // Resolve a URL as the browser would from the app's index page. |
| 131 | function resolveUrl(ref, app, from = `/${app}/`) { |
| 132 | return new URL(ref, `${APPS_URL}${from}`); |
| 133 | } |
| 134 | |
| 135 | function checkOutput(out, app) { |
| 136 | const problems = []; |
| 137 | const warnings = []; |
| 138 | const base = `/${app}/`; |
| 139 | const index = path.join(out, 'index.html'); |
| 140 | if (!fs.existsSync(index)) die(`build output has no index.html: ${out}`); |
| 141 | const html = fs.readFileSync(index, 'utf8'); |
| 142 | |
| 143 | // Every same-site URL in index.html must stay inside /<app>/. |
| 144 | for (const m of html.matchAll(/\b(?:src|href)=["']([^"']+)["']/g)) { |
| 145 | const u = resolveUrl(m[1], app); |
| 146 | if (u.origin === APPS_URL && !u.pathname.startsWith(base)) { |
| 147 | problems.push(`index.html references ${m[1]} (resolves outside ${base})`); |
| 148 | } |
| 149 | } |
| 150 | |
| 151 | // Web manifest: id, scope and start_url must be under /<app>/, so each app installs separately. |
| 152 | const mf = html.match(/<link[^>]+rel=["']manifest["'][^>]*>/i); |
| 153 | if (mf) { |
| 154 | const href = mf[0].match(/href=["']([^"']+)["']/)?.[1]; |
| 155 | const mUrl = href && resolveUrl(href, app); |
| 156 | const file = mUrl && path.join(out, decodeURIComponent(mUrl.pathname.slice(base.length))); |
| 157 | if (!file || !fs.existsSync(file)) { |
| 158 | problems.push(`manifest ${href} not found in build output`); |
| 159 | } else { |
| 160 | const m = JSON.parse(fs.readFileSync(file, 'utf8')); |
| 161 | const at = mUrl.pathname; |
| 162 | for (const key of ['scope', 'start_url', 'id']) { |
| 163 | if (m[key] === undefined) continue; |
| 164 | const p = resolveUrl(m[key], app, at).pathname; |
| 165 | if (!p.startsWith(base)) problems.push(`manifest ${key} "${m[key]}" resolves to ${p}, outside ${base}`); |
| 166 | } |
| 167 | if (m.id === undefined) warnings.push(`manifest has no "id"; browsers fall back to start_url. Set "id": "${base}" to be explicit.`); |
| 168 | } |
| 169 | } |
| 170 | |
| 171 | // Service workers: a registration scope (or script) outside /<app>/ would capture other apps. |
| 172 | for (const f of walk(out).filter((f) => /\.(m?js|html)$/.test(f))) { |
| 173 | const text = fs.readFileSync(f, 'utf8'); |
| 174 | for (const m of text.matchAll(/serviceWorker\.register\(\s*(["'`])([^"'`]+)\1([^)]*)\)/g)) { |
| 175 | const script = resolveUrl(m[2], app).pathname; |
| 176 | if (!script.startsWith(base)) problems.push(`${path.relative(out, f)} registers service worker ${m[2]} outside ${base}`); |
| 177 | const scope = m[3].match(/scope\s*:\s*(["'`])([^"'`]+)\1/)?.[2]; |
| 178 | if (scope && !resolveUrl(scope, app).pathname.startsWith(base)) { |
| 179 | problems.push(`${path.relative(out, f)} registers a service worker with scope "${scope}" outside ${base}`); |
| 180 | } |
| 181 | } |
| 182 | if (text.includes('gitlab.io')) warnings.push(`${path.relative(out, f)} mentions gitlab.io`); |
| 183 | } |
| 184 | return { problems, warnings }; |
| 185 | } |
| 186 | |
| 187 | // ---------------------------------------------------------------- main |
| 188 | |
| 189 | export function deploy(argv) { |
| 190 | const o = parseArgs(argv); |
| 191 | const cwd = process.cwd(); |
| 192 | let top; |
| 193 | try { |
| 194 | top = git(['rev-parse', '--show-toplevel'], cwd); |
| 195 | } catch { |
| 196 | top = cwd; |
| 197 | } |
| 198 | let { cfg } = readConfig(top); |
| 199 | const repo = cfg.repo || cfg.app; |
| 200 | |
| 201 | let src = top; |
| 202 | let tmp = null; |
| 203 | if (o.here) { |
| 204 | const dirty = spawnSync('git', ['status', '--porcelain'], { cwd: top, encoding: 'utf8' }).stdout.trim(); |
| 205 | if (dirty) console.warn('fw deploy: --here with uncommitted changes; the deployed app will not match any commit'); |
| 206 | } else { |
| 207 | // Build what is pushed, never the working copy: a fresh shallow clone of the canonical repo. |
| 208 | tmp = fs.mkdtempSync(path.join(os.tmpdir(), `fw-deploy-${cfg.app}-`)); |
| 209 | src = path.join(tmp, 'src'); |
| 210 | step(`cloning ${HOST}:git/${repo}.git (${o.ref})`); |
| 211 | run('git', ['clone', '--quiet', '--depth', '1', '--branch', o.ref, `${HOST}:git/${repo}.git`, src]); |
| 212 | ({ cfg } = readConfig(src)); // the committed config is what gets deployed |
| 213 | } |
| 214 | // `--as`: the same repo under another path; the repo it is cloned from stays cfg.app's (or cfg.repo). |
| 215 | const app = o.as || cfg.app; |
| 216 | const commit = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: src, encoding: 'utf8' }).stdout.trim() || 'unknown'; |
| 217 | const { pkg } = readConfig(src); |
| 218 | const env = { ...process.env, FW_APP: app, FW_BASE: `/${app}/` }; |
| 219 | |
| 220 | try { |
| 221 | const { pm, install } = packageManager(src); |
| 222 | step(`${pm} ${install.join(' ')}`); |
| 223 | run(pm, install, { cwd: src, env }); |
| 224 | |
| 225 | for (const c of cfg.prebuild || []) { |
| 226 | step(c); |
| 227 | shell(c, { cwd: src, env }); |
| 228 | } |
| 229 | |
| 230 | const scripts = pkg.scripts || {}; |
| 231 | const checks = cfg.checks ?? ['typecheck', 'lint', 'test'].filter((s) => scripts[s] && !/no test specified/.test(scripts[s])); |
| 232 | for (const c of checks) { |
| 233 | if (!scripts[c]) die(`check "${c}" is not a script in package.json`); |
| 234 | step(`check: ${pm} run ${c}`); |
| 235 | run(pm, ['run', c], { cwd: src, env }); |
| 236 | } |
| 237 | |
| 238 | step(`build: ${cfg.build || `${pm} run build`}`); |
| 239 | if (cfg.build) shell(cfg.build, { cwd: src, env }); |
| 240 | else run(pm, ['run', 'build'], { cwd: src, env }); |
| 241 | |
| 242 | const outName = cfg.out || ['dist', 'public', 'build'].find((d) => fs.existsSync(path.join(src, d, 'index.html'))); |
| 243 | if (!outName) die('could not find the build output (set fuzzyworld.out)'); |
| 244 | const out = path.join(src, outName); |
| 245 | |
| 246 | step(`checking ${outName}/`); |
| 247 | const { problems, warnings } = checkOutput(out, app); |
| 248 | for (const w of warnings) console.warn(` warning: ${w}`); |
| 249 | if (problems.length) { |
| 250 | for (const p of problems) console.error(` ✗ ${p}`); |
| 251 | die(`${problems.length} problem(s) in the build output; nothing was uploaded`); |
| 252 | } |
| 253 | console.log(' ✓ all URLs, manifest and service worker stay inside', `/${app}/`); |
| 254 | |
| 255 | fs.writeFileSync(path.join(out, '.fuzzyworld.json'), JSON.stringify({ |
| 256 | app, repo, title: (cfg.title || cfg.app) + (o.as ? ` (${o.as.startsWith(cfg.app + '-') ? o.as.slice(cfg.app.length + 1) : o.as})` : ''), description: cfg.description || '', replay: cfg.replay || '', |
| 257 | commit, ref: o.here ? '(working copy)' : o.ref, deployed: new Date().toISOString(), |
| 258 | }, null, 2)); |
| 259 | |
| 260 | if (o.dryRun) { |
| 261 | console.log(`\n(dry run) would upload ${out}/ to ${HOST}:apps.fuzzyworld.net/${app}/`); |
| 262 | if (cfg.extra?.length) console.log(`(dry run) kept across deploys: ${cfg.extra.join(', ')}`); |
| 263 | return; |
| 264 | } |
| 265 | step(`uploading to apps.fuzzyworld.net/${app}/`); |
| 266 | run('ssh', [HOST, `mkdir -p apps.fuzzyworld.net/${app}`]); |
| 267 | run('rsync', rsyncArgs(cfg, out, `${HOST}:apps.fuzzyworld.net/${app}/`)); |
| 268 | run('ssh', [HOST, `~/bin/fw app-deployed ${app} ${repo}`]); |
| 269 | console.log(`\n✅ ${APPS_URL}/${app}/ (commit ${commit.slice(0, 12)})`); |
| 270 | } finally { |
| 271 | if (tmp) fs.rmSync(tmp, { recursive: true, force: true }); |
| 272 | } |
| 273 | } |
| 274 | |
| 275 | if (process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) { |
| 276 | try { |
| 277 | deploy(process.argv.slice(2)); |
| 278 | } catch (e) { |
| 279 | if (!(e instanceof DeployError)) throw e; |
| 280 | console.error(`\nfw deploy: ${e.message}`); |
| 281 | process.exit(1); |
| 282 | } |
| 283 | } |