storebound/cli/web.ts

127 lines
8.1 KiB
TypeScript

// Web presence: the files a domain serves so links open the app, and a check of the live files.
// storebound web init [--out web] [--landing] [--version-code N] [--dry] [--json]
// writes <out>/.well-known/apple-app-site-association (apple.teamId), <out>/.well-known/assetlinks.json (play +
// web.androidSha256, or the Play app signing key read with --version-code), <out>/smart-app-banner.html
// (apple.appId) and with --landing / web.landing <out>/index.html + the two store marks. Local writes only;
// unchanged files are left alone. Deploy the folder to the domain root yourself.
// storebound web check [<domain>] [--json] read-only, no credentials: the live files on https://<domain>
// Sources (checked October 2026): see cli/lib/web.ts, plus the Play Developer API
// https://developers.google.com/android-publisher/api-ref/rest/v3/generatedapks/list
// (GET applications/{packageName}/generatedApks/{versionCode} -> generatedApks[].certificateSha256Hash, the app
// signing key's certificate SHA-256; its encoding is not documented, normalizeFingerprint takes hex and base64).
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join, relative } from 'node:path';
import type { Ctx } from './lib/ctx';
import { done, log, printJson, warn } from './lib/log';
import { textTable } from './lib/table';
import { TEMPLATE_TEXT } from './lib/template-files';
import {
AASA_LEGACY_PATH, AASA_PATH, ASSETLINKS_PATH, type Check, checkAasa, checkAasaLegacy, checkAssetLinks, checkLanding,
type Fetched, normalizeFingerprint, planWeb, type WebIds, webIdsOf,
} from './lib/web';
export async function run(ctx: Ctx): Promise<void> {
const sub = ctx.args._[0];
if (sub === 'init') return init(ctx);
if (sub === 'check') return check(ctx);
throw new Error('usage: storebound web init [--out web] [--landing] [--version-code N] [--dry] [--json] | web check [<domain>] [--json]');
}
/** Play app signing certificate SHA-256 from the generated APKs of one uploaded bundle (read-only). */
async function playFingerprints(root: string, packageName: string, versionCode: number): Promise<string[]> {
const { playClient } = await import('./play/client');
const play = await playClient(root, packageName);
const r = await play.call<{ generatedApks?: { certificateSha256Hash?: string }[] }>('GET', `${play.API}/generatedApks/${versionCode}`);
const out = new Set<string>();
for (const g of r.generatedApks ?? []) {
const fp = g.certificateSha256Hash ? normalizeFingerprint(g.certificateSha256Hash) : null;
if (fp) out.add(fp); else if (g.certificateSha256Hash) warn('web', 'certificateSha256Hash in an unexpected form, skipped');
}
return [...out];
}
async function init(ctx: Ctx): Promise<void> {
const { config: c, root } = ctx.loaded;
const a = ctx.args, dry = a.has('dry'), json = a.has('json');
const out = ctx.loaded.resolve(a.str('out', c.web?.out ?? 'web'));
let fingerprints = c.web?.androidSha256 ?? [];
const vc = a.get('version-code');
if (!fingerprints.length && c.play && vc !== undefined) {
const n = Number(vc);
if (!Number.isInteger(n) || n <= 0) throw new Error('--version-code: the versionCode of a bundle you uploaded, e.g. 42');
fingerprints = await playFingerprints(root, c.play.packageName, n);
log('web', fingerprints.length ? `Play app signing key: ${fingerprints.length} fingerprint(s) from versionCode ${n}; add them to web.androidSha256 to keep them` : `no generated APKs for versionCode ${n}`);
}
const plan = planWeb(c, {
landing: a.has('landing') || !!c.web?.landing,
template: TEMPLATE_TEXT['web/landing.html'],
marks: { appstore: TEMPLATE_TEXT['web/appstore.svg'] ?? '', googleplay: TEMPLATE_TEXT['web/googleplay.svg'] ?? '' },
fingerprints,
});
const files = plan.files.map(f => {
const at = join(out, f.path);
const old = existsSync(at) ? readFileSync(at, 'utf8') : undefined;
const status = old === undefined ? 'new' : old === f.content ? 'unchanged' : 'updated';
if (!dry && status !== 'unchanged') { mkdirSync(dirname(at), { recursive: true }); writeFileSync(at, f.content); }
return { path: relative(root, at), status, bytes: Buffer.byteLength(f.content), content: f.content };
});
if (json) return printJson({ dry, out: relative(root, out) || '.', files: files.map(({ path, status, bytes }) => ({ path, status, bytes })), skipped: plan.skipped });
for (const f of files) {
log('web', `${dry ? 'would write' : f.status.padEnd(9)} ${f.path} (${f.bytes} bytes)${dry ? ` [${f.status}]` : ''}`);
if (dry) console.log(f.content.replace(/\n?$/, '\n'));
}
for (const s of plan.skipped) log('web', `skipped: ${s}`);
if (!files.length) warn('web', 'nothing to write: add apple.teamId, web.androidSha256 or apple.appId (see skills/web-presence)');
else done('web', dry ? `dry run: ${files.length} file(s), nothing written` : `${files.length} file(s) in ${relative(root, out) || '.'}; deploy its contents to the root of https://${c.web?.domain ?? '<your domain>'}, then run storebound web check`);
}
/** GET without following redirects; never throws (a network error becomes status 0). */
async function grab(url: string, follow = false): Promise<Fetched> {
try {
const r = await fetch(url, { redirect: follow ? 'follow' : 'manual', headers: { 'User-Agent': 'storebound-web-check' } });
const buf = new Uint8Array(await r.arrayBuffer());
return { url, status: r.status, location: r.headers.get('location') ?? undefined, contentType: r.headers.get('content-type') ?? '', body: new TextDecoder().decode(buf), bytes: buf.length };
} catch (e) {
return { url, status: 0, contentType: '', body: '', bytes: 0, location: `network error: ${(e as Error).message.slice(0, 120)}` };
}
}
/** All checks of one site. `base` is https://<domain> in real runs (tests pass a local http server). */
export async function checkSite(base: string, ids: WebIds, o: { landing?: boolean; apple?: boolean; play?: boolean } = {}): Promise<Check[]> {
const host = new URL(base).host;
const out: Check[] = [];
const net = (r: Fetched, id: string): Check[] | null => (r.status === 0 ? [{ id, url: r.url, ok: false, level: 'problem', message: r.location ?? 'network error', fix: `make https://${host} reachable from the public internet with a valid certificate` }] : null);
if (o.apple !== false) {
const r = await grab(base + AASA_PATH);
out.push(...(net(r, 'aasa') ?? checkAasa(r, ids, host)));
const l = await grab(base + AASA_LEGACY_PATH);
if (l.status !== 0) out.push(...checkAasaLegacy(l));
}
if (o.play !== false) {
const r = await grab(base + ASSETLINKS_PATH);
out.push(...(net(r, 'assetlinks') ?? checkAssetLinks(r, ids, host)));
}
if (o.landing) {
const r = await grab(base + '/', true);
out.push(...(net(r, 'landing') ?? checkLanding(r, ids)));
}
return out;
}
async function check(ctx: Ctx): Promise<void> {
const { config: c } = ctx.loaded;
const a = ctx.args;
const domain = (a._[1] ?? c.web?.domain ?? '').replace(/^https?:\/\//, '').replace(/\/.*$/, '');
// --base-url is for tests only (a local http server); real runs always use https://<domain>
const base = (a.get('base-url') ?? '').replace(/\/$/, '') || (domain ? `https://${domain}` : '');
if (!base) throw new Error('usage: storebound web check <domain> (or set web.domain in storebound.config.ts)');
if (!/^[a-z0-9.-]+(:\d+)?$/i.test(new URL(base).host)) throw new Error(`not a domain: ${domain}`);
const checks = await checkSite(base, webIdsOf(c), { landing: !!c.web?.landing, apple: !!c.apple, play: !!c.play });
const problems = checks.filter(x => x.level === 'problem').length;
if (a.has('json')) printJson({ domain: domain || new URL(base).host, checks });
else {
console.log(textTable(['', 'check', 'result', 'fix'], checks.map(x => [x.level === 'ok' ? 'ok' : x.level === 'problem' ? 'PROBLEM' : 'info', x.id, x.message, x.fix && x.level !== 'ok' ? x.fix : ''])));
log('web', problems ? `${problems} problem(s) on ${new URL(base).host}` : `no problems on ${new URL(base).host} (Apple's CDN refreshes within about a day; Android verifies at install)`);
}
if (problems) process.exitCode = 1;
}