mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
243 lines
8.9 KiB
JavaScript
243 lines
8.9 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { accessSync, constants, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { delimiter, join, resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { spawnSync } from 'node:child_process';
|
|
|
|
const projectDir = resolve(import.meta.dirname, '..');
|
|
const easConfig = JSON.parse(readFileSync(join(projectDir, 'eas.json'), 'utf8'));
|
|
const profiles = {
|
|
production: {
|
|
apiOrigin: 'https://api.sauci.app',
|
|
authOrigin: 'https://ckjcrkjpmhqhiucifukx.supabase.co',
|
|
branch: 'production',
|
|
profile: 'production',
|
|
},
|
|
staging: {
|
|
apiOrigin: 'https://api.preprod.sauci.app',
|
|
authOrigin: 'https://itbzhrvlgvdmzbnhzhyx.supabase.co',
|
|
branch: 'staging',
|
|
profile: 'preview',
|
|
},
|
|
};
|
|
const requiredPublicKeys = [
|
|
'EXPO_PUBLIC_API_URL',
|
|
'EXPO_PUBLIC_SUPABASE_URL',
|
|
'EXPO_PUBLIC_SUPABASE_ANON_KEY',
|
|
];
|
|
const requiredProductionRevenueCatConfig = [
|
|
['EXPO_PUBLIC_REVENUECAT_IOS_API_KEY', 'appl_'],
|
|
['EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY', 'goog_'],
|
|
['EXPO_PUBLIC_REVENUECAT_ENTITLEMENT_ID', null],
|
|
];
|
|
const EOAS_PACKAGE_RUNNER = 'sauci-eoas-npx';
|
|
const EOAS_RUNNER_BIN_DIR = join(projectDir, 'scripts', 'bin');
|
|
|
|
function fail(message) {
|
|
throw new Error(`OTA publish preflight: ${message}`);
|
|
}
|
|
|
|
function requireCanonicalHttpsRoot(value, expected, label) {
|
|
try {
|
|
const parsed = new URL(value);
|
|
if (
|
|
value !== expected ||
|
|
parsed.protocol !== 'https:' ||
|
|
parsed.origin !== expected ||
|
|
parsed.username ||
|
|
parsed.password ||
|
|
parsed.pathname !== '/' ||
|
|
parsed.search ||
|
|
parsed.hash ||
|
|
parsed.port
|
|
) {
|
|
fail(`${label} must be the canonical HTTPS root ${expected}`);
|
|
}
|
|
} catch {
|
|
fail(`${label} must be the canonical HTTPS root ${expected}`);
|
|
}
|
|
}
|
|
|
|
export function resolvePublishEnvironment(target, environment = process.env, config = easConfig) {
|
|
const profile = profiles[target];
|
|
if (!profile) fail(`target must be one of: ${Object.keys(profiles).join(', ')}`);
|
|
|
|
const profileEnv = config.build?.[profile.profile]?.env;
|
|
if (!profileEnv) fail(`missing EAS build profile ${profile.profile}`);
|
|
const env = { ...environment, ...profileEnv, RELEASE_CHANNEL: target };
|
|
|
|
for (const key of requiredPublicKeys) {
|
|
if (!env[key]) fail(`${key} is required for ${target} export`);
|
|
}
|
|
requireCanonicalHttpsRoot(env.EXPO_PUBLIC_SUPABASE_URL, profile.authOrigin, `${target} Auth URL`);
|
|
requireCanonicalHttpsRoot(env.EXPO_PUBLIC_API_URL, profile.apiOrigin, `${target} API URL`);
|
|
|
|
if (target === 'production') {
|
|
for (const [key, prefix] of requiredProductionRevenueCatConfig) {
|
|
const value = env[key];
|
|
if (typeof value !== 'string' || !value.trim()) fail(`${key} is required for production export`);
|
|
if (prefix && !value.startsWith(prefix)) {
|
|
fail(`${key} must be a valid ${prefix} RevenueCat public key`);
|
|
}
|
|
}
|
|
}
|
|
|
|
return { branch: profile.branch, env };
|
|
}
|
|
|
|
function exportFiles(outputDir) {
|
|
return readdirSync(outputDir, { withFileTypes: true }).flatMap((entry) => {
|
|
const entryPath = join(outputDir, entry.name);
|
|
return entry.isDirectory() ? exportFiles(entryPath) : [entryPath];
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Expo inlines EXPO_PUBLIC_* values into the JavaScript bundle. Verify the
|
|
* isolated production export rather than trusting only the process environment:
|
|
* a missing key otherwise becomes an empty string on installed devices.
|
|
*/
|
|
export function assertProductionExportContainsRevenueCatConfig(outputDir, environment) {
|
|
const files = exportFiles(outputDir).filter((filePath) => statSync(filePath).isFile());
|
|
for (const [key] of requiredProductionRevenueCatConfig) {
|
|
const expectedValue = environment[key];
|
|
const present = files.some((filePath) => readFileSync(filePath).includes(expectedValue));
|
|
if (!present) fail(`production export does not contain ${key}`);
|
|
}
|
|
}
|
|
|
|
function resolveExecutable(command, pathValue) {
|
|
for (const directory of pathValue.split(delimiter)) {
|
|
if (!directory) continue;
|
|
const candidate = join(directory, command);
|
|
try {
|
|
accessSync(candidate, constants.X_OK);
|
|
return candidate;
|
|
} catch {
|
|
// Continue looking through PATH.
|
|
}
|
|
}
|
|
fail(`could not resolve ${command} before configuring the EOAS package runner`);
|
|
}
|
|
|
|
/**
|
|
* EOAS starts its own Expo subprocess with EXPO_NO_DOTENV=1. Pin its package
|
|
* runner to our checked-in wrapper so the actual EOAS export fails before it
|
|
* starts if its inherited RevenueCat configuration differs from this profile.
|
|
*/
|
|
export function buildEoasEnvironment(environment) {
|
|
const originalPath = environment.PATH || process.env.PATH || '';
|
|
const expected = Object.fromEntries(requiredProductionRevenueCatConfig.map(([key]) => [
|
|
`SAUCI_EOAS_EXPECTED_${key}`,
|
|
environment[key],
|
|
]));
|
|
return {
|
|
...environment,
|
|
EXPO_NO_DOTENV: '1',
|
|
EOAS_PACKAGE_RUNNER,
|
|
PATH: `${EOAS_RUNNER_BIN_DIR}${delimiter}${originalPath}`,
|
|
SAUCI_EOAS_NPX: resolveExecutable('npx', originalPath),
|
|
...expected,
|
|
};
|
|
}
|
|
|
|
export function buildEoasPublishArgs(branch, rawPublishArgs, useProductionGuard = false) {
|
|
const args = [
|
|
'eoas',
|
|
'publish',
|
|
'--branch',
|
|
branch,
|
|
...parsePublishArgs(rawPublishArgs),
|
|
];
|
|
if (useProductionGuard) args.splice(4, 0, '--packageRunner', EOAS_PACKAGE_RUNNER);
|
|
return args;
|
|
}
|
|
|
|
export function parsePublishArgs(args) {
|
|
const parsed = [];
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const argument = args[index];
|
|
const takeValue = (flag, validate) => {
|
|
const value = args[index + 1];
|
|
if (!value || !validate(value)) fail(`${flag} requires a valid value`);
|
|
parsed.push(flag, value);
|
|
index += 1;
|
|
};
|
|
if (argument === '--platform') {
|
|
takeValue('--platform', (value) => ['ios', 'android', 'all'].includes(value));
|
|
} else if (argument.startsWith('--platform=')) {
|
|
const value = argument.slice('--platform='.length);
|
|
if (!['ios', 'android', 'all'].includes(value)) fail('--platform requires ios, android, or all');
|
|
parsed.push(argument);
|
|
} else if (argument === '-m' || argument === '--message') {
|
|
takeValue(argument, (value) => value.length > 0);
|
|
} else if (argument.startsWith('--message=')) {
|
|
if (!argument.slice('--message='.length)) fail('--message requires a value');
|
|
parsed.push(argument);
|
|
} else if (argument === '--rollout-percentage') {
|
|
takeValue(argument, (value) => /^(?:[1-9]|[1-9][0-9])$/.test(value));
|
|
} else if (argument.startsWith('--rollout-percentage=')) {
|
|
const value = argument.slice('--rollout-percentage='.length);
|
|
if (!/^(?:[1-9]|[1-9][0-9])$/.test(value)) fail('--rollout-percentage must be 1-99');
|
|
parsed.push(argument);
|
|
} else if (argument === '--nonInteractive') {
|
|
parsed.push(argument);
|
|
} else {
|
|
fail(`unsupported publish argument ${argument}`);
|
|
}
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
function run(command, args, env) {
|
|
const result = spawnSync(command, args, { cwd: projectDir, env, stdio: 'inherit' });
|
|
if (result.error) throw result.error;
|
|
if (result.status !== 0) process.exit(result.status ?? 1);
|
|
}
|
|
|
|
function exportAndVerify(target, environment) {
|
|
const outputDir = mkdtempSync(join(tmpdir(), `sauci-ota-${target}-`));
|
|
try {
|
|
run('npx', ['expo', 'export', '--output-dir', outputDir], environment);
|
|
if (target === 'production') {
|
|
assertProductionExportContainsRevenueCatConfig(outputDir, environment);
|
|
}
|
|
} finally {
|
|
rmSync(outputDir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
export function main(args = process.argv.slice(2), environment = process.env) {
|
|
const [target, ...remaining] = args;
|
|
const preflight = remaining.includes('--preflight');
|
|
const doctor = remaining.includes('--doctor');
|
|
const rawPublishArgs = remaining.filter((arg) => arg !== '--preflight' && arg !== '--doctor');
|
|
if (preflight && doctor) fail('choose either --preflight or --doctor');
|
|
|
|
const { branch, env } = resolvePublishEnvironment(target, environment);
|
|
const exportEnv = { ...env, EXPO_NO_DOTENV: '1' };
|
|
if (preflight) {
|
|
exportAndVerify(target, exportEnv);
|
|
console.log(`OTA ${target} export preflight passed`);
|
|
return;
|
|
}
|
|
|
|
if (doctor) {
|
|
if (rawPublishArgs.length) fail('--doctor does not accept additional EOAS arguments');
|
|
run('npx', ['eoas', 'doctor', '--channel', branch], exportEnv);
|
|
return;
|
|
}
|
|
|
|
exportAndVerify(target, exportEnv);
|
|
const eoasEnvironment = target === 'production' ? buildEoasEnvironment(exportEnv) : exportEnv;
|
|
run('npx', buildEoasPublishArgs(branch, rawPublishArgs, target === 'production'), eoasEnvironment);
|
|
}
|
|
|
|
// Compare real filesystem paths. A URL pathname percent-encodes spaces, so
|
|
// comparing it to process.argv[1] silently skips main() under any checkout
|
|
// whose absolute path contains a space, and every ota:* script then exits 0
|
|
// having done nothing.
|
|
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main();
|