release acceptance matrix: tools/acceptance.ts runs every user-facing command from the packed npm tarball (npx --package=<tgz> on Node 20 / 22, bun x --bun after bun add <tgz>) in fresh temp folders with a fresh HOME, real piped stdin (n, EOF) and per-cell fake stores over HTTP (STOREBOUND_STORE_MOCK, loopback only); a cell passes only with the expected exit code and no crash text; report -> markdown matrix. Prompts go through stdinIsTTY (STOREBOUND_ASSUME_TTY drives them with piped stdin), STOREBOUND_DESKTOP_APP=none for the install prompt on a machine with the app. release.yml: required acceptance job matrix (Linux + Windows x node20/node22/bun) and report, the matrix attached to the release; ci.yml: the quick subset on Linux and Windows. Scan fixtures for React Native and Expo
This commit is contained in:
parent
ae681e3ecd
commit
f58e192001
26 changed files with 926 additions and 15 deletions
|
|
@ -19,6 +19,7 @@ import { PLAY_APIS } from './api/play.gen';
|
|||
import { WRAPPED } from './api/wrapped.gen';
|
||||
import type { ParamValue } from './api/types';
|
||||
import * as rt from './lib/rt';
|
||||
import { stdinIsTTY } from './lib/tty';
|
||||
|
||||
const STORES: Store[] = ['apple', 'play'];
|
||||
|
||||
|
|
@ -140,7 +141,7 @@ export async function run(args: Args): Promise<void> {
|
|||
const kind = gateOf(st, op.id, op.method);
|
||||
const decision = decide({
|
||||
kind, method: op.method, dry: args.has('dry'), yes: args.has('yes'), getOnly,
|
||||
tty: !!process.stdin.isTTY && !!process.stdout.isTTY,
|
||||
tty: stdinIsTTY() && !!process.stdout.isTTY,
|
||||
agent: process.env.STOREBOUND_SERVE === '1' || process.env.STOREBOUND_MCP === '1',
|
||||
});
|
||||
if (decision.action === 'refuse') throw new Error(`${op.id}: ${decision.why}`);
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { ascClient } from './asc';
|
|||
|
||||
export { AGE_RATING_NONE } from '../lib/store-catalogs';
|
||||
import { AGE_RATING_NONE } from '../lib/store-catalogs';
|
||||
import { stdinIsTTY } from '../lib/tty';
|
||||
|
||||
const CATEGORY_RELS = ['primaryCategory', 'primarySubcategoryOne', 'primarySubcategoryTwo', 'secondaryCategory'] as const;
|
||||
const str = (v: unknown): string => (v === null || v === undefined ? '' : String(v));
|
||||
|
|
@ -75,7 +76,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
else for (const line of appInfoPlanLines(p)) console.log(line);
|
||||
if (dry) { done('apple', `dry run: ${p.changes ? `${p.changes} change(s)` : 'nothing to change'}; read-only, nothing written`); return; }
|
||||
if (!p.changes) { done('apple', 'appinfo already up to date (App Privacy is UI-only: store-declarations skill)'); return; }
|
||||
if (process.stdin.isTTY && !(await confirm(ctx, `Apply these ${p.changes} change(s) to App Store Connect?`))) { warn('apple', 'nothing written'); return; }
|
||||
if (stdinIsTTY() && !(await confirm(ctx, `Apply these ${p.changes} change(s) to App Store Connect?`))) { warn('apple', 'nothing written'); return; }
|
||||
const changed = (key: string) => p.items.some(i => i.key === key && i.action !== 'same');
|
||||
|
||||
if (info.categories && changed('categories')) {
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import {
|
|||
import { pages } from '../prices';
|
||||
import { createOnce, findOrCreate, getById } from '../lib/upsert';
|
||||
import { type Asc, ascClient, md5, type J } from './asc';
|
||||
import { stdinIsTTY } from '../lib/tty';
|
||||
|
||||
const TAG = 'apple';
|
||||
|
||||
|
|
@ -84,7 +85,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
const yes = ctx.args.has('yes');
|
||||
// price changes of existing subscriptions: known per subscription, decided at the first one (lib/price-gate.ts)
|
||||
let liveOk: boolean | undefined;
|
||||
if (process.stdin.isTTY && !yes && process.env.STOREBOUND_SERVE !== '1') {
|
||||
if (stdinIsTTY() && !yes && process.env.STOREBOUND_SERVE !== '1') {
|
||||
if (!(await confirm(ctx, 'Create / update these subscriptions on App Store Connect? Nothing is public before App Review, but price changes of existing subscriptions apply to new subscribers right away.'))) return;
|
||||
liveOk = true;
|
||||
} else if (!yes) log(TAG, 'not a terminal: creating what is new; price changes of existing subscriptions wait for the owner');
|
||||
|
|
@ -220,7 +221,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
* the code can redeem it). Existing codes (same customCode) are left alone. */
|
||||
async function customCodes(c: Asc, ctx: Ctx, subId: string, s: Subscription, oc: OfferCodeDef, ocId: string | undefined): Promise<void> {
|
||||
if (!ctx.args.has('codes')) { log(TAG, ` ${oc.customCodes!.length} custom code(s) of "${oc.name}" not created (pass --codes from a terminal)`); return; }
|
||||
if (!process.stdin.isTTY) { warn(TAG, ` custom codes of "${oc.name}" need a terminal confirmation`); return; }
|
||||
if (!stdinIsTTY()) { warn(TAG, ` custom codes of "${oc.name}" need a terminal confirmation`); return; }
|
||||
const id = ocId ?? (await c.all(`/v1/subscriptions/${subId}/offerCodes?limit=200`)).find((x: J) => x.attributes.name === oc.name)?.id;
|
||||
if (!id) throw new Error(`${s.id}: offer code campaign "${oc.name}" not found`);
|
||||
const have = new Set((await c.all(`/v1/subscriptionOfferCodes/${id}/customCodes?limit=200`)).map((x: J) => x.attributes.customCode));
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import { verify } from './lib/minisign';
|
|||
import { parsePlist } from './lib/plist';
|
||||
import { addFolder } from './lib/registry';
|
||||
import * as rt from './lib/rt';
|
||||
import { stdinIsTTY } from './lib/tty';
|
||||
|
||||
type Fetch = (url: string) => Promise<Response>;
|
||||
|
||||
|
|
@ -209,7 +210,7 @@ export async function run(args: Args): Promise<void> {
|
|||
try { const r = rt.spawnSync(chk, { timeout: 10_000 }); running = isRunning(platform, r.exitCode, r.stdout.toString()); } catch { /* no pgrep / tasklist: go on */ }
|
||||
if (running) throw new Error(`storebound desktop ${decision.from} is running: quit it first, then run this again (or update it from the app: Settings > Updates)`);
|
||||
}
|
||||
const how = consent(args.has('yes'), !!process.stdin.isTTY);
|
||||
const how = consent(args.has('yes'), stdinIsTTY());
|
||||
if (how !== 'yes') {
|
||||
if (how === 'declined') {
|
||||
const again = ['storebound', 'desktop', ...process.argv.slice(3).filter(a => a !== '--yes'), '--yes'].join(' ');
|
||||
|
|
|
|||
|
|
@ -45,6 +45,8 @@ const groupUsage = (g: string, keys: string[]): string =>
|
|||
async function main(): Promise<void> {
|
||||
// tests (tests/cli-help.test.ts) run the CLI with STOREBOUND_NO_NETWORK=1: any request fails loudly
|
||||
if (process.env.STOREBOUND_NO_NETWORK === '1') globalThis.fetch = (async (u: unknown) => { throw new Error(`network disabled (STOREBOUND_NO_NETWORK): ${String(u)}`); }) as unknown as typeof fetch;
|
||||
// the release acceptance matrix: store hosts answered by a local double (loopback only, cli/lib/store-mock-env.ts)
|
||||
if (process.env.STOREBOUND_STORE_MOCK) (await import('./lib/store-mock-env')).installStoreMock();
|
||||
// tools (adb, emulator, ffmpeg…) are also looked up where they usually live, for apps started without a shell PATH
|
||||
process.env.PATH = toolPath(process.env);
|
||||
const argv = process.argv.slice(2);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { Args } from './args';
|
|||
import type { Language, Loaded } from './config';
|
||||
import { playCodes } from './config';
|
||||
import * as rt from './rt';
|
||||
import { stdinIsTTY } from './tty';
|
||||
|
||||
export interface Ctx { loaded: Loaded; args: Args }
|
||||
|
||||
|
|
@ -38,7 +39,7 @@ export async function confirm(ctx: Ctx, question: string): Promise<boolean> {
|
|||
return declined('confirm-required: terminal only');
|
||||
}
|
||||
if (ctx.args.has('yes')) return true;
|
||||
if (!process.stdin.isTTY) {
|
||||
if (!stdinIsTTY()) {
|
||||
console.log(`${question}\n not a terminal: re-run with --yes to confirm`);
|
||||
return declined('not a terminal and no --yes (re-run with --yes to confirm)');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -98,6 +98,8 @@ export interface Installed { path: string; how: 'bundle' | 'exe' | 'appimage' |
|
|||
export function findInstalled(platform: NodeJS.Platform, env: Record<string, string | undefined> = process.env,
|
||||
exists: (p: string) => boolean = existsSync, home = homedir(),
|
||||
list: (dir: string) => string[] = d => { try { return readdirSync(d); } catch { return []; } }): Installed | null {
|
||||
// the release acceptance matrix drives the install prompt on a machine that has the app: act as if it had none
|
||||
if (env.STOREBOUND_DESKTOP_APP === 'none') return null;
|
||||
if (platform === 'darwin') {
|
||||
for (const dir of ['/Applications', join(home, 'Applications')]) {
|
||||
const app = join(dir, 'storebound.app');
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
// play subscriptions, apple subscriptions and prices import (apple iap never changes the prices of an existing product).
|
||||
// Imported only by those commands, so --allow-price-changes is a flag of theirs alone (tools/gen-flags.ts).
|
||||
import { confirm, type Ctx, declined } from './ctx';
|
||||
import { stdinIsTTY } from './tty';
|
||||
|
||||
/** The flag that lets a write command change the price of an existing product. `flow run` never adds it and refuses it in steps. */
|
||||
export const ALLOW_PRICE_CHANGES = 'allow-price-changes';
|
||||
|
|
@ -18,7 +19,7 @@ export interface HeldPrice { id: string; changes: { region: string; from: string
|
|||
export async function allowPriceChanges(ctx: Ctx, count: number, question: string): Promise<boolean> {
|
||||
if (!count || ctx.args.has('allow-price-changes')) return true;
|
||||
const yes = ctx.args.has('yes');
|
||||
if (process.env.STOREBOUND_SERVE === '1' || (process.stdin.isTTY && !yes)) return confirm(withoutYes(ctx), question);
|
||||
if (process.env.STOREBOUND_SERVE === '1' || (stdinIsTTY() && !yes)) return confirm(withoutYes(ctx), question);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
|
|||
32
cli/lib/store-mock-env.ts
Normal file
32
cli/lib/store-mock-env.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
// STOREBOUND_STORE_MOCK=http://127.0.0.1:<port>: every request to a store host (App Store Connect, Google APIs, the
|
||||
// public store pages, the example.com placeholders) goes to that local double instead, as
|
||||
// <mock>/__host/<host>/<path>. The release acceptance matrix (tools/acceptance.ts) runs the packed npm tarball this
|
||||
// way against the fake stores of tests/helpers/fake-stores.ts, on every platform and runtime. Only a loopback address
|
||||
// (with an optional path: the matrix gives every cell its own store) is accepted, so the variable can never send store keys or tokens to another machine. Unset: nothing changes.
|
||||
|
||||
const STORE_HOST = /^(?:[a-z0-9-]+\.)*(?:apple\.com|googleapis\.com|google\.com|example\.com)$/i;
|
||||
const LOOPBACK = /^http:\/\/(?:127\.0\.0\.1|localhost|\[::1\]):\d{1,5}(?:\/[\w.-]+)*$/;
|
||||
|
||||
/** The URL a request goes to under the mock (unchanged for hosts that are not store hosts). */
|
||||
export function mockedUrl(url: string, base: string): string {
|
||||
let u: URL;
|
||||
try { u = new URL(url); } catch { return url; }
|
||||
if (u.protocol !== 'https:' || !STORE_HOST.test(u.hostname)) return url;
|
||||
return `${base}/__host/${u.hostname}${u.pathname}${u.search}`;
|
||||
}
|
||||
|
||||
/** Installs the redirect when STOREBOUND_STORE_MOCK is set; throws on a non-loopback value. */
|
||||
export function installStoreMock(env: Record<string, string | undefined> = process.env): boolean {
|
||||
const base = env.STOREBOUND_STORE_MOCK?.replace(/\/+$/, '');
|
||||
if (!base) return false;
|
||||
if (!LOOPBACK.test(base)) throw new Error(`STOREBOUND_STORE_MOCK must be a loopback address like http://127.0.0.1:8080 (got ${JSON.stringify(base)})`);
|
||||
const real = globalThis.fetch;
|
||||
globalThis.fetch = ((input: string | URL | Request, init?: RequestInit) => {
|
||||
if (input instanceof Request) {
|
||||
const to = mockedUrl(input.url, base);
|
||||
return real(to === input.url ? input : new Request(to, input), init);
|
||||
}
|
||||
return real(mockedUrl(String(input), base), init);
|
||||
}) as typeof fetch;
|
||||
return true;
|
||||
}
|
||||
5
cli/lib/tty.ts
Normal file
5
cli/lib/tty.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
// Whether a prompt can ask on stdin. STOREBOUND_ASSUME_TTY=1 treats piped stdin as the terminal, so the release
|
||||
// acceptance matrix (tools/acceptance.ts) drives the real [y/N] and [Y/n] prompts with piped answers ("n\n", EOF) on
|
||||
// every platform and runtime: the same reading code a person's terminal goes through.
|
||||
export const stdinIsTTY = (env: Record<string, string | undefined> = process.env): boolean =>
|
||||
env.STOREBOUND_ASSUME_TTY === '1' || !!process.stdin.isTTY;
|
||||
|
|
@ -20,6 +20,7 @@ import { confirm, need } from './lib/ctx';
|
|||
import { done, log, printJson, warn } from './lib/log';
|
||||
import { ascClient, EDITABLE, type J } from './apple/asc';
|
||||
import { playClient } from './play/client';
|
||||
import { stdinIsTTY } from './lib/tty';
|
||||
|
||||
const TAG = 'listing';
|
||||
|
||||
|
|
@ -143,7 +144,7 @@ async function set(ctx: Ctx): Promise<void> {
|
|||
if (a.has('json')) printJson({ ...p, dry, written: false });
|
||||
if (errors) throw new Error(`nothing sent: ${errors} error(s)`);
|
||||
if (dry || !p.changes.length) { done(TAG, dry ? 'dry run: nothing sent' : 'already the same on Google Play'); return; }
|
||||
if (process.stdin.isTTY && !(await confirm(ctx, `Change ${p.changes.length} field(s) of ${lang} on Google Play (one edit, committed)?`))) { warn(TAG, 'nothing sent'); return; }
|
||||
if (stdinIsTTY() && !(await confirm(ctx, `Change ${p.changes.length} field(s) of ${lang} on Google Play (one edit, committed)?`))) { warn(TAG, 'nothing sent'); return; }
|
||||
const { root } = ctx.loaded;
|
||||
const play = await playClient(root, need(config.play?.packageName, 'play.packageName'));
|
||||
const body: Record<string, string> = { language: lang };
|
||||
|
|
@ -173,7 +174,7 @@ async function set(ctx: Ctx): Promise<void> {
|
|||
if (p.refused) throw new Error(p.refused);
|
||||
if (errors) throw new Error(`nothing sent: ${errors} error(s)`);
|
||||
if (dry || !p.changes.length) { done(TAG, dry ? 'dry run: nothing sent' : 'already the same on the App Store'); return; }
|
||||
if (process.stdin.isTTY && !(await confirm(ctx, `Change ${p.changes.length} field(s) of ${lang} on App Store Connect?`))) { warn(TAG, 'nothing sent'); return; }
|
||||
if (stdinIsTTY() && !(await confirm(ctx, `Change ${p.changes.length} field(s) of ${lang} on App Store Connect?`))) { warn(TAG, 'nothing sent'); return; }
|
||||
const info = Object.fromEntries(p.changes.filter(c => (APPLE_INFO_FIELDS as readonly string[]).includes(c.field)).map(c => [c.field, c.to]));
|
||||
const ver = Object.fromEntries(p.changes.filter(c => (APPLE_VERSION_FIELDS as readonly string[]).includes(c.field)).map(c => [c.field, c.to]));
|
||||
if (Object.keys(info).length) {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import type { Ctx } from '../lib/ctx';
|
|||
import { confirm, need } from '../lib/ctx';
|
||||
import { done, log, printJson, step, warn } from '../lib/log';
|
||||
import { playClient } from './client';
|
||||
import { stdinIsTTY } from '../lib/tty';
|
||||
|
||||
export function readPlayCopy(ctx: Ctx) {
|
||||
const { config, resolve } = ctx.loaded;
|
||||
|
|
@ -139,7 +140,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
else for (const line of playPlanLines(p)) console.log(line);
|
||||
if (dry) { done('play', `dry run: ${p.changes ? `${p.changes} change(s)` : 'nothing to change'}; read-only, nothing written`); return; }
|
||||
if (!p.changes) { done('play', 'listing already up to date'); return; }
|
||||
if (process.stdin.isTTY && !(await confirm(ctx, `Apply these ${p.changes} change(s) to Google Play (one edit, committed)?`))) { warn('play', 'nothing written'); return; }
|
||||
if (stdinIsTTY() && !(await confirm(ctx, `Apply these ${p.changes} change(s) to Google Play (one edit, committed)?`))) { warn('play', 'nothing written'); return; }
|
||||
// The edit compares again with what it holds (a Console save or another run since the plan): only differing details
|
||||
// and languages are PUT; nothing left to change = the edit is deleted, not committed. Commit only after every PUT worked.
|
||||
let sent = 0;
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import {
|
|||
import { subscriptionCsv } from '../apple/subscriptions';
|
||||
import { createOnce } from '../lib/upsert';
|
||||
import { playClient, type PlayClient } from './client';
|
||||
import { stdinIsTTY } from '../lib/tty';
|
||||
|
||||
/** GET of one Play resource, null on 404. */
|
||||
export async function getOrNull<T>(play: PlayClient, url: string): Promise<T | null> {
|
||||
|
|
@ -88,7 +89,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
const yes = ctx.args.has('yes');
|
||||
const priced = todo.filter(w => w.diff.basePlan === 'changed' && w.diff.priceChanges.length);
|
||||
let liveOk: boolean;
|
||||
if (process.stdin.isTTY && !yes && process.env.STOREBOUND_SERVE !== '1') {
|
||||
if (stdinIsTTY() && !yes && process.env.STOREBOUND_SERVE !== '1') {
|
||||
if (!(await confirm(ctx, `Write ${todo.length} subscription(s) to Google Play? Activated base plans and offers can be bought as soon as the app is published; price changes apply to new subscribers.`))) return;
|
||||
liveOk = true;
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import {
|
|||
} from './lib/prices';
|
||||
import { playClient, type PlayClient } from './play/client';
|
||||
import { allSubscriptions, appleSubPriceRows, basePlanId, type PlaySubscription, playSubPriceRows } from './lib/subscriptions';
|
||||
import { stdinIsTTY } from './lib/tty';
|
||||
|
||||
const TAG = 'prices';
|
||||
|
||||
|
|
@ -154,7 +155,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
if (live.length) log(TAG, `owner: price change of ${live.length} existing product / store pair(s) (--allow-price-changes): ${live.map(k => k.replace('|', ' ')).join(', ')}`);
|
||||
if (dry) { done(TAG, 'dry run: nothing written'); return; }
|
||||
let liveOk: boolean;
|
||||
if (process.stdin.isTTY && !a.has('yes') && process.env.STOREBOUND_SERVE !== '1') {
|
||||
if (stdinIsTTY() && !a.has('yes') && process.env.STOREBOUND_SERVE !== '1') {
|
||||
if (!(await confirm(ctx, `Write the new prices for ${changed.size} product / store pair(s)? Users see them as soon as the stores apply them; an App Store price increase cannot be undone once it starts.`))) return;
|
||||
liveOk = true;
|
||||
} else {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue