// 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 /.well-known/apple-app-site-association (apple.teamId), /.well-known/assetlinks.json (play + // web.androidSha256, or the Play app signing key read with --version-code), /smart-app-banner.html // (apple.appId) and with --landing / web.landing /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 [] [--json] read-only, no credentials: the live files on https:// // 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 { 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 [] [--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 { 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(); 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 { 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 ?? ''}, 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 { 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:// 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 { 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 { 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:// const base = (a.get('base-url') ?? '').replace(/\/$/, '') || (domain ? `https://${domain}` : ''); if (!base) throw new Error('usage: storebound web check (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; }