apps folders add adds the folder (template note, refusals with reasons); MCP: .mcp.json never spawns a missing plugin path (sh fallback to PATH, npx), mcp/serve skip the self-update, spawned handshake test; store build minimums in one dated table: Play target API 36 and App Store Xcode 26 / SDK 26 blocked in scan, validate, play upload/release, apple upload; pitfalls 81-83

This commit is contained in:
CRTkafa 2026-10-20 12:15:00 +07:00
parent 9805582278
commit 9ab64bcbdf
16 changed files with 323 additions and 14 deletions

View file

@ -14,6 +14,7 @@
// with `apple testflight wait` (waitForBuild) until processingState is VALID (exit 0), INVALID / FAILED (exit 1) or the
// timeout (exit 7).
// An upload is a write: --dry shows the plan; a real run asks first (--yes in scripts).
import { appleSdkProblem } from '../lib/store-minimums';
import { closeSync, existsSync, openSync, readdirSync, readSync, statSync } from 'node:fs';
import { basename, join, relative } from 'node:path';
import { ascKeyFile } from '../lib/credentials';
@ -230,6 +231,9 @@ export async function run(ctx: Ctx): Promise<void> {
const info = ipaInfo(ipa);
if (want && info.build !== want) throw new Error(`${relative(root, ipa)} is build ${info.build}, not ${want} (--build): did the last apple build fail?`);
if (info.bundleId !== bundleId) throw new Error(`${relative(root, ipa)} is ${info.bundleId}, the config says ${bundleId}`);
// App Store Connect refuses IPAs from an Xcode / SDK older than its current minimum (cli/lib/store-minimums.ts)
const sdkWhy = appleSdkProblem(info);
if (sdkWhy) throw new Error(`${relative(root, ipa)}: not uploaded, ${sdkWhy}`);
// every PrivacyInfo.xcprivacy in the app (root, Frameworks, bundles): Apple refuses the build minutes after the
// upload when NSPrivacyTracking and NSPrivacyTrackingDomains disagree (ITMS-91064), so stop before any network call
const privacy = bundleTrackingIssues(ipaManifests(ipa));

View file

@ -24,7 +24,7 @@ import { ascClient } from './apple/asc';
import { REPORTING_API } from './play/google';
import * as rt from './lib/rt';
import { existsSync, readFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { basename, join, resolve } from 'node:path';
import { CONFIG_NAMES } from './lib/config';
import { addFolder, addStoreApp, readRegistry, registryFile, removeFolder, removeStoreApp } from './lib/registry';
@ -162,6 +162,10 @@ const tilde = (p: string) => (process.env.HOME && p.startsWith(process.env.HOME)
/** apps folders / add / remove: the machine's app folder list (cli/lib/registry.ts). */
async function folders(sub: string, args: Args): Promise<void> {
// `apps folders add [<dir>]` / `apps folders remove <dir>`: the same as `apps add` / `apps remove` (this form used
// to fall through to the list and add nothing, without a word)
if (sub === 'folders' && (args._[1] === 'add' || args._[1] === 'remove')) { sub = args._[1]; args._.splice(0, 1); }
if (sub === 'folders' && args._[1]) throw new Error(`storebound apps folders ${args._[1]}: unknown; to add a folder: storebound apps add [<folder>] (or apps folders add [<folder>]), to list: storebound apps folders`);
if (sub === 'folders') {
const r = readRegistry();
if (args.has('json')) { printJson({ file: registryFile(), projects: r.projects.map(p => ({ ...p, missing: !existsSync(p.path) })), storeApps: r.storeApps ?? [] }); return; }
@ -200,9 +204,13 @@ ${tilde(registryFile())}: the same list as the desktop app's App folders`);
}
const dir = resolve(args._[1] ?? '.');
if (sub === 'add') {
if (!CONFIG_NAMES.some(n => existsSync(join(dir, n)))) throw new Error(`${dir}: no storebound config here (storebound init starts one)`);
if (!existsSync(dir)) throw new Error(`${dir}: no such folder`);
const cfg = CONFIG_NAMES.find(n => existsSync(join(dir, n)));
if (!cfg) throw new Error(`${dir}: not added, no storebound config here (${CONFIG_NAMES.join(', ')}); run storebound init in it, then this again`);
const r = addFolder(dir);
log('apps', r.added ? `added ${tilde(r.project.path)} (it shows in the desktop app too)` : `${tilde(r.project.path)} is already in the list`);
const note = templateNote(join(dir, cfg));
if (note) warn('apps', note);
return;
}
if (!args._[1]) throw new Error('storebound apps remove <folder>');
@ -249,3 +257,11 @@ export async function run(args: Args): Promise<void> {
console.error('usage: storebound apps list [--store apple|play] [--icons] [--json] | apps check [--store apple|play] [--json] | apps folders [--json] | apps add [<folder>] | apps add --store apple|play --app-id <id> [--account <profile>] | apps remove <folder> | apps remove apple:<id>');
process.exit(2);
}
/** A note when the config still holds the template's placeholders (the folder is added anyway: it is the owner's). */
export function templateNote(file: string): string | null {
let t = '';
try { t = readFileSync(file, 'utf8'); } catch { return null; }
const left = [/['"]Example App['"]/.test(t) && "app.name is still the template's 'Example App'", /com\.example\./.test(t) && 'the bundle id / package is still com.example.*'].filter(Boolean);
return left.length ? `${left.join('; ')}: set your app's values in ${basename(file)} (storebound check lists the rest)` : null;
}

View file

@ -62,7 +62,8 @@ async function main(): Promise<void> {
}
}
// npx / bunx run a cached copy: start the newest storebound instead (cli/lib/self-update.ts)
{ const { runNewest } = await import('./lib/self-update'); await runNewest((await import('../package.json')).version, process.argv.slice(2)); }
// never for mcp / serve: a client waits for the handshake on stdout, and a second npx download can outlast its timeout
if (argv[0] !== 'mcp' && argv[0] !== 'serve') { const { runNewest } = await import('./lib/self-update'); await runNewest((await import('../package.json')).version, process.argv.slice(2)); }
const [a, b] = argv;
if (!a || a === 'help' || a === '--help' || a === '-h') return help();
if (a === 'version' || a === '--version') return console.log((await import('../package.json')).version);

View file

@ -8,6 +8,7 @@ import { basename, dirname, extname, join, relative, sep } from 'node:path';
import { resolvePackage } from '../node-packages';
import { isBuildCopyPath, type Manifest, parseManifest } from '../privacy-manifest';
import { type DepRef, type Eco, matchesSdk, SDKS, type SdkDef } from './sdks';
import { exportedIpas, ipaInfo } from '../xcode';
export interface Loc { file: string; line: number }
export interface PlistEntry { key: string; value: unknown; at: Loc }
@ -88,6 +89,8 @@ export interface ProjectFacts {
sources: { path: string; text: string }[];
/** Folders of built web output inside the project (Capacitor webDir): low-trust evidence, see isBuilt. */
builtDirs: string[];
/** Xcode / SDK of the newest IPA in build/ios/export (Info.plist DTXcode, DTSDKName, DTPlatformVersion). */
iosBuild?: { file: string; xcode?: string; sdkName?: string; platformVersion?: string } | null;
/** Files looked at, relative to root (for the report). */
scanned: number;
}
@ -542,7 +545,7 @@ export function detectProject(root: string): ProjectFacts {
for (const x of extra.services) if (!svc.has(key('service', x.name))) android.services.push(x);
}
}
return { root, stacks: [...stacks], ios, android: isAndroid, deps, sdks, plist, plistSources, entitlements, privacyManifests, androidInfo: android, native, remoteStart, sources, builtDirs, scanned: files.length };
return { root, stacks: [...stacks], ios, android: isAndroid, deps, sdks, plist, plistSources, entitlements, privacyManifests, androidInfo: android, native, remoteStart, sources, builtDirs, iosBuild: newestIpaBuild(root), scanned: files.length };
}
/** NDK r27 flag and the linker flag of older NDKs that make native libraries 16 KB aligned. */
@ -640,3 +643,13 @@ export const where = (l: Loc): string => `${l.file}:${l.line}`;
/** Ecosystems in a readable order for the report. */
export const ECO_LABEL: Record<Eco, string> = { cocoapods: 'CocoaPods', spm: 'SwiftPM', carthage: 'Carthage', gradle: 'Gradle', npm: 'npm', pub: 'pub', unity: 'Unity', godot: 'Godot' };
/** The build tools of the newest IPA under build/ios/export, or null (no IPA, or unreadable). */
function newestIpaBuild(root: string): ProjectFacts['iosBuild'] {
try {
const ipa = exportedIpas(join(root, 'build/ios/export'))[0];
if (!ipa) return null;
const i = ipaInfo(ipa);
return { file: relative(root, ipa).split(sep).join('/'), xcode: i.xcode, sdkName: i.sdkName, platformVersion: i.platformVersion };
} catch { return null; }
}

View file

@ -47,6 +47,7 @@ import { playRules } from './play-rules';
import { grep, grepOwn, type Hit, lineOf, type ProjectFacts } from './project';
import { matchesSdk, OBFUSCATORS, REMOTE_CODE, REMOTE_SDKS, type RemoteSdkDef, versionAtLeast } from './sdks';
import { reviewNotes } from '../review-details';
import { appleSdkProblem, PLAY_TARGET_SDK_TABLE } from '../store-minimums';
export type Severity = 'blocker' | 'high' | 'medium' | 'low';
export type ScanStore = 'apple' | 'play';
@ -93,8 +94,10 @@ export interface ScanInput {
export const APPLE_GUIDELINES = 'https://developer.apple.com/app-store/review/guidelines/';
const PLAY = 'https://support.google.com/googleplay/android-developer/answer/';
export const PLAY_POLICY = 'https://play.google/developer-content-policy/';
export const PLAY_TARGET_SDK = { phone: 36, wear: 35, automotive: 35, tv: 34 } as const;
export const PLAY_TARGET_SDK_SINCE = '2026-08-31';
/** The Play target API level in force today (the dated table is cli/lib/store-minimums.ts). */
const PLAY_SDK_ROW = PLAY_TARGET_SDK_TABLE.filter(r => r.since <= new Date().toISOString().slice(0, 10)).at(-1) ?? PLAY_TARGET_SDK_TABLE[0]!;
export const PLAY_TARGET_SDK = PLAY_SDK_ROW.min;
export const PLAY_TARGET_SDK_SINCE = PLAY_SDK_ROW.since;
// ---- code signals ----
/** `re` only on lines that are not import / require / export-from statements (a module path is not evidence). Line
@ -363,6 +366,13 @@ export function scanRules(inp: ScanInput): ScanFinding[] {
}
// ---- App Tracking Transparency ----
// ---- App Store: the Xcode / SDK of the newest local IPA (cli/lib/store-minimums.ts) ----
if (apple && p.iosBuild) {
const why = appleSdkProblem(p.iosBuild);
if (why) add({ rule: 'apple-min-sdk', guideline: 'App Store: minimum Xcode / SDK', store: 'apple', severity: 'blocker', title: why.split(': ')[0]!,
evidence: [{ file: p.iosBuild.file, line: 1, text: [p.iosBuild.xcode && `DTXcode ${p.iosBuild.xcode}`, p.iosBuild.sdkName && `DTSDKName ${p.iosBuild.sdkName}`].filter(Boolean).join(', ') }],
fix: why.slice(why.indexOf(': ') + 2), skill: 'build-and-sign', ref: 'https://developer.apple.com/news/upcoming-requirements/' });
}
if (apple && p.ios) {
const trackers = p.sdks.filter(s => s.sdk.tracking);
const trackEv = sdkHits(s => s.sdk.tracking);
@ -552,7 +562,7 @@ export function scanRules(inp: ScanInput): ScanFinding[] {
const need = PLAY_TARGET_SDK[a.formFactor];
if (a.targetSdk && a.targetSdk.value < need)
add({ rule: 'play-target-sdk', guideline: 'Play: Target API level', store: 'play', severity: 'blocker', title: `targetSdk ${a.targetSdk.value} is below ${need}, which new apps and updates need since ${PLAY_TARGET_SDK_SINCE}`, evidence: [{ ...a.targetSdk.at, text: `targetSdk ${a.targetSdk.value}` }],
fix: `Raise targetSdk (and compileSdk) to ${need}, test the behavior changes of each Android version in between, then upload a new AAB (an extension to 2026-11-01 can be requested in Play Console).`, skill: 'play-first-release',
fix: `Set targetSdkVersion = ${need} (and compileSdkVersion) in ${a.targetSdk.at.file} and rebuild; test the behavior changes of each Android version in between, then upload a new AAB ${PLAY_SDK_ROW.extensionUntil ? ` (an extension to ${PLAY_SDK_ROW.extensionUntil} can be requested in Play Console)` : ''}.`, skill: 'play-first-release',
ref: 'https://developer.android.com/google/play/requirements/target-sdk' });
else if (!a.targetSdk && a.targetSdkFrom)
add({ rule: 'play-target-sdk', guideline: 'Play: Target API level', store: 'play', severity: 'low', title: `targetSdk comes from ${a.targetSdkFrom}: check that the built AAB targets ${need} or higher`, evidence: a.manifests.slice(0, 1).map(f => ({ file: f, line: 1, text: 'AndroidManifest.xml' })),

81
cli/lib/store-minimums.ts Normal file
View file

@ -0,0 +1,81 @@
// Store build minimums, one dated table per store: a new year is one new row.
// - Google Play: the target API level (targetSdkVersion) that new apps and app updates need. Since 2026-08-31 Google's
// rule is the same for new apps and updates (an extension to 2026-11-01 can be requested in Play Console).
// https://developer.android.com/google/play/requirements/target-sdk
// - App Store: the Xcode / SDK an upload must be built with (Apple's "Upcoming requirements").
// https://developer.apple.com/news/upcoming-requirements/
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
export type FormFactor = 'phone' | 'wear' | 'tv' | 'automotive';
/** Google Play target API level per form factor, by the date it is enforced (oldest first). */
export const PLAY_TARGET_SDK_TABLE: readonly { since: string; extensionUntil?: string; min: Record<FormFactor, number> }[] = [
{ since: '2025-08-31', extensionUntil: '2025-11-01', min: { phone: 35, wear: 34, automotive: 34, tv: 34 } },
{ since: '2026-08-31', extensionUntil: '2026-11-01', min: { phone: 36, wear: 35, automotive: 35, tv: 34 } },
];
/** App Store: uploads must be built with at least this Xcode and platform SDK (iOS / iPadOS etc.), by date. */
export const APPLE_MIN_SDK_TABLE: readonly { since: string; xcode: number; sdk: number }[] = [
{ since: '2025-04-24', xcode: 16, sdk: 18 },
{ since: '2026-04-28', xcode: 26, sdk: 26 },
];
const today = (): string => new Date().toISOString().slice(0, 10);
function rowAt<T extends { since: string }>(t: readonly T[], date: string): T | null {
let r: T | null = null;
for (const x of t) if (x.since <= date) r = x;
return r;
}
/** The Play target API level in force on `date` (default today). */
export function playTargetSdk(form: FormFactor = 'phone', date = today()): { min: number; since: string; extensionUntil?: string } | null {
const r = rowAt(PLAY_TARGET_SDK_TABLE, date);
return r ? { min: r.min[form], since: r.since, extensionUntil: r.extensionUntil } : null;
}
/** Why Play refuses this targetSdk, with the fix; null when it is fine (or unknown). */
export function playTargetSdkProblem(target: number | undefined | null, o: { form?: FormFactor; date?: string; where?: string } = {}): string | null {
const r = playTargetSdk(o.form ?? 'phone', o.date);
if (!r || !target || target >= r.min) return null;
const where = o.where ?? 'android/variables.gradle (or app/build.gradle)';
return `targetSdkVersion ${target} < ${r.min}: Google Play takes new apps and updates only for API ${r.min}+ since ${r.since}` +
`${o.form && o.form !== 'phone' ? ` (${o.form})` : ''}; set targetSdkVersion = ${r.min} (and compileSdkVersion) in ${where} and rebuild` +
(r.extensionUntil ? ` (an extension to ${r.extensionUntil} can be requested in Play Console)` : '');
}
/** The App Store minimum in force on `date` (default today). */
export function appleMinSdk(date = today()): { xcode: number; sdk: number; since: string } | null {
return rowAt(APPLE_MIN_SDK_TABLE, date);
}
/** Xcode major from DTXcode ("2600" -> 26, "1640" -> 16). */
export const xcodeMajor = (dt: string | undefined): number | null => (dt && /^\d{3,4}$/.test(dt) ? Math.floor(Number(dt) / 100) : null);
/** SDK major from DTPlatformVersion ("26.0") or DTSDKName ("iphoneos26.0"). */
export function sdkMajor(platformVersion: string | undefined, sdkName?: string): number | null {
const m = (platformVersion ?? '').match(/^(\d+)/) ?? (sdkName ?? '').match(/(\d+)(?:\.\d+)*$/);
return m ? Number(m[1]) : null;
}
/** Why App Store Connect refuses an IPA built with this Xcode / SDK, with the fix; null when fine (or unknown). */
export function appleSdkProblem(b: { xcode?: string; sdkName?: string; platformVersion?: string }, date?: string): string | null {
const r = appleMinSdk(date);
if (!r) return null;
const x = xcodeMajor(b.xcode), s = sdkMajor(b.platformVersion, b.sdkName);
if ((x === null || x >= r.xcode) && (s === null || s >= r.sdk)) return null;
const got = [x !== null ? `Xcode ${x}` : '', b.sdkName ?? (s !== null ? `SDK ${s}` : '')].filter(Boolean).join(', ');
return `built with ${got} < Xcode ${r.xcode} / SDK ${r.sdk}: App Store Connect takes uploads only from Xcode ${r.xcode}+ with the ${r.sdk} SDKs since ${r.since}; ` +
`install Xcode ${r.xcode} (xcode-select -s), rebuild (storebound apple build) and upload the new IPA`;
}
/** The project file that sets targetSdkVersion (Capacitor variables.gradle, Gradle app module, Unity ProjectSettings),
* for the fix line; a generic hint when none is found. */
export function targetSdkFile(root: string): string {
const files = ['android/variables.gradle', 'android/app/build.gradle', 'android/app/build.gradle.kts', 'app/build.gradle', 'app/build.gradle.kts',
'android/build.gradle', 'android/build.gradle.kts', 'ProjectSettings/ProjectSettings.asset'];
for (const f of files) {
try { if (existsSync(join(root, f)) && /targetSdk|AndroidTargetSdkVersion/.test(readFileSync(join(root, f), 'utf8'))) return f.endsWith('.asset') ? `${f} (AndroidTargetSdkVersion; Player Settings > Target API Level)` : f; } catch { /* unreadable */ }
}
return 'the app module\'s build.gradle (Capacitor: android/variables.gradle; Flutter: android/app/build.gradle; Unity: Player Settings > Target API Level)';
}

View file

@ -147,7 +147,7 @@ export const flutterConfigArgs = (build: string, version?: string): string[] =>
export const showCmd = (argv: string[], auth: AscAuth | null): string =>
argv.map(a => (auth && a === auth.keyPath ? '$ASC_P8' : /[\s"'$]/.test(a) ? JSON.stringify(a) : a)).join(' ');
export interface IpaInfo { bundleId: string; version: string; build: string; minOs?: string; name?: string; platform?: string }
export interface IpaInfo { bundleId: string; version: string; build: string; minOs?: string; name?: string; platform?: string; xcode?: string; sdkName?: string; platformVersion?: string }
/** Bundle id, CFBundleShortVersionString and CFBundleVersion from Payload/<App>.app/Info.plist of an IPA. */
/** Every .ipa in the export folder and its per-build subfolders (<version>-<build>/), newest first. */
@ -169,7 +169,8 @@ export function ipaInfo(ipa: string | Buffer): IpaInfo {
const str = (k: string): string | undefined => (typeof p[k] === 'string' ? p[k] as string : undefined);
const bundleId = str('CFBundleIdentifier'), version = str('CFBundleShortVersionString'), build = str('CFBundleVersion');
if (!bundleId || !version || !build) throw new Error('Info.plist without CFBundleIdentifier / CFBundleShortVersionString / CFBundleVersion');
return { bundleId, version, build, minOs: str('MinimumOSVersion'), name: str('CFBundleDisplayName') ?? str('CFBundleName'), platform: (p.CFBundleSupportedPlatforms as string[] | undefined)?.[0] };
return { bundleId, version, build, minOs: str('MinimumOSVersion'), name: str('CFBundleDisplayName') ?? str('CFBundleName'), platform: (p.CFBundleSupportedPlatforms as string[] | undefined)?.[0],
xcode: str('DTXcode'), sdkName: str('DTSDKName'), platformVersion: str('DTPlatformVersion') };
}
/** End states of an App Store Connect build upload (BuildUploadState); processing is polled by apple testflight wait. */

View file

@ -12,6 +12,7 @@ import type { Ctx } from '../lib/ctx';
import { confirm, need } from '../lib/ctx';
import { done, log, warn } from '../lib/log';
import { aabInfo } from '../lib/gradle';
import { playTargetSdkProblem, targetSdkFile } from '../lib/store-minimums';
import { playClient } from './client';
import { usedVersionCodes } from './next-version-code';
import { readPlayCopy, refusePlayNotes } from './listing';
@ -49,6 +50,10 @@ export async function run(ctx: Ctx): Promise<void> {
refusePlayNotes(ctx, notes, `play release notes${a.has('dry') ? ' (dry run)' : ''}`);
if (live && !a.has('dry') && !(await confirm(ctx, `Release to PRODUCTION as "${live.status}" (public once Google approves)?`))) return;
if (aab) {
const sdkWhy = playTargetSdkProblem(aabInfo(aab).targetSdk, { where: targetSdkFile(root) });
if (sdkWhy) throw new Error(`${aab}: not released, ${sdkWhy}`);
}
const play = await playClient(root, pkg);
try {
await play.edit(async (id, ctl) => {

View file

@ -10,6 +10,7 @@ import { join, relative } from 'node:path';
import type { Ctx } from '../lib/ctx';
import { confirm, need } from '../lib/ctx';
import { aabInfo } from '../lib/gradle';
import { playTargetSdkProblem, targetSdkFile } from '../lib/store-minimums';
import { done, log, printJson } from '../lib/log';
import { nextVersionCode } from '../lib/release';
import { playClient } from './client';
@ -39,6 +40,9 @@ export async function run(ctx: Ctx): Promise<void> {
}
const info = aabInfo(aab);
if (info.packageName !== pkg) throw new Error(`${relative(root, aab)} is ${info.packageName}, the config says ${pkg}`);
// Play refuses bundles below its current target API level (cli/lib/store-minimums.ts): stop before any network call
const sdkWhy = playTargetSdkProblem(info.targetSdk, { where: targetSdkFile(root) });
if (sdkWhy) throw new Error(`${relative(root, aab)}: not uploaded, ${sdkWhy}`);
const size = statSync(aab).size;
const dry = a.has('dry');
const json = a.has('json');