236 lines
13 KiB
TypeScript
236 lines
13 KiB
TypeScript
// Web assets of a Capacitor app inside the native projects, and the prebuild hook that refreshes them.
|
|
// A Capacitor app's native build ships whatever `npx cap sync` last copied into ios/App/App/public and
|
|
// android/app/src/main/assets/public. Without a fresh web build + cap sync, `apple build` / `play build` archive an old
|
|
// game (pitfall 23). So:
|
|
// - build.prebuild (or apple.prebuild / play.prebuild) is a shell command the build commands run first, in the
|
|
// project root; inside one `flow run` the same command runs once (STOREBOUND_PREBUILD_DONE carries what ran).
|
|
// - after it, the copied public/index.html must be newer than the source: the last git commit touching anything
|
|
// outside ios/ android/ node_modules/ store/ .storebound/, and <webDir>/index.html. Else the build stops
|
|
// (--allow-stale-web overrides). `storebound check` warns about the same when no prebuild is set.
|
|
// Where the Capacitor project is (capacitorProject): the root's capacitor.config.*, else one found one or two levels
|
|
// down (monorepo: package.json "workspaces", apps/*, packages/*, or a direct child folder; node_modules skipped).
|
|
// Exactly one is used (and named); several are an error that asks for play.build.dir / apple.build.*. A folder from the
|
|
// config (play.build.dir, apple.build.workspace / project) always wins: its nearest capacitor.config.* above it.
|
|
// The comparison (webStaleness) and the capacitor config reading (webDirOf) are pure.
|
|
import { createHash } from 'node:crypto';
|
|
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
import type { ShipConfig } from './config';
|
|
import { log, warn } from './log';
|
|
import { pump } from './runner';
|
|
import * as rt from './rt';
|
|
|
|
export type Store = 'apple' | 'play';
|
|
|
|
/** Where `npx cap sync` copies the web build per store (Capacitor's default native layout), from the Capacitor project. */
|
|
export const COPIED_INDEX: Record<Store, string> = {
|
|
apple: 'ios/App/App/public/index.html',
|
|
play: 'android/app/src/main/assets/public/index.html',
|
|
};
|
|
|
|
/** Folders whose commits never make the web assets stale (native projects, deps, store files, local state). */
|
|
export const NOT_SOURCE = ['ios', 'android', 'node_modules', 'store', '.storebound'];
|
|
|
|
/** Env of a flow run: the prebuild commands that already ran in it, one per line. */
|
|
export const PREBUILD_DONE_ENV = 'STOREBOUND_PREBUILD_DONE';
|
|
|
|
const CAP_CONFIGS = ['capacitor.config.ts', 'capacitor.config.js', 'capacitor.config.json'];
|
|
|
|
/** webDir of a capacitor config's text (ts, js or json), default "www". A simple read: the first `webDir: '...'`. */
|
|
export function webDirOf(text: string): string {
|
|
const m = /["']?webDir["']?\s*:\s*(["'`])([^"'`\n]+)\1/.exec(text);
|
|
return m?.[2]?.trim() || 'www';
|
|
}
|
|
|
|
/** A Capacitor project: its folder relative to the storebound root ('' = the root), config file and webDir. */
|
|
export interface CapProject { dir: string; config: string; webDir: string }
|
|
|
|
const capConfigIn = (abs: string): string | undefined => CAP_CONFIGS.find(f => existsSync(join(abs, f)));
|
|
|
|
function capAt(root: string, dir: string): CapProject | null {
|
|
const f = capConfigIn(join(root, dir));
|
|
if (!f) return null;
|
|
let text = '';
|
|
try { text = readFileSync(join(root, dir, f), 'utf8'); } catch { /* unreadable: default webDir */ }
|
|
return { dir, config: f, webDir: webDirOf(text) };
|
|
}
|
|
|
|
const isDir = (p: string): boolean => { try { return statSync(p).isDirectory(); } catch { return false; } };
|
|
const subdirs = (abs: string): string[] => { try { return readdirSync(abs).filter(n => !n.startsWith('.') && n !== 'node_modules' && isDir(join(abs, n))); } catch { return []; } };
|
|
|
|
/** Folders (relative, '/'-separated) one or two levels down that hold a capacitor.config.*: package.json workspaces
|
|
* (plain paths and one-level "dir/*" globs), apps/*, packages/* and the root's direct child folders. Sorted. */
|
|
export function capacitorCandidates(root: string): string[] {
|
|
const globs = new Set(['apps/*', 'packages/*', '*']);
|
|
try {
|
|
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { workspaces?: string[] | { packages?: string[] } };
|
|
const ws = Array.isArray(pkg.workspaces) ? pkg.workspaces : pkg.workspaces?.packages ?? [];
|
|
for (const w of ws) if (typeof w === 'string') globs.add(w.replace(/^\.\//, '').replace(/\/$/, ''));
|
|
} catch { /* no package.json */ }
|
|
const dirs = new Set<string>();
|
|
for (const g of globs) {
|
|
const parts = g.split('/');
|
|
if (parts.length > 2 || parts.some(p => p === '**' || p === 'node_modules' || p.startsWith('.'))) continue;
|
|
const expand = (base: string, rest: string[]): void => {
|
|
if (!rest.length) { dirs.add(base); return; }
|
|
const [head, ...tail] = rest;
|
|
const names = head === '*' ? subdirs(join(root, base)) : head!.includes('*') ? [] : [head!];
|
|
for (const n of names) if (isDir(join(root, base, n))) expand(base ? `${base}/${n}` : n, tail);
|
|
};
|
|
expand('', parts);
|
|
}
|
|
return [...dirs].filter(d => capConfigIn(join(root, d))).sort();
|
|
}
|
|
|
|
/**
|
|
* The Capacitor project of a storebound root, or null when there is none. `hint`: a folder from the config
|
|
* (play.build.dir, the folder of apple.build.workspace / project), whose nearest capacitor.config.* above it (up to the
|
|
* root) wins. Else the root's own config, else exactly one found one or two levels down; several throw.
|
|
*/
|
|
export function capacitorProject(root: string, hint?: string): CapProject | null {
|
|
if (hint) {
|
|
for (let d = resolve(root, hint); ; d = dirname(d)) {
|
|
const rel = relative(root, d);
|
|
if (rel.startsWith('..')) break;
|
|
const c = capAt(root, rel.split(sep).join('/'));
|
|
if (c) return c;
|
|
if (!rel) break;
|
|
}
|
|
return null;
|
|
}
|
|
const top = capAt(root, '');
|
|
if (top) return top;
|
|
const found = capacitorCandidates(root);
|
|
if (found.length > 1) throw new Error(`several Capacitor projects (${found.join(', ')}): set play.build.dir (e.g. "${found[0]}/android") and apple.build.workspace or apple.build.project (e.g. "${found[0]}/ios/App/App.xcworkspace")`);
|
|
return found.length ? capAt(root, found[0]!) : null;
|
|
}
|
|
|
|
/** A path inside the Capacitor project, relative to the storebound root. */
|
|
export const inCap = (cap: CapProject, rel: string): string => (cap.dir ? `${cap.dir}/${rel}` : rel);
|
|
|
|
export interface Staleness {
|
|
/** True when the copied index.html is missing or older than the source. */
|
|
stale: boolean;
|
|
missing: boolean;
|
|
/** Newest source time (ms) or null when nothing is known (no git, no webDir/index.html). */
|
|
source: number | null;
|
|
}
|
|
|
|
/** Copied web assets vs the source, all times in ms (null = unknown / missing). Pure. */
|
|
export function webStaleness(t: { copied: number | null; commit: number | null; webIndex: number | null }): Staleness {
|
|
const known = [t.commit, t.webIndex].filter((x): x is number => typeof x === 'number' && Number.isFinite(x));
|
|
const source = known.length ? Math.max(...known) : null;
|
|
if (t.copied === null) return { stale: true, missing: true, source };
|
|
return { stale: source !== null && t.copied < source, missing: false, source };
|
|
}
|
|
|
|
const fmt = (ms: number | null): string => (ms === null ? 'unknown' : new Date(ms).toISOString().replace('T', ' ').slice(0, 19) + 'Z');
|
|
|
|
/** The build-stopping message (also `check`'s warning). `path`: the copied index.html, relative to the root. */
|
|
export function staleMessage(path: string, s: Staleness, copied: number | null): string {
|
|
return `web assets are older than the source (copied ${s.missing ? 'never' : fmt(copied)}, source ${fmt(s.source)}): `
|
|
+ `run your web build + cap sync, or set build.prebuild. ${path}${s.missing ? ' is missing' : ''}; --allow-stale-web builds anyway`;
|
|
}
|
|
|
|
/** Time (ms) of the last git commit touching anything in root outside NOT_SOURCE (at the root and in the Capacitor
|
|
* project folder `capDir`; node_modules anywhere); null without git or commits. */
|
|
export function lastSourceCommit(root: string, capDir = ''): number | null {
|
|
const ex = new Set(NOT_SOURCE.map(d => `:(exclude)${d}/`));
|
|
if (capDir) for (const d of ['ios', 'android']) ex.add(`:(exclude)${capDir}/${d}/`);
|
|
ex.add(':(exclude,glob)**/node_modules/**');
|
|
try {
|
|
const r = rt.spawnSync(['git', 'log', '-1', '--format=%ct', '--', '.', ...ex], { cwd: root, stdout: 'pipe', stderr: 'pipe' });
|
|
if (r.exitCode !== 0) return null;
|
|
const s = Number(r.stdout.toString().trim());
|
|
return s > 0 ? s * 1000 : null;
|
|
} catch { return null; }
|
|
}
|
|
|
|
const mtime = (p: string): number | null => { try { return statSync(p).mtimeMs; } catch { return null; } };
|
|
|
|
export interface WebBundle { path: string; sha256: string; mtime: string }
|
|
|
|
/** The copied index.html that goes into the native build: path (from the root), sha256 (first 12 hex) and mtime. */
|
|
export function webBundle(root: string, store: Store, cap: CapProject): WebBundle | null {
|
|
const path = inCap(cap, COPIED_INDEX[store]);
|
|
const m = mtime(join(root, path));
|
|
if (m === null) return null;
|
|
return { path, sha256: createHash('sha256').update(readFileSync(join(root, path))).digest('hex').slice(0, 12), mtime: new Date(m).toISOString() };
|
|
}
|
|
|
|
/** Staleness of one store's copied assets in a Capacitor project. */
|
|
export function webState(root: string, store: Store, cap: CapProject): { staleness: Staleness; copied: number | null; path: string } {
|
|
const path = inCap(cap, COPIED_INDEX[store]);
|
|
const copied = mtime(join(root, path));
|
|
const staleness = webStaleness({ copied, commit: lastSourceCommit(root, cap.dir), webIndex: mtime(join(root, inCap(cap, cap.webDir), 'index.html')) });
|
|
return { staleness, copied, path };
|
|
}
|
|
|
|
/** The prebuild command of a store: apple.prebuild / play.prebuild, else build.prebuild. */
|
|
export function prebuildFor(config: ShipConfig, store: Store): string | undefined {
|
|
const own = config[store]?.prebuild;
|
|
return (own ?? config.build?.prebuild)?.trim() || undefined;
|
|
}
|
|
|
|
/** Prebuild commands that already ran in this flow run (from the env). */
|
|
export function prebuildsDone(env: Record<string, string | undefined> = process.env): Set<string> {
|
|
return new Set((env[PREBUILD_DONE_ENV] ?? '').split('\n').map(s => s.trim()).filter(Boolean));
|
|
}
|
|
|
|
/** Runs a prebuild command in root with sh -c, streaming its output; throws on a nonzero exit. */
|
|
export async function runPrebuild(cmd: string, root: string, tag: string): Promise<void> {
|
|
log(tag, `prebuild: $ ${cmd}`);
|
|
const p = rt.spawn(['sh', '-c', cmd], { cwd: root, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe', env: process.env });
|
|
const tail: string[] = [];
|
|
const each = (line: string, kind: 'stdout' | 'stderr'): void => {
|
|
tail.push(line); if (tail.length > 20) tail.shift();
|
|
(kind === 'stderr' ? console.error : console.log)(` ${line}`);
|
|
};
|
|
await Promise.all([pump(p.stdout, 'stdout', each), pump(p.stderr, 'stderr', each)]);
|
|
const code = await p.exited;
|
|
if (code !== 0) throw new Error(`prebuild failed (exit ${code}): ${cmd}\n${tail.slice(-10).join('\n')}\n(--no-prebuild skips it)`);
|
|
log(tag, 'prebuild: ok');
|
|
}
|
|
|
|
export interface WebPlan {
|
|
prebuild: string | null;
|
|
/** Why the prebuild does not run: --no-prebuild, already ran in this flow run. */
|
|
prebuildSkip?: string;
|
|
/** The Capacitor project folder ('.' = the root), or null for other projects. */
|
|
capacitor: string | null;
|
|
bundle: WebBundle | null;
|
|
stale?: boolean;
|
|
staleMessage?: string;
|
|
}
|
|
|
|
/**
|
|
* The web step of `apple build` / `play build`: run the prebuild (unless --no-prebuild, already done in this flow
|
|
* run, or --dry), then check the copied assets of the Capacitor project `cap`. Returns what went in, for the plan /
|
|
* output.
|
|
*/
|
|
export async function webStep(o: { config: ShipConfig; root: string; cap: CapProject | null; store: Store; tag: string; dry: boolean; noPrebuild: boolean; allowStale: boolean; quiet?: boolean }): Promise<WebPlan> {
|
|
const cmd = prebuildFor(o.config, o.store) ?? null;
|
|
const plan: WebPlan = { prebuild: cmd, capacitor: o.cap ? o.cap.dir || '.' : null, bundle: null };
|
|
const say = (msg: string) => { if (!o.quiet) log(o.tag, msg); };
|
|
if (cmd) {
|
|
if (o.noPrebuild) plan.prebuildSkip = '--no-prebuild';
|
|
else if (prebuildsDone().has(cmd)) plan.prebuildSkip = 'already ran in this flow run';
|
|
if (plan.prebuildSkip) say(`prebuild skipped (${plan.prebuildSkip}): ${cmd}`);
|
|
else if (o.dry) say(`would run prebuild (in the project root): ${cmd}`);
|
|
else await runPrebuild(cmd, o.root, o.tag);
|
|
}
|
|
if (!o.cap) return plan;
|
|
const st = webState(o.root, o.store, o.cap);
|
|
plan.bundle = webBundle(o.root, o.store, o.cap);
|
|
plan.stale = st.staleness.stale;
|
|
if (st.staleness.stale) {
|
|
const msg = staleMessage(st.path, st.staleness, st.copied);
|
|
plan.staleMessage = msg;
|
|
if (o.dry) {
|
|
if (!o.quiet) warn(o.tag, cmd && !plan.prebuildSkip ? `${msg} (the prebuild above would refresh them)` : msg);
|
|
} else if (o.allowStale) warn(o.tag, `--allow-stale-web: building anyway; ${msg}`);
|
|
else throw new Error(msg);
|
|
}
|
|
if (plan.bundle) say(`web bundle: ${plan.bundle.path} sha256 ${plan.bundle.sha256}, ${plan.bundle.mtime}`);
|
|
return plan;
|
|
}
|