- cli/lib/rt.ts: the runtime layer on node: modules (spawn / spawnSync with Bun's defaults, exit codes and "Executable not found", which with PATHEXT, sleep, serve, Glob, hasher, gunzip, stdin, SQLite, packageRoot, config imports with a type-stripping fallback for Node 20); every Bun.* use in cli/ goes through it - tools/gen-embedded.ts -> cli/lib/embedded.ts: templates/, cli/shots/*.html and Play's Data safety form embedded (replaces Bun text imports; the bundle and the compiled sidecar carry them), checked fresh by bun run ci - package.json: bin dist/storebound.mjs (Node ESM bundle, tools/build-node.ts, prepack), engines node >=20 - bun run ci: node bundle + node smoke (real node: --version, help, check same as Bun, scan, release plan --help, init, MCP handshake + tool call); Forgejo CI sets up Node 20; tests/node-compat.test.ts guards cli/ - install writes npx MCP commands when run with Node; bin/storebound falls back to Node - docs: npx storebound init (Node 20+) or bunx storebound init
206 lines
14 KiB
TypeScript
206 lines
14 KiB
TypeScript
// Optional RevenueCat adapter: keep a RevenueCat project's catalog in step with the config.
|
|
// storebound revenuecat diff [--json] read-only parity report; exit 1 when out of sync
|
|
// storebound revenuecat sync [--dry] [--yes] [--current] [--json]
|
|
// sync creates and attaches only (products, entitlements, their products, the offering, its packages and their
|
|
// products), in dependency order, after a confirmation. It never deletes, detaches, archives or updates anything:
|
|
// what RevenueCat has beyond the config is listed as "extra, left alone". --current also makes the offering the
|
|
// current one (that changes what the apps show by default, so it is opt-in). Re-running is safe: the plan is
|
|
// computed from a fresh read every time.
|
|
// Out of scope (RevenueCat's job): runtime entitlements, receipt / purchase token validation, customer info,
|
|
// webhooks, paywalls, store server notifications setup, the App Store / Play credentials inside RevenueCat.
|
|
// Key: REVENUECAT_SECRET_KEY (env or .storebound/credentials.json), a v2 secret key with project configuration
|
|
// permissions (apps read; products, entitlements, offerings, packages read, or read_write for sync). Never printed.
|
|
// Config: revenuecat { projectId, apps?: { apple, play }, entitlements?, offering? }. Pure parts: lib/revenuecat.ts.
|
|
// Sources (checked October 2026):
|
|
// https://www.revenuecat.com/docs/api-v2 (auth, pagination, rate limits: 429 + Retry-After seconds)
|
|
// https://www.revenuecat.com/docs/api-v2/app GET /projects/{project_id}/apps
|
|
// https://www.revenuecat.com/docs/api-v2/product GET / POST /projects/{project_id}/products
|
|
// https://www.revenuecat.com/docs/api-v2/entitlement GET / POST .../entitlements, GET .../entitlements/{id}/products,
|
|
// POST .../entitlements/{id}/actions/attach_products
|
|
// https://www.revenuecat.com/docs/api-v2/offering GET / POST .../offerings, POST .../offerings/{id} (is_current)
|
|
// https://www.revenuecat.com/docs/api-v2/package GET / POST .../offerings/{id}/packages, GET .../packages/{id}/products,
|
|
// POST .../packages/{id}/actions/attach_products
|
|
import { credentialValue } from './lib/credentials';
|
|
import { confirm, type Ctx, need } from './lib/ctx';
|
|
import { AmbiguousWriteError, createOnce } from './lib/upsert';
|
|
import { errorBody } from './lib/http';
|
|
import { done, log, printJson, warn } from './lib/log';
|
|
import {
|
|
outOfSync, RC_SCOPE, type RcApp, type RcEntitlement, type RcOffering, type RcPackage, rcDiff, rcPlan, type RcProduct, type RcRow,
|
|
type RcState, type RcStep, resolveApps, wantedRevenueCat,
|
|
} from './lib/revenuecat';
|
|
import { textTable } from './lib/table';
|
|
import * as rt from './lib/rt';
|
|
|
|
const TAG = 'revenuecat';
|
|
const ORIGIN = 'https://api.revenuecat.com';
|
|
const BASE = `${ORIGIN}/v2`;
|
|
const USAGE = 'usage: storebound revenuecat diff [--json] | sync [--dry] [--yes] [--current] [--json]';
|
|
type J = Record<string, any>; // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
|
|
export interface RcClient { get(path: string): Promise<J>; all(path: string): Promise<J[]>; post(path: string, body: unknown): Promise<J> }
|
|
|
|
/** Small v2 client: Bearer auth, next_page pagination (only ever followed on api.revenuecat.com), 429 retries with
|
|
* Retry-After. Errors name the method, path and status; the key never appears in them. */
|
|
export function rcClient(key: string, o: { retries?: number } = {}): RcClient {
|
|
const redact = (s: string) => (key ? s.split(key).join('***') : s);
|
|
async function call(method: 'GET' | 'POST', url: string, body?: unknown): Promise<J> {
|
|
const u = new URL(url, ORIGIN);
|
|
if (u.origin !== ORIGIN) throw new Error(`RevenueCat: refusing to send the key to ${u.origin}`);
|
|
for (let attempt = 0; ; attempt++) {
|
|
let r: Response;
|
|
try {
|
|
r = await fetch(u, {
|
|
method, signal: AbortSignal.timeout(30_000),
|
|
headers: { 'Authorization': `Bearer ${key}`, 'Accept': 'application/json', ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
|
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
});
|
|
} catch (e) {
|
|
// a POST without an answer may have been applied: callers look before sending it again (createOnce)
|
|
if (method === 'POST') throw new AmbiguousWriteError(redact(`RevenueCat ${method} ${u.pathname}: no answer (${(e as Error).message})`));
|
|
throw e;
|
|
}
|
|
if (r.status === 429 && attempt < (o.retries ?? 3)) {
|
|
const s = Math.min(60, Math.max(0, Number(r.headers.get('retry-after') ?? '5') || 0));
|
|
warn(TAG, `rate limited, waiting ${s}s`);
|
|
await r.body?.cancel();
|
|
await rt.sleep(s * 1000);
|
|
continue;
|
|
}
|
|
const text = await r.text();
|
|
if (!r.ok && r.status >= 500 && method === 'POST') throw new AmbiguousWriteError(redact(`RevenueCat ${method} ${u.pathname}: ${r.status} ${errorBody(text, 500)}`), r.status);
|
|
if (!r.ok) {
|
|
const hint = r.status === 401 || r.status === 403 ? ' (REVENUECAT_SECRET_KEY: a v2 secret key with project configuration permissions for this project)' : '';
|
|
throw new Error(redact(`RevenueCat ${method} ${u.pathname}: ${r.status} ${errorBody(text, 500)}${hint}`));
|
|
}
|
|
return text ? JSON.parse(text) as J : {};
|
|
}
|
|
}
|
|
const abs = (p: string) => (p.startsWith('http') ? p : p.startsWith('/v2/') ? ORIGIN + p : BASE + p);
|
|
return {
|
|
get: p => call('GET', abs(p)),
|
|
post: (p, b) => call('POST', abs(p), b),
|
|
async all(p) {
|
|
const out: J[] = [];
|
|
for (let url: string | null = abs(p); url;) {
|
|
const r = await call('GET', url);
|
|
out.push(...(r.items ?? []));
|
|
url = typeof r.next_page === 'string' && r.next_page ? abs(r.next_page) : null;
|
|
}
|
|
return out;
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Everything the diff and the plan need, read-only. Attachments are read only for the configured entitlements and
|
|
* the configured offering. */
|
|
export async function readState(c: RcClient, projectId: string, want: { entitlements: string[]; offering: string }): Promise<RcState> {
|
|
const P = `/projects/${encodeURIComponent(projectId)}`;
|
|
const products = (await c.all(`${P}/products?limit=100`)) as RcProduct[];
|
|
log(TAG, `${products.length} product(s)`);
|
|
const entitlements: RcEntitlement[] = [];
|
|
for (const e of await c.all(`${P}/entitlements?limit=100`)) {
|
|
const productIds = want.entitlements.includes(e.lookup_key) ? (await c.all(`${P}/entitlements/${encodeURIComponent(e.id)}/products?limit=100`)).map(p => p.id as string) : [];
|
|
entitlements.push({ id: e.id, lookup_key: e.lookup_key, display_name: e.display_name, state: e.state, productIds });
|
|
}
|
|
log(TAG, `${entitlements.length} entitlement(s)`);
|
|
const offerings: RcOffering[] = [];
|
|
for (const o of await c.all(`${P}/offerings?limit=100`)) {
|
|
const packages: RcPackage[] = [];
|
|
if (o.lookup_key === want.offering) {
|
|
for (const p of await c.all(`${P}/offerings/${encodeURIComponent(o.id)}/packages?limit=100`)) {
|
|
const items = await c.all(`${P}/packages/${encodeURIComponent(p.id)}/products?limit=100`);
|
|
packages.push({ id: p.id, lookup_key: p.lookup_key, display_name: p.display_name, position: p.position,
|
|
products: items.map(i => ({ product_id: typeof i.product === 'string' ? i.product : i.product?.id, eligibility_criteria: i.eligibility_criteria })) });
|
|
}
|
|
}
|
|
offerings.push({ id: o.id, lookup_key: o.lookup_key, display_name: o.display_name, is_current: o.is_current, state: o.state, packages });
|
|
}
|
|
log(TAG, `${offerings.length} offering(s)`);
|
|
return { products, entitlements, offerings };
|
|
}
|
|
|
|
export function formatRows(rows: RcRow[]): string {
|
|
if (!rows.length) return 'nothing to compare';
|
|
return textTable(['status', 'what', 'key', 'detail'], rows.map(r => [r.status, r.section, r.key, r.msg]));
|
|
}
|
|
|
|
async function load(ctx: Ctx) {
|
|
const { config, root } = ctx.loaded;
|
|
const rc = need(config.revenuecat, 'revenuecat');
|
|
const key = credentialValue(root, 'REVENUECAT_SECRET_KEY');
|
|
if (!key) throw new Error('REVENUECAT_SECRET_KEY is not set (env, or .storebound/credentials.json): a RevenueCat v2 secret key with project configuration permissions');
|
|
const c = rcClient(key);
|
|
const want = wantedRevenueCat(config);
|
|
const needApps = (['apple', 'play'] as const).some(s => !rc.apps?.[s]);
|
|
const apps = needApps ? (await c.all(`/projects/${encodeURIComponent(rc.projectId)}/apps?limit=100`)) as RcApp[] : null;
|
|
const resolved = resolveApps(config, apps);
|
|
const state = await readState(c, rc.projectId, { entitlements: want.entitlements.map(e => e.lookupKey), offering: want.offering.lookupKey });
|
|
return { rc, c, want, resolved, state };
|
|
}
|
|
|
|
export async function run(ctx: Ctx): Promise<void> {
|
|
const a = ctx.args;
|
|
const sub = a._[0];
|
|
if (sub !== 'diff' && sub !== 'sync') throw new Error(USAGE);
|
|
const { rc, c, want, resolved, state } = await load(ctx);
|
|
const rows = rcDiff(want, state, resolved.ids, resolved.problems);
|
|
const bad = outOfSync(rows);
|
|
|
|
if (sub === 'diff') {
|
|
if (a.has('json')) printJson({ projectId: rc.projectId, apps: resolved.ids, outOfSync: bad, rows, scope: RC_SCOPE });
|
|
else console.log(`${formatRows(rows)}\n\n${RC_SCOPE}`);
|
|
done(TAG, `${bad ? `${bad} difference(s): run storebound revenuecat sync --dry` : 'in sync'}; ${rows.filter(r => r.status === 'extra').length} extra (left alone)`);
|
|
if (bad) process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
// sync
|
|
const plan = rcPlan(want, state, resolved.ids, { current: a.has('current') });
|
|
const blockers = rows.filter(r => r.section === 'config' || r.section === 'app');
|
|
for (const b of blockers) warn(TAG, b.msg);
|
|
const left = rows.filter(r => r.status === 'extra' || (r.status === 'mismatch' && r.section !== 'config'));
|
|
if (a.has('json') && a.has('dry')) { printJson({ projectId: rc.projectId, apps: resolved.ids, plan, leftAlone: left }); return; }
|
|
console.log(plan.length ? plan.map((s, i) => `${String(i + 1).padStart(String(plan.length).length)}. ${s.what}`).join('\n') : 'nothing to create or attach');
|
|
if (left.length) console.log(`\nleft alone (sync never deletes, detaches or changes):\n${left.map(r => ` ${r.status} ${r.section} ${r.key}: ${r.msg}`).join('\n')}`);
|
|
console.log(`\n${RC_SCOPE}`);
|
|
if (!plan.length) { done(TAG, 'nothing to do'); return; }
|
|
if (a.has('dry')) { done(TAG, `dry run: ${plan.length} step(s), nothing sent`); return; }
|
|
if (!(await confirm(ctx, `Run these ${plan.length} step(s) on RevenueCat project ${rc.projectId}?`))) { log(TAG, 'not confirmed, nothing sent'); return; }
|
|
await execute(c, rc.projectId, plan, state, resolved.ids, want.offering.lookupKey);
|
|
done(TAG, `${plan.length} step(s) done; run storebound revenuecat diff to check`);
|
|
}
|
|
|
|
/** Runs the plan in order, resolving product / entitlement / offering / package ids from the state and what it creates. */
|
|
export async function execute(c: RcClient, projectId: string, plan: RcStep[], state: RcState, apps: Partial<Record<'apple' | 'play', string>>, offeringKey: string): Promise<void> {
|
|
const P = `/projects/${encodeURIComponent(projectId)}`;
|
|
const storeOf = new Map(Object.entries(apps).map(([s, id]) => [id, s] as const));
|
|
const productId = new Map<string, string>();
|
|
for (const p of state.products) { const s = storeOf.get(p.app_id); if (s) productId.set(`${s}:${p.store_identifier}`, p.id); }
|
|
const entId = new Map(state.entitlements.map(e => [e.lookup_key, e.id] as const));
|
|
const current = state.offerings.find(o => o.lookup_key === offeringKey);
|
|
let offering = current?.id ?? '';
|
|
const pkgId = new Map((current?.packages ?? []).map(p => [p.lookup_key, p.id] as const));
|
|
const pid = (k: string) => { const id = productId.get(k); if (!id) throw new Error(`RevenueCat product ${k} was not created`); return id; };
|
|
// creates go through createOnce: a 409 (lookup key / store identifier taken) or an unclear answer finds the existing
|
|
// one by its key with a fresh list, never a second one; attaching and is_current are the same when sent twice
|
|
const once = async (what: string, list: string, match: (x: J) => boolean, create: () => Promise<J>): Promise<string> =>
|
|
(await createOnce<J>({ what, tag: TAG, unique: true, find: async () => (await c.all(list)).find(match) ?? null, create })).value.id as string;
|
|
for (const [i, s] of plan.entries()) {
|
|
log(TAG, `${i + 1}/${plan.length} ${s.what}`);
|
|
switch (s.op) {
|
|
case 'create-product': productId.set(s.key, await once(s.what, `${P}/products?limit=100`, x => x.app_id === s.body.app_id && x.store_identifier === s.body.store_identifier, () => c.post(`${P}/products`, s.body))); break;
|
|
case 'create-entitlement': entId.set(s.lookupKey, await once(s.what, `${P}/entitlements?limit=100`, x => x.lookup_key === s.lookupKey, () => c.post(`${P}/entitlements`, s.body))); break;
|
|
case 'attach-entitlement':
|
|
await c.post(`${P}/entitlements/${encodeURIComponent(entId.get(s.lookupKey)!)}/actions/attach_products`, { product_ids: s.products.map(pid) }); break;
|
|
case 'create-offering': offering = await once(s.what, `${P}/offerings?limit=100`, x => x.lookup_key === s.lookupKey, () => c.post(`${P}/offerings`, s.body)); break;
|
|
case 'create-package': {
|
|
const pkgs = `${P}/offerings/${encodeURIComponent(offering)}/packages`;
|
|
pkgId.set(s.lookupKey, await once(s.what, `${pkgs}?limit=100`, x => x.lookup_key === s.lookupKey, () => c.post(pkgs, s.body))); break;
|
|
}
|
|
case 'attach-package':
|
|
await c.post(`${P}/packages/${encodeURIComponent(pkgId.get(s.lookupKey)!)}/actions/attach_products`, { products: s.products.map(k => ({ product_id: pid(k), eligibility_criteria: 'all' })) }); break;
|
|
case 'set-current': await c.post(`${P}/offerings/${encodeURIComponent(offering)}`, { is_current: true }); break;
|
|
}
|
|
}
|
|
}
|