diff --git a/.mcp.json b/.mcp.json index cfd2b8d..fbf5215 100644 --- a/.mcp.json +++ b/.mcp.json @@ -1,9 +1,10 @@ { "mcpServers": { "storebound": { - "command": "${CLAUDE_PLUGIN_ROOT}/bin/storebound", + "command": "sh", "args": [ - "mcp" + "-c", + "r=\"${CLAUDE_PLUGIN_ROOT:-}\"; if [ -n \"$r\" ] && [ -x \"$r/bin/storebound\" ]; then exec \"$r/bin/storebound\" mcp; fi; if command -v storebound >/dev/null 2>&1; then exec storebound mcp; fi; exec npx -y storebound@latest mcp" ] } } diff --git a/CHANGELOG.md b/CHANGELOG.md index bc75c97..f4c031a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## Unreleased +- `apps folders add []` adds the folder (it listed and added nothing); a template config is added with a note, + a folder without a config is refused with the reason and the fix (pitfall 81). +- MCP: the plugin's `.mcp.json` no longer spawns a plugin path that may be gone (ENOENT, CONNECTION_CLOSED): it falls + back to `storebound` on PATH, then `npx -y storebound@latest`; `mcp` and `serve` skip the self-update (pitfall 82). +- Store build minimums in one dated table (`cli/lib/store-minimums.ts`): `play upload` / `play release` refuse an AAB + below Play's target API level (36 since 2026-08-31) and `apple upload` an IPA built below Xcode 26 / the 26 SDKs + (since 2026-04-28), with the file to change; `scan` / `validate` report the same (`play-target-sdk`, `apple-min-sdk`) + (pitfall 83). + ## 0.8.15 · 2026-10-19 - **Mac App Store screenshots.** `apple.mac: true` adds the `mac` size (2880 x 1800; `shots check` takes exactly diff --git a/cli/apple/upload.ts b/cli/apple/upload.ts index 72ff2f7..661cc4d 100644 --- a/cli/apple/upload.ts +++ b/cli/apple/upload.ts @@ -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 { 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)); diff --git a/cli/apps.ts b/cli/apps.ts index 2a7d434..1200151 100644 --- a/cli/apps.ts +++ b/cli/apps.ts @@ -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 { + // `apps folders add []` / `apps folders remove `: 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 [] (or apps folders add []), 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 '); @@ -249,3 +257,11 @@ export async function run(args: Args): Promise { console.error('usage: storebound apps list [--store apple|play] [--icons] [--json] | apps check [--store apple|play] [--json] | apps folders [--json] | apps add [] | apps add --store apple|play --app-id [--account ] | apps remove | apps remove apple:'); 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; +} diff --git a/cli/index.ts b/cli/index.ts index b6346f4..7ba9efd 100755 --- a/cli/index.ts +++ b/cli/index.ts @@ -62,7 +62,8 @@ async function main(): Promise { } } // 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); diff --git a/cli/lib/scan/project.ts b/cli/lib/scan/project.ts index dd45fc7..f0b8f8b 100644 --- a/cli/lib/scan/project.ts +++ b/cli/lib/scan/project.ts @@ -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 = { 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; } +} diff --git a/cli/lib/scan/rules.ts b/cli/lib/scan/rules.ts index 11aeb6d..8b2ddda 100644 --- a/cli/lib/scan/rules.ts +++ b/cli/lib/scan/rules.ts @@ -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' })), diff --git a/cli/lib/store-minimums.ts b/cli/lib/store-minimums.ts new file mode 100644 index 0000000..d7dcf9e --- /dev/null +++ b/cli/lib/store-minimums.ts @@ -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 }[] = [ + { 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: 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)'; +} diff --git a/cli/lib/xcode.ts b/cli/lib/xcode.ts index c37e47f..0969ac8 100644 --- a/cli/lib/xcode.ts +++ b/cli/lib/xcode.ts @@ -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/Info.plist of an IPA. */ /** Every .ipa in the export folder and its per-build subfolders (-/), 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. */ diff --git a/cli/play/release.ts b/cli/play/release.ts index 10d9d99..808214a 100644 --- a/cli/play/release.ts +++ b/cli/play/release.ts @@ -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 { 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) => { diff --git a/cli/play/upload.ts b/cli/play/upload.ts index c7489ca..0ded9c1 100644 --- a/cli/play/upload.ts +++ b/cli/play/upload.ts @@ -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 { } 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'); diff --git a/skills/store-pitfalls/SKILL.md b/skills/store-pitfalls/SKILL.md index cff17e9..3013bda 100644 --- a/skills/store-pitfalls/SKILL.md +++ b/skills/store-pitfalls/SKILL.md @@ -994,3 +994,35 @@ that fails without the fix (see CONTRIBUTING.md). `cli/shots/report.ts`, `IMAGE_SPECS.mac` and `macNote` in `cli/shots/spec.ts`, `MAC_DISPLAY` in `cli/apple/screenshots.ts`, `sheetTimeoutMs`; `tests/mac-landscape.test.ts`, `tests/browser/landscape.browser.ts`, the macOS case in `tests/idempotency.test.ts`. + +## 81. `storebound apps folders add` listed the folders and added nothing +- **Symptom:** `storebound apps folders add` (or with `"$PWD"`) in a freshly init-ed folder printed the folder list, + no error, and ~/.storebound/projects.json stayed without the folder. +- **Cause:** only `apps add` added; `apps folders` ignored every word after it and listed. +- **Do:** `apps folders add []` is now the same as `apps add []` (bare = the current folder). A folder + whose config still has template values is added with a note; a folder without a config is refused with the reason + and the fix (`storebound init`). Unknown words after `apps folders` are an error, never a silent list. +- **Guard:** `folders` and `templateNote` in `cli/apps.ts`; `tests/store-minimums.test.ts` (apps folders add). + +## 82. The MCP server failed to connect: spawn ENOENT, CONNECTION_CLOSED +- **Symptom:** the client showed `storebound (ENOENT) posix_spawn '/…/bin/storebound'` or + "CONNECTION_CLOSED: Connection closed". +- **Cause:** `.mcp.json` spawned `${CLAUDE_PLUGIN_ROOT}/bin/storebound` directly; when that folder is gone (an old + plugin cache after an update) or the variable is unset (the repo opened as a project), the spawn fails before any + code runs. Under npx, the self-update could also start a second npx download before the handshake. +- **Do:** `.mcp.json` runs `sh -c`: the plugin's launcher if it exists, else `storebound` on PATH, else + `npx -y storebound@latest mcp`. `mcp` and `serve` skip the self-update. Nothing may print on stdout before the + JSON-RPC answer (logs go to stderr). +- **Guard:** `MCP_LAUNCH` in `tools/gen-adapters.ts`, `cli/index.ts`; `tests/mcp-spawn.test.ts` spawns the CLI, the + launcher and the `.mcp.json` command with a missing plugin root and completes `initialize` within 10 s. + +## 83. Uploads below the store's build minimum (Play target API level, App Store Xcode / SDK) +- **Symptom:** Google Play refuses a bundle with targetSdkVersion 35 (API 36+ for new apps and updates since + 2026-08-31); App Store Connect refuses an IPA built with an Xcode / SDK older than the minimum (Xcode 26 / the 26 + SDKs since 2026-04-28). +- **Do:** set `targetSdkVersion` (and `compileSdkVersion`) in android/variables.gradle (Capacitor), the app module's + build.gradle(.kts), Flutter's android/app/build.gradle or Unity's Target API Level; build iOS with the current Xcode. + `scan` / `validate` (rules `play-target-sdk`, `apple-min-sdk` from the newest IPA in build/ios/export) and + `play upload` / `play release` / `apple upload` block before any network call, naming the requirement, date and fix. +- **Guard:** the dated tables `PLAY_TARGET_SDK_TABLE` and `APPLE_MIN_SDK_TABLE` in `cli/lib/store-minimums.ts` (a new + year is one row); `tests/store-minimums.test.ts`. diff --git a/tests/mcp-spawn.test.ts b/tests/mcp-spawn.test.ts new file mode 100644 index 0000000..b14fbf5 --- /dev/null +++ b/tests/mcp-spawn.test.ts @@ -0,0 +1,51 @@ +// The MCP server as a client starts it: a spawned process, an `initialize` on stdin, a JSON-RPC answer as the very +// first stdout line within 10 s (a log line on stdout closes the client's connection: CONNECTION_CLOSED). +import { describe, expect, test } from 'bun:test'; +import { spawn } from 'node:child_process'; +import { readFileSync, statSync } from 'node:fs'; +import { join } from 'node:path'; + +const ROOT = join(import.meta.dir, '..'); +const INIT = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: { name: 'test', version: '1' } } }); + +export function handshake(cmd: string, args: string[], env: Record = {}, ms = 10_000): Promise<{ first: string; stderr: string }> { + return new Promise((res, rej) => { + const p = spawn(cmd, args, { cwd: ROOT, env: { ...process.env, STOREBOUND_NO_NETWORK: '1', ...env }, stdio: ['pipe', 'pipe', 'pipe'] }); + let out = '', err = ''; + const t = setTimeout(() => { p.kill(); rej(new Error(`no initialize answer in ${ms} ms; stdout: ${out.slice(0, 300)} stderr: ${err.slice(0, 300)}`)); }, ms); + p.stderr.on('data', d => { err += d; }); + p.stdout.on('data', d => { + out += d; + const nl = out.indexOf('\n'); + if (nl >= 0) { clearTimeout(t); p.kill(); res({ first: out.slice(0, nl), stderr: err }); } + }); + p.on('error', e => { clearTimeout(t); rej(e); }); + p.stdin.write(INIT + '\n'); + }); +} + +describe('mcp start-up (spawned)', () => { + test('bun cli/index.ts mcp answers initialize first on stdout', async () => { + const { first } = await handshake(process.execPath, [join(ROOT, 'cli/index.ts'), 'mcp']); + const r = JSON.parse(first); + expect(r.id).toBe(1); + expect(r.result.serverInfo.name).toBe('storebound'); + }, 15_000); + + test('the plugin launcher (bin/storebound) is executable, POSIX sh, and completes the handshake', async () => { + const bin = join(ROOT, 'bin/storebound'); + expect(readFileSync(bin, 'utf8').startsWith('#!/bin/sh\n')).toBe(true); + expect(statSync(bin).mode & 0o111).not.toBe(0); + const { first } = await handshake(bin, ['mcp']); + expect(JSON.parse(first).result.serverInfo.name).toBe('storebound'); + }, 15_000); + + test('.mcp.json never spawns a path that may not exist (falls back when the plugin root is missing)', async () => { + const m = JSON.parse(readFileSync(join(ROOT, '.mcp.json'), 'utf8')).mcpServers.storebound as { command: string; args: string[] }; + expect(m.command).toBe('sh'); + // a plugin root that is gone (an old cache folder): the script must still start a server, here from the repo's bin + const args = m.args.map(a => a.replaceAll('${CLAUDE_PLUGIN_ROOT:-}', '/nonexistent-plugin-root')); + const { first } = await handshake(m.command, args, { PATH: `${join(ROOT, 'bin')}:${process.env.PATH}` }); + expect(JSON.parse(first).result.serverInfo.name).toBe('storebound'); + }, 15_000); +}); diff --git a/tests/store-minimums.test.ts b/tests/store-minimums.test.ts new file mode 100644 index 0000000..61cb165 --- /dev/null +++ b/tests/store-minimums.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, test } from 'bun:test'; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { appleMinSdk, appleSdkProblem, PLAY_TARGET_SDK_TABLE, playTargetSdk, playTargetSdkProblem, targetSdkFile } from '../cli/lib/store-minimums'; +import { readRegistry } from '../cli/lib/registry'; + +const ROOT = join(import.meta.dir, '..'); + +describe('Google Play target API level', () => { + test('the dated table picks the row in force', () => { + expect(playTargetSdk('phone', '2026-08-30')!.min).toBe(35); + expect(playTargetSdk('phone', '2026-08-31')!.min).toBe(36); + expect(playTargetSdk('wear', '2026-10-20')!.min).toBe(35); + expect(PLAY_TARGET_SDK_TABLE.map(r => r.since)).toEqual([...PLAY_TARGET_SDK_TABLE.map(r => r.since)].sort()); + }); + test('below the minimum: requirement, date and fix in one line', () => { + const m = playTargetSdkProblem(35, { date: '2026-10-20', where: 'android/variables.gradle' })!; + expect(m).toContain('targetSdkVersion 35 < 36'); + expect(m).toContain('2026-08-31'); + expect(m).toContain('set targetSdkVersion = 36'); + expect(m).toContain('android/variables.gradle'); + expect(playTargetSdkProblem(36, { date: '2026-10-20' })).toBeNull(); + expect(playTargetSdkProblem(undefined)).toBeNull(); + }); + test('the fix names the file that sets it (Capacitor variables.gradle)', () => { + const d = mkdtempSync(join(tmpdir(), 'sb-sdk-')); + mkdirSync(join(d, 'android')); + writeFileSync(join(d, 'android/variables.gradle'), 'ext {\n targetSdkVersion = 35\n}\n'); + expect(targetSdkFile(d)).toBe('android/variables.gradle'); + }); +}); + +describe('App Store minimum Xcode / SDK', () => { + test('table and IPA build fields', () => { + expect(appleMinSdk('2026-05-01')).toMatchObject({ xcode: 26, sdk: 26 }); + expect(appleMinSdk('2026-04-01')).toMatchObject({ xcode: 16, sdk: 18 }); + const m = appleSdkProblem({ xcode: '1640', sdkName: 'iphoneos18.4', platformVersion: '18.4' }, '2026-10-20')!; + expect(m).toContain('Xcode 16'); + expect(m).toContain('Xcode 26'); + expect(m).toContain('2026-04-28'); + expect(appleSdkProblem({ xcode: '2600', sdkName: 'iphoneos26.0', platformVersion: '26.0' }, '2026-10-20')).toBeNull(); + expect(appleSdkProblem({}, '2026-10-20')).toBeNull(); + }); +}); + +describe('apps folders add', () => { + const cli = (cwd: string, reg: string, ...args: string[]) => spawnSync(process.execPath, [join(ROOT, 'cli/index.ts'), ...args], { cwd, encoding: 'utf8', env: { ...process.env, STOREBOUND_REGISTRY: reg, STOREBOUND_NO_UPDATE: '1' } }); + const fresh = () => { + const d = mkdtempSync(join(tmpdir(), 'sb-folder-')); + writeFileSync(join(d, 'storebound.config.ts'), "export default { app: { name: 'Example App' }, apple: { bundleId: 'com.example.app' } };\n"); + return { d, reg: join(d, 'reg.json') }; + }; + test('a freshly init-ed folder is added (bare form = cwd), with a template note', () => { + const { d, reg } = fresh(); + const r = cli(d, reg, 'apps', 'folders', 'add'); + expect(r.status).toBe(0); + expect(readRegistry(reg).projects.map(p => p.path)).toEqual([readRegistry(reg).projects[0]!.path]); + expect(readRegistry(reg).projects[0]!.path.endsWith(d.split('/').pop()!)).toBe(true); + expect(r.stderr + r.stdout).toContain("'Example App'"); + }); + test('with a path, like apps add', () => { + const { d, reg } = fresh(); + expect(cli(tmpdir(), reg, 'apps', 'folders', 'add', d).status).toBe(0); + expect(JSON.parse(readFileSync(reg, 'utf8')).projects).toHaveLength(1); + }); + test('no config: refused with the reason and the fix', () => { + const d = mkdtempSync(join(tmpdir(), 'sb-empty-')); + const r = cli(d, join(d, 'reg.json'), 'apps', 'folders', 'add'); + expect(r.status).not.toBe(0); + expect(r.stderr).toContain('no storebound config'); + expect(r.stderr).toContain('storebound init'); + }); +}); diff --git a/tests/wave1.test.ts b/tests/wave1.test.ts index 7ccda59..21faa86 100644 --- a/tests/wave1.test.ts +++ b/tests/wave1.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from 'bun:test'; import { mkdtempSync, readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { generate, parseSkill, readSkills } from '../tools/gen-adapters'; +import { generate, MCP_LAUNCH, parseSkill, readSkills } from '../tools/gen-adapters'; import { scan } from '../tools/scrub'; import { FORBIDDEN_ARGS, listTools, LOCAL_WRITE_TOOLS, TOOLS, toArgv, validateInput } from '../cli/mcp-tools'; import { handle, LEGACY_PROTOCOL as PROTOCOL } from '../cli/mcp'; @@ -38,7 +38,7 @@ describe('adapters', () => { const version = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version; for (const f of ['.claude-plugin/plugin.json', '.codex-plugin/plugin.json', 'gemini-extension.json']) expect(JSON.parse(out.get(f)!).version).toBe(version); expect(JSON.parse(out.get('.codex-plugin/plugin.json')!).skills).toBe('./skills/'); - expect(JSON.parse(out.get('.mcp.json')!).mcpServers.storebound).toEqual({ command: '${CLAUDE_PLUGIN_ROOT}/bin/storebound', args: ['mcp'] }); + expect(JSON.parse(out.get('.mcp.json')!).mcpServers.storebound).toEqual({ command: 'sh', args: ['-c', MCP_LAUNCH] }); expect(JSON.parse(out.get('gemini-extension.json')!).mcpServers.storebound.command).toContain('${extensionPath}'); expect(out.get('.cursor/rules/storebound.mdc')!).toMatch(/^---\ndescription: .+\nalwaysApply: false\n---\n/); }); diff --git a/tools/gen-adapters.ts b/tools/gen-adapters.ts index cba4d50..5df1481 100644 --- a/tools/gen-adapters.ts +++ b/tools/gen-adapters.ts @@ -40,6 +40,10 @@ const json = (o: unknown) => JSON.stringify(o, null, 2) + '\n'; const short = (d: string) => d.split(' - ')[0]!.split('. Use when')[0]!.replace(/\.$/, ''); /** Every generated file: path -> content. Pure apart from reading the sources. */ +/** The plugin's MCP start: the plugin's launcher, else storebound on PATH, else the npm package. */ +export const MCP_LAUNCH = 'r="${CLAUDE_PLUGIN_ROOT:-}"; if [ -n "$r" ] && [ -x "$r/bin/storebound" ]; then exec "$r/bin/storebound" mcp; fi; ' + + 'if command -v storebound >/dev/null 2>&1; then exec storebound mcp; fi; exec npx -y storebound@latest mcp'; + export function generate(root: string): Map { const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { version: string; description: string; license: string; keywords: string[] }; const core = readFileSync(join(root, 'docs', 'agents-core.md'), 'utf8').trim(); @@ -53,7 +57,9 @@ export function generate(root: string): Map { // 1. Claude Code plugin + its MCP server + marketplace out.set('.claude-plugin/plugin.json', json({ name: 'storebound', version: pkg.version, description, author: { name: 'CRTkafa' }, homepage, repository, license: pkg.license, keywords })); - out.set('.mcp.json', json({ mcpServers: { storebound: { command: '${CLAUDE_PLUGIN_ROOT}/bin/storebound', args: ['mcp'] } } })); + // through sh, never a direct path: a plugin root that is gone (an old cache folder after an update, or the repo opened + // as a project where CLAUDE_PLUGIN_ROOT is unset) made the client's spawn fail with ENOENT (pitfall: MCP spawn ENOENT) + out.set('.mcp.json', json({ mcpServers: { storebound: { command: 'sh', args: ['-c', MCP_LAUNCH] } } })); out.set('.claude-plugin/marketplace.json', json({ name: 'crtkafa', description: 'CRTkafa\'s open-sourced tools.', owner: { name: 'CRTkafa' }, plugins: [{ name: 'storebound', source: '.', description: 'Take a mobile app or game from nothing to Submit for review on both stores: skills for every store step and questionnaire plus the storebound CLI and MCP server.', version: pkg.version, category: 'development', tags: ['mobile', 'google-play', 'app-store', 'release'] }] }));