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
7 changes: 5 additions & 2 deletions packages/stim-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ All commands below take the same `npx stim-cli` prefix.

- **Config** at `~/.stim-cli/config.json`, keyed by absolute project path. Symlinked worktrees collapse via `realpath`. Every write goes through a lockfile and lands by atomic rename, so several agents provisioning at once cannot lose each other's device records. A config that will not parse is reported by name and never reset automatically -- it holds the records of every device stim-cli owns, and resetting it would orphan all of them.
- **Port allocation:** `start` scans upward from 8082 for a port that is both unclaimed in the registry and actually free on the machine, reclaiming ports from dead projects on the way. Claiming is race-safe: the write only lands if the config still shows the port unclaimed, so two parallel runs that probe the same free port cannot both take it. A project whose directory only _looks_ gone because its volume is unmounted keeps its port.
- **Owned device creation:** on iOS, `ios` creates the newest iPhone device type -- highest generation number, base model rather than Pro/Pro Max -- on the newest installed runtime by default (or reuses the project's already-recorded owned sim, booting it if shut down). On Android, it creates an AVD via `avdmanager create avd` against the newest installed arm64 system image (stim-cli never installs system images itself -- it errors with install instructions if none is found). Owned AVDs always cold-boot: the emulator neither loads nor saves a Quick Boot snapshot, avoiding a large snapshot for a disposable device at the cost of a slower restart after `stop`. Override the defaults with `ios.deviceType` / `ios.runtime` / `android.systemImage` in a settings file -- see "Settings" below.
- **Owned device creation:** on iOS, `ios` creates the newest iPhone device type -- highest generation number, base model rather than Pro/Pro Max -- on the newest installed runtime by default (or reuses the project's already-recorded owned sim, booting it if shut down). On Android, it creates an AVD via `avdmanager create avd` against the newest installed arm64 system image (stim-cli never installs system images itself -- it errors with install instructions if none is found). New owned AVDs get an 8 GiB data partition, retaining headroom for repeated installs while capping userdata growth below the 10 GiB setting measured on the selected API 36 profile. Owned AVDs always cold-boot: the emulator neither loads nor saves a Quick Boot snapshot, avoiding a large snapshot for a disposable device at the cost of a slower restart after `stop`. Override the device defaults with `ios.deviceType` / `ios.runtime` / `android.systemImage` / `android.dataPartitionSizeGb` in a settings file -- see "Settings" below.
- **Runtime output is externalized.** Logs, state, pidfiles and Xcode DerivedData live under the global workspace directory, so `worktree remove` can reclaim them without project-tree state. Gradle still uses its normal project build directories; `--build-cache` points task caching at the shared Gradle user home.
- **The port is never baked into a build.** The fingerprint cache shares binaries across workspaces, so a port compiled in would let a binary built for 8082 be served to a workspace holding 8083. iOS gets `RCT_jsLocation` written into the app's simulator defaults (or an `expo-development-client` deep link). For a dev-client link, stim-cli also writes CoreSimulatorBridge approval entries for exactly that installed bundle id and scheme through the booted owned simulator's `defaults`; an unrelated scheme remains unapproved. Android gets `adb reverse tcp:8081 tcp:<port>`. `RCT_METRO_PORT` is deliberately not passed to builds.
- **Starting the bundler yourself still works.** Both Expo and the RN CLI probe the port and skip spawning a second bundler when one already answers `/status`, and `ios`'s Metro gate accepts a server you started as long as it runs from inside the project -- but nothing is captured that way, so `stim-cli logs` stays empty. Teardown (`stop`, `worktree remove`, `gc`) finds Metro by port via `lsof` and only kills it after confirming it answers `/status` **and** runs from inside the project: a port is not identity, so an unidentified listener is reported instead of killed.
Expand Down Expand Up @@ -489,18 +489,21 @@ generated by the installed binary and so cannot drift.
2. **Repo settings** -- shared by every worktree of the same repository (keyed by the repo's git common dir), also stored in `~/.stim-cli/config.json`. Local to this machine.
3. **Committed settings** -- `.stim-cli.json` at the repo root, checked into git and shared with everyone who clones the repo. Lowest precedence, but the only layer that travels with the repo -- and, with the `config` command gone, normally the one you want.

The keys stim-cli reads are `ios.deviceType`, `ios.runtime`, `ios.configuration`, `android.systemImage`, `android.variant`, `android.keystore`, `android.keystorePassword`, `worktreeDir`, `caches`, and, under `worktree`: `baseRef` (`"fresh"` or `"head"`), `include` (carry-over patterns, same role as `.worktreeinclude`) and `exclude` (the `--carry-ignored` skip list, same role as `.worktreeexclude`). **Anything else is ignored, and stim-cli warns about it by name on every run that resolves settings** -- a `worktree.install` pipeline, for instance, is not a key stim-cli reads. Example `.stim-cli.json`:
The keys stim-cli reads are `ios.deviceType`, `ios.runtime`, `ios.configuration`, `android.systemImage`, `android.dataPartitionSizeGb`, `android.variant`, `android.keystore`, `android.keystorePassword`, `worktreeDir`, `caches`, and, under `worktree`: `baseRef` (`"fresh"` or `"head"`), `include` (carry-over patterns, same role as `.worktreeinclude`) and `exclude` (the `--carry-ignored` skip list, same role as `.worktreeexclude`). **Anything else is ignored, and stim-cli warns about it by name on every run that resolves settings** -- a `worktree.install` pipeline, for instance, is not a key stim-cli reads. Example `.stim-cli.json`:

```json
{
"ios": { "deviceType": "iPhone 17 Pro" },
"android": { "dataPartitionSizeGb": 10 },
"worktree": {
"baseRef": "fresh",
"include": [".env", ".env.*"]
}
}
```

`android.dataPartitionSizeGb` is a whole number of GiB from 6 through 16384; it defaults to 8. It is applied only between creating a fresh owned AVD and its first boot because Android userdata images grow but do not shrink. Changing it leaves an existing AVD untouched; remove that worktree environment or reap the device with `gc --delete`, then let `android` create a replacement.

`android.keystorePassword` accepts apksigner's schemed form (`env:MY_KS_PASS`, `file:/keys/pw.txt`, `stdin`) as well as a bare password, which is how a committed file can name a release keystore without carrying its secret.

**Never put secrets in `.stim-cli.json`.** It's committed to git and readable by anyone with repo access. Secrets belong in gitignored files (`.env` and friends) that `worktree create`'s carry-over feature copies into each new worktree -- that mechanism exists specifically so gitignored, secret-bearing files reach a fresh worktree without ever being committed to `.stim-cli.json` or anywhere else in git history.
Expand Down
2 changes: 1 addition & 1 deletion packages/stim-cli/skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ Permanent local deletion lives in exactly **two** commands. `stop` never deletes

## Capacity

A booted iOS sim is roughly 1-2 GB of RAM, an Android emulator 2-3 GB. Owned Android AVDs neither load nor save Quick Boot snapshots: restarts are full boots, but a disposable workspace device does not retain a large snapshot on disk. On a 16 GB machine plan for **2-3 live environments**, not more. By default nothing enforces this. `npx stim-cli status` reports every workspace on the machine (it is machine-wide by default, not scoped to where you are standing), which is how you find out you would be the fourth. One caveat when counting: a monorepo workspace holds TWO registry entries -- the worktree root (which only reserves the label) and the app directory -- so the root's entry carries `labelOnly: true` in `--json` and is relabelled in the human view; count only the entries without it. Tear down what you're done with before creating more.
A booted iOS sim is roughly 1-2 GB of RAM, an Android emulator 2-3 GB. New owned Android AVDs use an 8 GiB data partition, retaining room for repeated installs while capping userdata growth below the 10 GiB setting measured on the selected API 36 profile; `android.dataPartitionSizeGb` accepts an integer from 6 through 16384 when a project needs a different creation-time size. Existing AVDs are never resized: recreate the environment to adopt a changed value. Owned Android AVDs neither load nor save Quick Boot snapshots, so restarts are full boots but a disposable workspace device does not retain a large snapshot on disk. On a 16 GB machine plan for **2-3 live environments**, not more. By default nothing enforces this. `npx stim-cli status` reports every workspace on the machine (it is machine-wide by default, not scoped to where you are standing), which is how you find out you would be the fourth. One caveat when counting: a monorepo workspace holds TWO registry entries -- the worktree root (which only reserves the label) and the app directory -- so the root's entry carries `labelOnly: true` in `--json` and is relabelled in the human view; count only the entries without it. Tear down what you're done with before creating more.

**Opt-in concurrency limits (unlimited by default).** When a machine cannot host as many parallel builds or devices as there are agents, two machine-level caps can rein it in -- set them under a top-level `concurrency` key in `~/.stim-cli/config.json` (`{ "concurrency": { "maxBuilds": 2, "maxDevices": 3 } }`), or via `STIM_CLI_MAX_BUILDS` / `STIM_CLI_MAX_DEVICES` which override the file. `maxBuilds` caps how many builds **compile** at once (a full slate WAITS -- it is a semaphore taken after the single-flight lock, so a waiter installing another workspace's artifact never burns a slot); `maxDevices` caps how many stim-cli-owned devices are **booted** at once, and a new `ios`/`android` at that cap is **refused** with `STIM_CLI_AT_CAPACITY` (interactive-shaped: it does not queue -- stop an environment or raise the cap). Unset, `0`, or any non-positive value means no enforcement. There is no `stim-cli config` command; these are files and env vars. See `npx stim-cli guide lifecycle` and `guide settings`.

Expand Down
13 changes: 13 additions & 0 deletions packages/stim-cli/src/__tests__/android-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,19 @@ describe('explicit remote backend behavior', () => {
expect(resolved).toBe(false);
});

test('an invalid Android data partition size is refused before device work', async () => {
const h = harness({
resolveSettingsFor: () => ({ android: { dataPartitionSizeGb: 5 } }),
ensureDevice: never('the device'),
});
const result = await h.run();
expect(result.ok).toBe(false);
expect(result.error?.code).toBe('STIM_CLI_BAD_ARG');
expect(result.error?.message).toContain('Invalid android.dataPartitionSizeGb setting');
expect(result.error?.remedy).toContain('whole number of GiB');
expect(h.calls.ensureDevice).toEqual([]);
});

test('the local path does not resolve a remote backend', async () => {
let resolved = false;
const h = harness({
Expand Down
183 changes: 180 additions & 3 deletions packages/stim-cli/src/__tests__/engine-device.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import assert from 'node:assert';
Expand Down Expand Up @@ -513,25 +513,37 @@ describe('ensureOwnedDevice: ios', () => {
describe('ensureOwnedDevice: android', () => {
let androidHome: string;
let prevAndroidHome: string | undefined;
let prevAndroidAvdHome: string | undefined;

beforeEach(() => {
androidHome = mkdtempSync(join(tmpdir(), 'stim-cli-test-sdk-'));
mkdirSync(join(androidHome, 'system-images', 'android-36', 'google_apis', 'arm64-v8a'), { recursive: true });
mkdirSync(join(androidHome, 'system-images', 'android-36', 'google_apis', 'x86_64'), { recursive: true });
prevAndroidHome = process.env.ANDROID_HOME;
prevAndroidAvdHome = process.env.ANDROID_AVD_HOME;
process.env.ANDROID_HOME = androidHome;
process.env.ANDROID_AVD_HOME = join(androidHome, 'avd');
});

afterEach(() => {
rmSync(androidHome, { recursive: true, force: true });
if (prevAndroidHome === undefined) delete process.env.ANDROID_HOME;
else process.env.ANDROID_HOME = prevAndroidHome;
if (prevAndroidAvdHome === undefined) delete process.env.ANDROID_AVD_HOME;
else process.env.ANDROID_AVD_HOME = prevAndroidAvdHome;
});

function androidExecutor({
avds = [],
createAvdError = null,
}: { avds?: string[]; createAvdError?: string | null } = {}) {
writeAvdFiles = true,
beforeCreateAvdError = () => {},
}: {
avds?: string[];
createAvdError?: string | null;
writeAvdFiles?: boolean;
beforeCreateAvdError?: () => void;
} = {}) {
const run: string[] = [];
const spawn: { cmd: string; args: readonly string[]; opts?: object }[] = [];
return {
Expand All @@ -542,9 +554,23 @@ describe('ensureOwnedDevice: android', () => {
run.push(cmd);
if (cmd === 'emulator -list-avds') return avds.length ? `${avds.join('\n')}\n` : '';
if (/create avd/.test(cmd)) {
if (createAvdError) throw new Error(createAvdError);
if (createAvdError) {
beforeCreateAvdError();
throw new Error(createAvdError);
}
const name = / -n "([^"]+)"/.exec(cmd)?.[1];
assert(name);
avds.push(name);
if (writeAvdFiles) {
const root = process.env.ANDROID_AVD_HOME!;
const content = join(root, `${name}.avd`);
mkdirSync(content, { recursive: true });
writeFileSync(join(root, `${name}.ini`), `path=${content}\n`);
writeFileSync(join(content, 'config.ini'), 'hw.cpu.ncore=4\ndisk.dataPartition.size=10G\n');
}
return '';
}
if (/delete avd/.test(cmd)) return '';
if (cmd === 'adb devices') return 'List of devices attached\n';
if (/emu avd name/.test(cmd)) return '';
if (/getprop sys\.boot_completed/.test(cmd)) return '1';
Expand Down Expand Up @@ -588,6 +614,157 @@ describe('ensureOwnedDevice: android', () => {
expect(result.avdName).toBe('stim-cli-app');
expect(result.owned).toBe(true);
expect(notes.some((n) => /no longer supports physical devices/i.test(n))).toBeTruthy();
expect(readFileSync(join(process.env.ANDROID_AVD_HOME!, 'stim-cli-app.avd', 'config.ini'), 'utf8')).toContain(
'disk.dataPartition.size=8589934592',
);
} finally {
rmSync(root, { recursive: true, force: true });
}
});

test('a fresh owned AVD uses the configured integer GiB override', async () => {
const root = projectDir();
try {
const { exec } = androidExecutor();
setExecutor(exec);
await ensureOwnedDevice({
platform: 'android',
project: getProject(root),
projectPath: root,
label: 'app',
settings: { android: { dataPartitionSizeGb: 10 } },
});
expect(readFileSync(join(process.env.ANDROID_AVD_HOME!, 'stim-cli-app.avd', 'config.ini'), 'utf8')).toContain(
'disk.dataPartition.size=10737418240',
);
} finally {
rmSync(root, { recursive: true, force: true });
}
});

test('a failed new-AVD configuration is centrally deleted and never booted', async () => {
const root = projectDir();
try {
const { run, spawn, exec } = androidExecutor({ writeAvdFiles: false });
setExecutor(exec);
await expect(
ensureOwnedDevice({
platform: 'android',
project: getProject(root),
projectPath: root,
label: 'app',
settings: {},
}),
).rejects.toThrow(/could not configure its data partition/i);
expect(run.some((cmd) => /delete avd -n "stim-cli-app"/.test(cmd))).toBe(true);
expect(spawn).toEqual([]);
expect(getProject(root)?.platforms?.android).toBeUndefined();
} finally {
rmSync(root, { recursive: true, force: true });
}
});

test('a failed configuration rollback stays tracked and cannot be recovered or booted', async () => {
const root = projectDir();
try {
const { run, spawn, exec } = androidExecutor();
setExecutor(exec);
const configureAvd = () => {
throw new Error('EEXIST: file already exists');
};
const teardownAvd = () => ({ status: 'failed' as const, reason: 'delete failed' });
await expect(
ensureOwnedDevice({
platform: 'android',
project: getProject(root),
projectPath: root,
label: 'app',
settings: {},
configureAvd,
teardownAvd,
}),
).rejects.toThrow(/could not configure.*already exists.*tracked for cleanup/i);
expect(getProject(root)?.platforms?.android).toMatchObject({
avdName: 'stim-cli-app',
owned: true,
setupIncomplete: true,
});
expect(spawn).toEqual([]);
expect(run.filter((cmd) => /create avd/.test(cmd))).toHaveLength(1);

await expect(
ensureOwnedDevice({
platform: 'android',
project: getProject(root),
projectPath: root,
label: 'app',
settings: {},
configureAvd,
teardownAvd,
}),
).rejects.toThrow(/incomplete setup.*could not be deleted/i);
expect(spawn).toEqual([]);
expect(run.filter((cmd) => /create avd/.test(cmd))).toHaveLength(1);
} finally {
rmSync(root, { recursive: true, force: true });
}
});

test('an unrecorded existing owned AVD is recovered without resizing it', async () => {
const root = projectDir();
const avdRoot = process.env.ANDROID_AVD_HOME!;
const content = join(avdRoot, 'stim-cli-app.avd');
mkdirSync(content, { recursive: true });
writeFileSync(join(avdRoot, 'stim-cli-app.ini'), `path=${content}\n`);
writeFileSync(join(content, 'config.ini'), 'disk.dataPartition.size=10G\n');
try {
const { exec } = androidExecutor({
avds: ['stim-cli-app'],
createAvdError: 'Error: AVD stim-cli-app already exists.',
});
setExecutor(exec);
await ensureOwnedDevice({
platform: 'android',
project: getProject(root),
projectPath: root,
label: 'app',
settings: { android: { dataPartitionSizeGb: 6 } },
configureAvd: () => {
throw new Error('must not configure a recovered AVD');
},
});
expect(readFileSync(join(content, 'config.ini'), 'utf8')).toBe('disk.dataPartition.size=10G\n');
} finally {
rmSync(root, { recursive: true, force: true });
}
});

test('a stale project snapshot cannot recover an AVD another concurrent run just recorded', async () => {
const root = projectDir();
try {
const staleProject = getProject(root);
const { spawn, exec } = androidExecutor({
avds: ['stim-cli-app'],
createAvdError: 'Error: AVD stim-cli-app already exists.',
beforeCreateAvdError: () => {
setDevice(root, 'android', { avdName: 'stim-cli-app', owned: true, setupIncomplete: true });
},
});
setExecutor(exec);
await expect(
ensureOwnedDevice({
platform: 'android',
project: staleProject,
projectPath: root,
label: 'app',
settings: {},
}),
).rejects.toThrow(/incomplete setup.*concurrent stim-cli run/i);
expect(spawn).toEqual([]);
expect(getProject(root)?.platforms?.android).toMatchObject({
avdName: 'stim-cli-app',
setupIncomplete: true,
});
} finally {
rmSync(root, { recursive: true, force: true });
}
Expand Down
Loading
Loading