127 lines
8.1 KiB
TypeScript
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;
|
|
}
|