fuzzyworld

fuzzyworld-tools / files / lib / deploy.mjs

deploy.mjs

12.6 KB · history · blame

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// }
23import { spawnSync, execFileSync } from 'node:child_process';
24import fs from 'node:fs';
25import os from 'node:os';
26import path from 'node:path';
27import { fileURLToPath } from 'node:url';
28
29const HOST = process.env.FW_HOST || 'dh';
30const APPS_URL = 'https://apps.fuzzyworld.net';
31const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
32
33class DeployError extends Error {}
34
35// Throws instead of exiting, so the temp clone is always removed (see `finally` in deploy()).
36function die(msg) {
37 throw new DeployError(msg);
38}
39
40function step(msg) {
41 console.log(`\n▶ ${msg}`);
42}
43
44function 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
49function shell(command, opts) {
50 run('/bin/sh', ['-c', command], opts);
51}
52
53function git(args, cwd) {
54 return execFileSync('git', args, { cwd, encoding: 'utf8' }).trim();
55}
56
57function 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.
72function 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.
79export 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).
89export 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
95export 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
112function 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
121function 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.
131function resolveUrl(ref, app, from = `/${app}/`) {
132 return new URL(ref, `${APPS_URL}${from}`);
133}
134
135function 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
189export 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
275if (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}