Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/cache/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
3 changes: 2 additions & 1 deletion packages/metro/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
14 changes: 7 additions & 7 deletions packages/metro/__tests__/shared-cache-stores.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
});
Expand Down Expand Up @@ -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 }> = [];
Expand All @@ -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 },
},
]);
});
Expand Down Expand Up @@ -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', {
Expand Down
44 changes: 12 additions & 32 deletions packages/metro/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,40 +91,20 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
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 {
Expand Down
4 changes: 3 additions & 1 deletion packages/stim-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions packages/stim-cli/src/__tests__/doctor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down
71 changes: 62 additions & 9 deletions packages/stim-cli/src/__tests__/settings.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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[];
Expand Down Expand Up @@ -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;
Expand All @@ -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']);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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.',
Expand Down
4 changes: 2 additions & 2 deletions packages/stim-cli/src/commands/android.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion packages/stim-cli/src/commands/ios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -565,7 +565,7 @@ async function runIos(opts: IosCommandOptions = {}, overrides: Partial<IosDeps>
platform: PLATFORM,
project: proj,
projectPath: root,
settingsRoot: settingsRepoRoot ?? root,
settingsRoot: root,
label,
settings,
flags: { deviceType, runtime },
Expand Down
2 changes: 1 addition & 1 deletion packages/stim-cli/src/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
3 changes: 3 additions & 0 deletions packages/stim-cli/src/guide/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions packages/stim-cli/src/guide/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1134,7 +1134,7 @@ THE POOL: WHICH DEVICE AN ID-LESS \`--device\` PICKS

\`ios --configuration <name>\` 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\` --
Expand Down
Loading
Loading