From afd90d9995cd815c11dab1c1c9ddced361f7e31a Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Thu, 10 Sep 2026 00:09:41 -0400 Subject: [PATCH 1/2] feat: scope committed settings to each app --- packages/cache/README.md | 3 +- packages/metro/README.md | 3 +- .../__tests__/shared-cache-stores.test.ts | 14 ++-- packages/metro/index.ts | 44 ++++-------- packages/stim-cli/README.md | 4 +- .../stim-cli/src/__tests__/doctor.test.ts | 7 +- .../stim-cli/src/__tests__/settings.test.ts | 71 ++++++++++++++++--- packages/stim-cli/src/commands/android.ts | 4 +- packages/stim-cli/src/commands/ios.ts | 2 +- packages/stim-cli/src/doctor.ts | 2 +- packages/stim-cli/src/guide/agent.ts | 3 + packages/stim-cli/src/guide/settings.ts | 36 ++++++---- packages/stim-cli/src/settings.ts | 18 ++--- website/docs/build-optimizations.md | 2 +- website/docs/settings.md | 15 ++-- 15 files changed, 140 insertions(+), 88 deletions(-) diff --git a/packages/cache/README.md b/packages/cache/README.md index 3c424f7b..6c5f93bc 100644 --- a/packages/cache/README.md +++ b/packages/cache/README.md @@ -23,7 +23,8 @@ If Stim is not installed globally, replace `stim` with `npx stim`. ``` The reference is a package name or a path relative to the settings file that -declares it. Machine settings override committed `.stim.json` settings, and the +declares it. Commit `.stim.json` beside the app's `package.json`; monorepo apps do +not inherit an ancestor's provider. Machine settings override committed settings, and the existing nested merge rules apply to `cache.options`. Keep secrets out of committed settings; read them from the environment or from machine settings. diff --git a/packages/metro/README.md b/packages/metro/README.md index ea2d799f..a742a1de 100644 --- a/packages/metro/README.md +++ b/packages/metro/README.md @@ -36,7 +36,8 @@ blocking Metro. Provider failures are misses. ``` Under `stim start` the supervisor passes the resolved selection to Metro. A -Metro process outside Stim reads the nearest committed `.stim.json`. See +Metro process outside Stim reads `.stim.json` only from its app working directory; +it does not inherit a monorepo-root provider. See [`@stim-cli/cache`](https://www.npmjs.com/package/@stim-cli/cache) for the provider contract. `clear()` only clears the local tier. diff --git a/packages/metro/__tests__/shared-cache-stores.test.ts b/packages/metro/__tests__/shared-cache-stores.test.ts index 3b6d27a3..155e74d0 100644 --- a/packages/metro/__tests__/shared-cache-stores.test.ts +++ b/packages/metro/__tests__/shared-cache-stores.test.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; import { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -24,7 +24,7 @@ let projectRoot: string; beforeEach(() => { home = mkdtempSync(join(tmpdir(), 'stim-metro-home-')); cacheDir = mkdtempSync(join(tmpdir(), 'stim-metro-cache-')); - projectRoot = mkdtempSync(join(tmpdir(), 'stim-metro-project-')); + projectRoot = realpathSync(mkdtempSync(join(tmpdir(), 'stim-metro-project-'))); process.env.STIM_HOME = home; process.env.STIM_METRO_CACHE = cacheDir; }); @@ -110,12 +110,12 @@ test('the supervisor environment adds one tiered store on the same root', async expect(remote.get('ff'.repeat(16))).toEqual(Buffer.from('fresh')); }); -test('Metro running outside Stim reads the nearest committed provider', async () => { +test('Metro running outside Stim reads only the app-local committed provider', async () => { const app = join(projectRoot, 'apps', 'mobile'); mkdirSync(app, { recursive: true }); mkdirSync(join(projectRoot, '.git'), { recursive: true }); writeFileSync( - join(projectRoot, '.stim.json'), + join(app, '.stim.json'), JSON.stringify({ cache: { provider: './tools/cache.cjs', options: { bucket: 'team' } } }), ); const seen: Array<{ projectRoot: string; config: CacheProviderConfig }> = []; @@ -134,7 +134,7 @@ test('Metro running outside Stim reads the nearest committed provider', async () expect(seen).toEqual([ { projectRoot: app, - config: { provider: './tools/cache.cjs', options: { bucket: 'team' }, baseDir: projectRoot }, + config: { provider: './tools/cache.cjs', options: { bucket: 'team' }, baseDir: app }, }, ]); }); @@ -170,12 +170,12 @@ test('the built-in filesystem store satisfies the provider contract', async () = expect(results.filter((result) => !result.passed)).toEqual([]); }); -test('the committed search stops at the repository root', async () => { +test('a monorepo app does not inherit the repository root provider', async () => { const repo = join(projectRoot, 'repo'); const app = join(repo, 'apps', 'mobile'); mkdirSync(app, { recursive: true }); mkdirSync(join(repo, '.git'), { recursive: true }); - writeFileSync(join(projectRoot, '.stim.json'), JSON.stringify({ cache: { provider: './outside-the-repo.cjs' } })); + writeFileSync(join(repo, '.stim.json'), JSON.stringify({ cache: { provider: './root-provider.cjs' } })); const seen: unknown[] = []; const stores = sharedCacheStores('demo', { diff --git a/packages/metro/index.ts b/packages/metro/index.ts index 662ca535..1bf64449 100644 --- a/packages/metro/index.ts +++ b/packages/metro/index.ts @@ -91,40 +91,20 @@ function isPlainObject(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value); } -function repositoryRoot(startDir: string): string | null { - let dir = startDir; - for (;;) { - if (fs.existsSync(path.join(dir, '.git'))) return dir; - const parent = path.dirname(dir); - if (parent === dir) return null; - dir = parent; - } -} - function committedProviderConfig(startDir: string): CacheProviderConfig | null { - const start = path.resolve(startDir); - const stop = repositoryRoot(start) ?? start; - let dir = start; - for (;;) { - let parsed: unknown; - try { - parsed = JSON.parse(fs.readFileSync(path.join(dir, '.stim.json'), 'utf-8')); - } catch { - parsed = null; - } - const cache = isPlainObject(parsed) && isPlainObject(parsed.cache) ? parsed.cache : null; - const reference = cache?.provider; - if (typeof reference === 'string' && reference.trim() !== '') { - return { - provider: reference.trim(), - options: isPlainObject(cache?.options) ? cache.options : {}, - baseDir: dir, - }; - } - const parent = path.dirname(dir); - if (dir === stop || parent === dir) return null; - dir = parent; + let dir: string; + let parsed: unknown; + try { + dir = fs.realpathSync(startDir); + parsed = JSON.parse(fs.readFileSync(path.join(dir, '.stim.json'), 'utf-8')); + } catch { + return null; } + const cache = isPlainObject(parsed) && isPlainObject(parsed.cache) ? parsed.cache : null; + const reference = cache?.provider; + return typeof reference === 'string' && reference.trim() !== '' + ? { provider: reference.trim(), options: isPlainObject(cache?.options) ? cache.options : {}, baseDir: dir } + : null; } function warnToStderr(_code: string, message: string): void { diff --git a/packages/stim-cli/README.md b/packages/stim-cli/README.md index 38003c82..8ed5bb9e 100644 --- a/packages/stim-cli/README.md +++ b/packages/stim-cli/README.md @@ -27,7 +27,9 @@ Node 20.19.4 or later on Node 20, or Node 22.12.0 or later, is required. Machine defaults in `~/.stim/config.json` can enable or disable native artifact caching, remote caches, Metro sharing, iOS compiler caching and prefix mapping, and Android ccache/CAS, PCH, Gradle caching, and target ABI narrowing. Optional -`.stim.json` overrides apply per repository. Run `stim guide settings` for the +`.stim.json` runtime overrides apply per app, beside its `package.json`; monorepo +apps do not inherit the repository-root file. Worktree-copy rules stay at the +repository root. Run `stim guide settings` for the `optimizations` schema; existing defaults remain unchanged. ## Normal workflow diff --git a/packages/stim-cli/src/__tests__/doctor.test.ts b/packages/stim-cli/src/__tests__/doctor.test.ts index f009e945..ac26ce6a 100644 --- a/packages/stim-cli/src/__tests__/doctor.test.ts +++ b/packages/stim-cli/src/__tests__/doctor.test.ts @@ -1393,14 +1393,15 @@ test('runDoctor checks one shared backend once', () => { } }); -test('runDoctor resolves a SimSlim profile from the repository root in a monorepo', () => { +test('runDoctor resolves the app-local SimSlim profile and ignores a monorepo root profile', () => { const repo = mkdtempSync(join(tmpdir(), 'stim-doc-monorepo-')); const project = join(repo, 'apps', 'mobile'); try { mkdirSync(project, { recursive: true }); writeFileSync(join(project, 'package.json'), JSON.stringify({ name: 'mobile' })); - writeFileSync(join(repo, 'simslim.json'), '{}\n'); - writeFileSync(join(repo, '.stim.json'), JSON.stringify({ ios: { simslimProfile: 'simslim.json' } })); + writeFileSync(join(project, 'simslim.json'), '{}\n'); + writeFileSync(join(repo, '.stim.json'), JSON.stringify({ ios: { simslimProfile: 'missing.json' } })); + writeFileSync(join(project, '.stim.json'), JSON.stringify({ ios: { simslimProfile: 'simslim.json' } })); execSync('git init -q', { cwd: repo }); const findings = runDoctor(project, { diff --git a/packages/stim-cli/src/__tests__/settings.test.ts b/packages/stim-cli/src/__tests__/settings.test.ts index 3f679fe3..5bf185cd 100644 --- a/packages/stim-cli/src/__tests__/settings.test.ts +++ b/packages/stim-cli/src/__tests__/settings.test.ts @@ -1,5 +1,5 @@ import assert from 'node:assert'; -import { mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'fs'; +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; import { @@ -33,6 +33,7 @@ import { } from '../settings.ts'; import { resolveOptimizations, resolveMetroSharedCache } from '../optimizations.ts'; import { saveConfig, setProjectSetting, setRepoSetting, upsertProject } from '../config.ts'; +import { findProjectRoot } from '../project.ts'; type SettingsView = { caches?: string[]; @@ -106,11 +107,11 @@ test('resolveSettings orders project over repo over committed', () => { JSON.stringify({ ios: { deviceType: 'iPhone 17' }, worktree: { exclude: ['.env'] } }), ); setRepoSetting('/repo/.git', 'ios.deviceType', 'iPhone 17 Pro'); - upsertProject('/proj', {}); - setProjectSetting('/proj', 'ios.deviceType', 'iPhone 17 Pro Max'); + upsertProject(tmpHome, {}); + setProjectSetting(tmpHome, 'ios.deviceType', 'iPhone 17 Pro Max'); const merged = resolveSettings({ - projectPath: '/proj', + projectPath: tmpHome, gitCommonDir: '/repo/.git', repoRoot: tmpHome, }) as SettingsView; @@ -119,6 +120,58 @@ test('resolveSettings orders project over repo over committed', () => { expect(merged.worktree.exclude).toEqual(['.env']); }); +test('monorepo apps use their own committed settings and provider paths without inheriting the root file', () => { + const repo = realpathSync(tmpHome); + const first = join(repo, 'apps', 'first'); + const second = join(repo, 'apps', 'second'); + mkdirSync(first, { recursive: true }); + mkdirSync(second, { recursive: true }); + writeFileSync(join(first, 'package.json'), JSON.stringify({ name: 'first' })); + writeFileSync(join(second, 'package.json'), JSON.stringify({ name: 'second' })); + writeFileSync( + join(repo, '.stim.json'), + JSON.stringify({ + ios: { configuration: 'Release' }, + worktree: { exclude: ['.env'] }, + cache: { provider: './root.cjs' }, + }), + ); + writeFileSync( + join(first, '.stim.json'), + JSON.stringify({ ios: { configuration: 'Debug' }, cache: { provider: './first.cjs' } }), + ); + writeFileSync( + join(second, '.stim.json'), + JSON.stringify({ android: { variant: 'demoDebug' }, cache: { provider: './second.cjs' } }), + ); + const context = (app: string) => ({ projectPath: app, repoRoot: repo, gitCommonDir: join(repo, '.git') }); + expect(resolveSettings(context(first))).toEqual({ + ios: { configuration: 'Debug' }, + cache: { provider: './first.cjs' }, + }); + expect(resolveSettings(context(second))).toEqual({ + android: { variant: 'demoDebug' }, + cache: { provider: './second.cjs' }, + }); + expect(resolveCacheProviderConfig(context(first))).toEqual({ provider: './first.cjs', options: {}, baseDir: first }); + expect(resolveCacheProviderConfig(context(second))).toEqual({ + provider: './second.cjs', + options: {}, + baseDir: second, + }); + rmSync(join(second, '.stim.json')); + expect(resolveSettings(context(second))).toEqual({}); + expect(resolveCacheProviderConfig(context(second))).toBeNull(); + expect(resolveSettings({ repoRoot: repo, gitCommonDir: join(repo, '.git') }).worktree).toEqual({ exclude: ['.env'] }); + const alias = join(repo, 'alias'); + symlinkSync(first, alias, 'dir'); + upsertProject(first, {}); + setProjectSetting(first, 'ios.runtime', '26.5'); + const aliasedApp = findProjectRoot(alias); + expect(aliasedApp).toBe(first); + expect(resolveSettings(context(aliasedApp!))).toEqual(resolveSettings(context(first))); +}); + test('unknownSettingKeys reports keys Stim no longer reads', () => { expect(unknownSettingKeys({ packageManager: 'pnpm' })).toEqual(['packageManager']); expect(unknownSettingKeys({ worktree: { install: ['pnpm i'] } })).toEqual(['worktree.install']); @@ -507,7 +560,7 @@ test('a committed provider resolves from the directory holding .stim.json', () = ); upsertProject('/proj', {}); - expect(resolveCacheProviderConfig({ projectPath: '/proj', gitCommonDir: '/repo/.git', repoRoot: tmpHome })).toEqual({ + expect(resolveCacheProviderConfig({ projectPath: tmpHome, gitCommonDir: '/repo/.git', repoRoot: '/repo' })).toEqual({ provider: './tools/cache-provider.cjs', options: { bucket: 'mobile' }, baseDir: tmpHome, @@ -545,10 +598,10 @@ test('provider options merge across layers with earlier layers winning', () => { JSON.stringify({ cache: { provider: './committed.cjs', options: { bucket: 'team', region: 'us' } } }), ); setRepoSetting('/repo/.git', 'cache', { options: { region: 'eu' } }); - upsertProject('/proj', {}); - setProjectSetting('/proj', 'cache', { options: { token: 'from-machine' } }); + upsertProject(tmpHome, {}); + setProjectSetting(tmpHome, 'cache', { options: { token: 'from-machine' } }); - expect(resolveCacheProviderConfig({ projectPath: '/proj', gitCommonDir: '/repo/.git', repoRoot: tmpHome })).toEqual({ + expect(resolveCacheProviderConfig({ projectPath: tmpHome, gitCommonDir: '/repo/.git', repoRoot: '/repo' })).toEqual({ provider: './committed.cjs', options: { token: 'from-machine', region: 'eu', bucket: 'team' }, baseDir: tmpHome, @@ -559,7 +612,7 @@ test('an invalid provider reference reports no provider and names the error', () writeFileSync(join(tmpHome, '.stim.json'), JSON.stringify({ cache: { provider: 42, options: { a: 1 } } })); upsertProject('/proj', {}); - const context = { projectPath: '/proj', gitCommonDir: '/repo/.git', repoRoot: tmpHome }; + const context = { projectPath: tmpHome, gitCommonDir: '/repo/.git', repoRoot: '/repo' }; expect(resolveCacheProviderConfig(context)).toBeNull(); expect(cacheProviderSettingError(resolveSettings(context))).toBe( 'Invalid cache.provider setting 42. Expected a module path or package name.', diff --git a/packages/stim-cli/src/commands/android.ts b/packages/stim-cli/src/commands/android.ts index 4818b287..7e91a394 100644 --- a/packages/stim-cli/src/commands/android.ts +++ b/packages/stim-cli/src/commands/android.ts @@ -709,7 +709,7 @@ export async function runAndroid(options: RunAndroidOptions = {} as RunAndroidOp }; const settingsRepoRoot = repoRoot(root); - const settingsRoot = settingsRepoRoot ?? root; + const settingsRoot = root; const settingsContext = { projectPath: root, gitCommonDir: gitCommonDir(root), @@ -757,7 +757,7 @@ export async function runAndroid(options: RunAndroidOptions = {} as RunAndroidOp return fail( 'STIM_BAD_ARG', avdConfigError, - 'Use only documented android.avdConfig keys, or an android.avdConfigFile fragment contained by the repository/project settings root.', + 'Use only documented android.avdConfig keys, or an android.avdConfigFile fragment contained by the app directory.', ); } const remoteSettingError = remoteDeviceSettingError(settings); diff --git a/packages/stim-cli/src/commands/ios.ts b/packages/stim-cli/src/commands/ios.ts index 0061a30a..66e8b993 100644 --- a/packages/stim-cli/src/commands/ios.ts +++ b/packages/stim-cli/src/commands/ios.ts @@ -565,7 +565,7 @@ async function runIos(opts: IosCommandOptions = {}, overrides: Partial platform: PLATFORM, project: proj, projectPath: root, - settingsRoot: settingsRepoRoot ?? root, + settingsRoot: root, label, settings, flags: { deviceType, runtime }, diff --git a/packages/stim-cli/src/doctor.ts b/packages/stim-cli/src/doctor.ts index 920452ce..17b0f5ca 100644 --- a/packages/stim-cli/src/doctor.ts +++ b/packages/stim-cli/src/doctor.ts @@ -739,7 +739,7 @@ export function runDoctor( let simslimProfileError: string | null = null; if (platform !== 'android') { try { - simslimProfile = iosSimSlimProfileSetting(projectSettings, settingsRepoRoot); + simslimProfile = iosSimSlimProfileSetting(projectSettings, projectRoot); } catch (error) { simslimProfileError = String((error as Error)?.message || error); } diff --git a/packages/stim-cli/src/guide/agent.ts b/packages/stim-cli/src/guide/agent.ts index 50ba6c9d..49bf9a87 100644 --- a/packages/stim-cli/src/guide/agent.ts +++ b/packages/stim-cli/src/guide/agent.ts @@ -75,6 +75,9 @@ RULES DURING THE LOOP react-native or expo. Anywhere else -- a monorepo root, a tools package -- start, ios and android refuse with STIM_NO_PROJECT naming that package.json, and doctor reports it as a finding. +- Put runtime .stim.json beside that app's package.json. Monorepo apps do not + inherit a repository-root runtime file. Keep repository-wide worktree-copy + rules at the main checkout root; see guide settings for the two scopes. - Run start before a debug ios or android build. If it returns STIM_NO_METRO, run stim start and retry. - Run ios or android again after a native input changes. A JavaScript-only diff --git a/packages/stim-cli/src/guide/settings.ts b/packages/stim-cli/src/guide/settings.ts index 3b9ef8e0..d157c12f 100644 --- a/packages/stim-cli/src/guide/settings.ts +++ b/packages/stim-cli/src/guide/settings.ts @@ -10,13 +10,21 @@ hand or committed; command-line selectors override their matching settings. Resolution order, first match wins: 1. project layer ~/.stim/config.json, under this project's entry 2. repo layer ~/.stim/config.json, under this repo's git common dir - 3. committed .stim.json at the repo root <- normally the one you want + 3. committed .stim.json beside the app's package.json 4. machine defaults ~/.stim/config.json, top-level optimizations only 5. Stim default -The committed file is plain JSON and is the only layer that travels with the -repo, so a device model or a carry-over rule every worktree should share -belongs there: +The committed file is plain JSON and travels with the app. Each monorepo app +reads its own file, never an ancestor's runtime settings. A single-app repository +still uses its root file. Existing machine project/repository overrides keep +their precedence. Move root runtime settings into each relevant app when +upgrading; relative profile/config/provider paths resolve from the app directory. + +Worktree copying is repository-wide: worktree warm reads worktree.exclude from +the main checkout's root .stim.json, not from individual apps. Keep that rule at +the repository root; runtime files and worktree-copy policy are separate scopes. + +An app's .stim.json can contain: { "ios": { @@ -25,7 +33,6 @@ belongs there: "simslimProfile": ".simslim/dev.json" }, "android": { "variant": "productionDebug" }, - "worktree": { "baseRef": "head" }, "caches": ["~/.myapp-metro-cache"] } @@ -45,15 +52,15 @@ KEYS STIM READS ios.configuration e.g. "Release" -- the Xcode configuration to build (simulator only). Committing { "ios": { "configuration": "Release" } } makes every - \`stim ios\` in the repo a release-shaped build: + \`stim ios\` in the app a release-shaped build: embedded JS, no Metro, cache keyed -release-sim, and a JS-bundle swap on cache hits. The \`--configuration\` flag overrides this per invocation. Unset means Debug. ios.remote "proxy" or "eas" to use that remote backend, the same as passing \`--remote proxy\` or \`--remote eas\`. The build still runs here; only the device is elsewhere. - ios.simslimProfile a SimSlim JSON profile under the repository root (or - project root outside Git), at most 64 KiB. Install the + ios.simslimProfile a SimSlim JSON profile under the app directory, + at most 64 KiB. Install the external tool once with \`brew install mobai-app/tap/simslim\`. SimSlim requires an iOS 18 or newer simulator. Each local \`stim ios\` @@ -101,12 +108,12 @@ KEYS STIM READS userdata grows but does not shrink. Recreate the environment to adopt a changed value. android.avdConfigFile - path under the repository root (or project root - outside Git) to a flat native key=value INI fragment, + path under the app directory to a flat native + key=value INI fragment, at most 64 KiB. Stim parses it and merges supported values into avdmanager's generated config.ini before first boot; it is never used as a - replacement file. Absolute paths, repository or + replacement file. Absolute paths, app-directory or symlink escapes, malformed or duplicate lines, and unsupported keys are refused before AVD creation. android.avdConfig flat object of the same native keys. It merges key by @@ -174,7 +181,8 @@ ${ANDROID_AVD_CONFIG_HELP.map((line) => ` ${line}`).joi still gated the same way a managed tunnel's is. Set it before Expo start so the manifest advertises it. worktree.exclude ignored-path skip list for worktree warm. Settings - come from the main checkout. A nonempty + come from the main checkout's repository-root .stim.json. + A nonempty .worktreeexclude in main replaces this setting. Registered nested Git worktrees are always skipped. cache.provider one optional SECOND-TIER cache provider: a module @@ -186,8 +194,8 @@ ${ANDROID_AVD_CONFIG_HELP.map((line) => ` ${line}`).joi written after the local write. Failures and timeouts are cache misses, never build or bundle failures. Stim ships no provider and never configures one. - This module is EXECUTABLE CODE that every worktree on - this repository runs; review a committed value the way + This module is EXECUTABLE CODE that every worktree of + this app runs; review a committed value the way you review a build script. \`stim ios\` and \`stim android\` use it unless artifact or remote artifact caching is disabled. Metro diff --git a/packages/stim-cli/src/settings.ts b/packages/stim-cli/src/settings.ts index 15e3f6bc..3955c610 100644 --- a/packages/stim-cli/src/settings.ts +++ b/packages/stim-cli/src/settings.ts @@ -297,9 +297,7 @@ export function androidAvdConfigSetting(settings: unknown, settingsRoot: string) /[\r\n\0]/.test(android.avdConfigFile) || isAbsolute(android.avdConfigFile) ) { - throw new Error( - 'Invalid android.avdConfigFile setting. Expected a relative file path inside the settings root (repository root, or project root outside Git).', - ); + throw new Error('Invalid android.avdConfigFile setting. Expected a relative file path inside the app directory.'); } try { const root = realpathSync(settingsRoot); @@ -351,9 +349,7 @@ export function iosSimSlimProfileSetting(settings: unknown, settingsRoot: string /[\r\n\0]/.test(value) || isAbsolute(value) ) { - throw new Error( - 'Invalid ios.simslimProfile setting. Expected a relative JSON file path inside the settings root (repository root, or project root outside Git).', - ); + throw new Error('Invalid ios.simslimProfile setting. Expected a relative JSON file path inside the app directory.'); } try { const root = realpathSync(settingsRoot); @@ -450,9 +446,9 @@ export function unknownSettingKeys(settings: unknown, prefix = ''): string[] { return unknown; } -export function readCommittedSettings(repoRoot?: string | null): SettingsObject { - if (!repoRoot) return {}; - const p = join(repoRoot, '.stim.json'); +export function readCommittedSettings(directory?: string | null): SettingsObject { + if (!directory) return {}; + const p = join(directory, '.stim.json'); if (!existsSync(p)) return {}; try { const parsed = JSON.parse(readFileSync(p, 'utf-8')); @@ -475,7 +471,7 @@ export function resolveSettings({ return mergeSettingsLayers([ projectPath ? getProjectSettings(projectPath) : null, gitCommonDir ? getRepoSettings(gitCommonDir) : null, - readCommittedSettings(repoRoot), + readCommittedSettings(projectPath ?? repoRoot), machine?.optimizations === undefined ? null : { optimizations: machine.optimizations }, ]); } @@ -518,7 +514,7 @@ export function resolveCacheProviderConfig({ const layers: CacheSettingsLayer[] = [ { settings: projectPath ? getProjectSettings(projectPath) : {}, baseDir: projectPath ?? null }, { settings: gitCommonDir ? getRepoSettings(gitCommonDir) : {}, baseDir: repoRoot ?? projectPath ?? null }, - { settings: readCommittedSettings(repoRoot), baseDir: repoRoot ?? null }, + { settings: readCommittedSettings(projectPath), baseDir: projectPath ?? null }, ]; let provider: string | null = null; diff --git a/website/docs/build-optimizations.md b/website/docs/build-optimizations.md index 0cbde4cc..948c6146 100644 --- a/website/docs/build-optimizations.md +++ b/website/docs/build-optimizations.md @@ -17,7 +17,7 @@ caching. For an overview of the layers, see [build speed and caches](./build-cac Put an `optimizations` object at the top level of `~/.stim/config.json` (or `$STIM_HOME/config.json`) to set machine defaults without changing a project. Merge it into the existing file, preserving project and device records. The same -object in the repository's `.stim.json`, or in repository or project settings, +object in the app's `.stim.json`, or in machine repository or project settings, overrides individual values using the [settings layers](./settings.md#settings-layers). These are the defaults; you only need to include values you want to change: diff --git a/website/docs/settings.md b/website/docs/settings.md index 1e79658c..6bd165b9 100644 --- a/website/docs/settings.md +++ b/website/docs/settings.md @@ -16,7 +16,7 @@ Stim reads the first value found in this order: 1. Project settings in `~/.stim/config.json`, keyed by absolute path. 2. Repository settings in the same machine file, keyed by the git common dir. -3. Committed `.stim.json` at the repository root. +3. Committed `.stim.json` beside the app's `package.json`. 4. Machine defaults under top-level `optimizations` in `~/.stim/config.json` (for optimization settings only). 5. The Stim default. @@ -31,6 +31,12 @@ reports it as a finding instead of refusing. ## Committed settings +Each monorepo app reads its own `.stim.json`; it does not inherit an ancestor's +runtime configuration. Single-app repositories still use their root file. +When upgrading, move runtime settings to each relevant app and make profile, +AVD-fragment and committed-provider paths relative to that app directory. +Explicit machine project/repository overrides keep their existing precedence. + `.stim.json` supports these keys: | Key | Purpose | @@ -60,15 +66,16 @@ reports it as a finding instead of refusing. | `caches` | Additional cache paths reported by `gc` | | `optimizations` | [Build optimization switches and defaults](./build-optimizations.md) | -`worktree warm` reads settings from the main checkout. A nonempty +`worktree warm` reads repository-wide copy settings from the main checkout's +root `.stim.json`, not individual app files. Keep `worktree.exclude` there. A nonempty `.worktreeexclude` in main replaces its resolved `worktree.exclude` setting; an empty or absent file uses the setting. Do not put secrets in a committed `.stim.json`. Keep secrets in ignored files and carry those files into a worktree. -`cache.provider` names a module that Stim executes in every worktree on the -repository. Review a committed value the way you review a build script, and +`cache.provider` names a module that Stim executes in every worktree of the +app. Review a committed value the way you review a build script, and keep provider credentials in the environment or in machine settings. Stim reads the module for `stim ios` and `stim android`; Metro uses it only when the project's own `metro.config.js` calls `sharedCacheStores()` from From 26f251422c03e107b87eb8cf06eb29ebb7846f8b Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Thu, 10 Sep 2026 00:15:57 -0400 Subject: [PATCH 2/2] docs: clarify app-level build defaults --- packages/stim-cli/src/guide/lifecycle.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/stim-cli/src/guide/lifecycle.ts b/packages/stim-cli/src/guide/lifecycle.ts index 25d197e5..bfeae852 100644 --- a/packages/stim-cli/src/guide/lifecycle.ts +++ b/packages/stim-cli/src/guide/lifecycle.ts @@ -823,7 +823,7 @@ OPT-IN CONCURRENCY LIMITS (UNLIMITED BY DEFAULT) install on a project with product flavors -- \`--variant productionDebug\` runs \`assembleProductionDebug\`, finds the APK in apk/production/debug/ and keys the build cache on the variant. It overrides the android.variant - setting (see \`guide settings\`), which is the repo-level default; unset, + setting (see \`guide settings\`), which is the app-level default; unset, the plain \`assembleDebug\` flow is unchanged. The --json payload's \`variant\` field reports what was built (null for the default). When neither is set and android/app/build.gradle declares more than one @@ -1134,7 +1134,7 @@ THE POOL: WHICH DEVICE AN ID-LESS \`--device\` PICKS \`ios --configuration \` selects the Xcode configuration -- \`--configuration Release\` builds a SIMULATOR Release app with the JS - bundle embedded. It overrides the ios.configuration setting (the repo-level + bundle embedded. It overrides the ios.configuration setting (the app-level default); unset, the Debug flow is unchanged. A non-Debug configuration skips Metro ENTIRELY: no gate, no port wiring, no dev-client deep link (a plain \`simctl launch\`), and the payload says \`metroPort: null\` --