storebound/cli/play/share.ts

75 lines
4.3 KiB
TypeScript

// Google Play internal app sharing: upload an app bundle or APK and get an install link for testers, with no track,
// no review and no version code rules.
// storebound play share --aab app.aab | --apk app.apk [--dry] [--yes] [--json]
// Prints file, size, sha256 and package; --dry stops there. Otherwise it asks (the build becomes installable by anyone
// allowed to use the link, for 60 days), uploads, and prints the share link and the certificate fingerprint of the
// re-signed build. --json: { downloadUrl, certificateFingerprint, sha256 } (dry: { dry, packageName, file, kind, bytes, sha256 }).
// Testers must turn on internal app sharing in the Play Store app; uploaders need "Release apps to testing tracks"
// or a place on the Console's uploader list. See skills/adhoc-distribution/SKILL.md.
// Sources (checked October 2026): https://developers.google.com/android-publisher/api-ref/rest/v3/internalappsharingartifacts
// and https://support.google.com/googleplay/android-developer/answer/9844679 (details in cli/lib/play-share.ts).
import { consoleLink } from '../lib/console-links';
import { createHash } from 'node:crypto';
import { createReadStream, existsSync, readFileSync, statSync } from 'node:fs';
import type { Ctx } from '../lib/ctx';
import { confirm, need } from '../lib/ctx';
import { done, log, printJson, warn } from '../lib/log';
import { mb, parseShareArtifact, SHARE_HEADERS, type ShareKind, shareProblems, shareUrl } from '../lib/play-share';
import { playClient } from './client';
const TAG = 'share';
const USAGE = 'usage: storebound play share --aab <app.aab> | --apk <app.apk> [--dry] [--yes] [--json]';
async function sha256File(file: string): Promise<string> {
const h = createHash('sha256');
for await (const chunk of createReadStream(file)) h.update(chunk as Buffer);
return h.digest('hex');
}
export async function run(ctx: Ctx): Promise<void> {
const { config, root } = ctx.loaded;
const a = ctx.args;
const pkg = need(config.play?.packageName, 'play.packageName');
const aab = a.get('aab'), apk = a.get('apk');
if ((!aab && !apk) || (aab && apk) || aab === 'true' || apk === 'true') throw new Error(USAGE);
const kind: ShareKind = aab ? 'bundle' : 'apk';
const file = (aab ?? apk)!;
if (!existsSync(file)) throw new Error(`${file}: no such file`);
const bytes = statSync(file).size;
const problems = shareProblems(kind, file, bytes);
if (problems.length) throw new Error(problems.join('\n'));
const sha256 = await sha256File(file);
const json = a.has('json');
const info = (m: string): void => { if (!json) log(TAG, m); };
info(`${pkg}: ${kind === 'bundle' ? 'app bundle' : 'APK'} ${file}, ${mb(bytes)}, sha256 ${sha256}`);
if (a.has('dry')) {
info(`dry run: would POST it to internal app sharing (${shareUrl(pkg, kind).replace(/\?.*/, '')}); nothing sent`);
if (json) printJson({ dry: true, packageName: pkg, file, kind, bytes, sha256 });
return;
}
if (!(await confirm(ctx, `Upload ${file} to Google Play internal app sharing for ${pkg}? Anyone allowed to use the link can install it for 60 days.`))) {
console.error('not confirmed, nothing uploaded');
return;
}
const play = await playClient(root, pkg);
info(`uploading ${mb(bytes)}, this can take minutes`);
const t0 = Date.now();
const beat = setInterval(() => info(`still uploading (${Math.round((Date.now() - t0) / 1000)}s)`), 15_000);
let res: unknown;
try {
res = await play.call('POST', shareUrl(pkg, kind), new Uint8Array(readFileSync(file)), { ...SHARE_HEADERS });
} catch (e) {
const m = (e as Error).message;
if (/-> 403/.test(m)) throw new Error(`${m}\n\nThe service account needs "Release apps to testing tracks" for this app, or a place on the internal app sharing uploader list (${consoleLink('play.internal-sharing')}).`);
throw e;
} finally { clearInterval(beat); }
const art = parseShareArtifact(res);
if (art.sha256 && art.sha256 !== sha256) warn(TAG, `Play reports sha256 ${art.sha256}, the local file is ${sha256}`);
if (json) { printJson(art); return; }
done(TAG, 'uploaded');
console.log(`share link: ${art.downloadUrl}`);
console.log(`certificate fingerprint (re-signed by Google): ${art.certificateFingerprint}`);
console.log('Testers: Play Store > Settings > About > tap "Play Store version" 7 times > turn on Internal app sharing, then open the link. The link expires in 60 days.');
}