diff --git a/packages/stim-cli/README.md b/packages/stim-cli/README.md index f58c9bad..43182644 100644 --- a/packages/stim-cli/README.md +++ b/packages/stim-cli/README.md @@ -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:`. `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. @@ -489,11 +489,12 @@ 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.*"] @@ -501,6 +502,8 @@ The keys stim-cli reads are `ios.deviceType`, `ios.runtime`, `ios.configuration` } ``` +`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. diff --git a/packages/stim-cli/skill/SKILL.md b/packages/stim-cli/skill/SKILL.md index 265643c8..cecc606b 100644 --- a/packages/stim-cli/skill/SKILL.md +++ b/packages/stim-cli/skill/SKILL.md @@ -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`. diff --git a/packages/stim-cli/src/__tests__/android-command.test.ts b/packages/stim-cli/src/__tests__/android-command.test.ts index 5782eb10..6322660a 100644 --- a/packages/stim-cli/src/__tests__/android-command.test.ts +++ b/packages/stim-cli/src/__tests__/android-command.test.ts @@ -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({ diff --git a/packages/stim-cli/src/__tests__/engine-device.test.ts b/packages/stim-cli/src/__tests__/engine-device.test.ts index 6cf86d45..ee7b1697 100644 --- a/packages/stim-cli/src/__tests__/engine-device.test.ts +++ b/packages/stim-cli/src/__tests__/engine-device.test.ts @@ -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'; @@ -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 { @@ -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'; @@ -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 }); } diff --git a/packages/stim-cli/src/__tests__/guide.test.ts b/packages/stim-cli/src/__tests__/guide.test.ts index 7e22be01..38c8472a 100644 --- a/packages/stim-cli/src/__tests__/guide.test.ts +++ b/packages/stim-cli/src/__tests__/guide.test.ts @@ -279,6 +279,20 @@ test('the settings topic lists exactly the keys settings.js honours', () => { } }); +test('the Android data partition contract is consistent across user guidance', () => { + const settings = renderTopic('settings'); + const cleanup = renderTopic('cleanup'); + assert(settings); + assert(cleanup); + for (const body of [settings, cleanup]) { + expect(body).toMatch(/android\.dataPartitionSizeGb/i); + expect(body).toMatch(/8 GiB[^.]*default|defaults to 8/i); + expect(body).toMatch(/6 through 16384/i); + expect(body).toMatch(/newly created|new owned/i); + expect(body).toMatch(/never resized|does not shrink/i); + } +}); + test('the skill points at the guide command and the topics it advertises', () => { const skill = readFileSync(new URL('../../skill/SKILL.md', import.meta.url), 'utf-8'); expect(skill).toMatch(/stim-cli guide/); diff --git a/packages/stim-cli/src/__tests__/settings.test.ts b/packages/stim-cli/src/__tests__/settings.test.ts index 426b8cef..d9155b28 100644 --- a/packages/stim-cli/src/__tests__/settings.test.ts +++ b/packages/stim-cli/src/__tests__/settings.test.ts @@ -3,6 +3,9 @@ import { mkdtempSync, rmSync, writeFileSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; import { + androidDataPartitionSizeBytes, + androidDataPartitionSizeGbSetting, + androidDataPartitionSizeGbSettingError, mergeSettingsLayers, ngrokUrlSetting, publicUrlSetting, @@ -101,13 +104,40 @@ test('unknownSettingKeys accepts every key that is still honoured', () => { expect( unknownSettingKeys({ ios: { deviceType: 'iPhone 17 Pro', runtime: '26.2', configuration: 'Release' }, - android: { systemImage: 'pkg', variant: 'productionDebug' }, + android: { systemImage: 'pkg', dataPartitionSizeGb: 8, variant: 'productionDebug' }, worktree: { baseRef: 'fresh', include: ['.env'] }, worktreeDir: '/tmp/wt', }), ).toEqual([]); }); +describe('Android data partition size settings', () => { + test('defaults above the emulator minimum and converts GiB to exact bytes', () => { + expect(androidDataPartitionSizeGbSetting({})).toBe(8); + expect(androidDataPartitionSizeBytes(6)).toBe(6 * 1024 ** 3); + }); + + test('accepts an integer override through the emulator ext4 maximum', () => { + expect(androidDataPartitionSizeGbSetting({ android: { dataPartitionSizeGb: 12 } })).toBe(12); + expect(androidDataPartitionSizeGbSettingError({ android: { dataPartitionSizeGb: 16 * 1024 } })).toBeNull(); + }); + + test('uses the ordinary first-layer-wins precedence', () => { + const merged = mergeSettingsLayers([ + { android: { dataPartitionSizeGb: 12 } }, + { android: { dataPartitionSizeGb: 10 } }, + { android: { dataPartitionSizeGb: 8 } }, + ]); + expect(androidDataPartitionSizeGbSetting(merged)).toBe(12); + }); + + test.each([5, 6.5, '8', 16 * 1024 + 1])('rejects invalid value %p', (value) => { + const settings = { android: { dataPartitionSizeGb: value } }; + expect(androidDataPartitionSizeGbSettingError(settings)).toMatch(/integer from 6 through 16384 GiB/); + expect(() => androidDataPartitionSizeGbSetting(settings)).toThrow(/android\.dataPartitionSizeGb/); + }); +}); + describe('remote device settings', () => { test('accepts only the explicit proxy and eas backends', () => { expect(remoteIosSetting({ ios: { remote: 'proxy' } })).toBe('proxy'); diff --git a/packages/stim-cli/src/__tests__/sim-android.test.ts b/packages/stim-cli/src/__tests__/sim-android.test.ts index e08b0c22..936e6886 100644 --- a/packages/stim-cli/src/__tests__/sim-android.test.ts +++ b/packages/stim-cli/src/__tests__/sim-android.test.ts @@ -1,5 +1,15 @@ import assert from 'node:assert'; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'fs'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; import { setExecutor, resetExecutor } from '../exec.ts'; @@ -12,6 +22,7 @@ import { findBuildTool, headlessEmulatorArgs, bootAndroidEmulator, + configureNewOwnedAvdDataPartition, listAvds, parseAvdList, parseAdbDevices, @@ -23,6 +34,7 @@ import { deleteAvd, resolveOwnedAvdSerial, waitForBoot, + withAvdDataPartitionSize, } from '../sim/android.ts'; let tmpHome: string; @@ -222,6 +234,50 @@ test('ownedAvdDirectory returns null when every emulator root is missing and ign ).toBe(null); }); +test('withAvdDataPartitionSize replaces duplicates and preserves unrelated config', () => { + expect( + withAvdDataPartitionSize( + 'hw.cpu.ncore=4\r\ndisk.dataPartition.size=10G\r\ntag.id=google_apis\r\ndisk.dataPartition.size=8G\r\n', + 6 * 1024 ** 3, + ), + ).toBe('hw.cpu.ncore=4\r\ndisk.dataPartition.size=6442450944\r\ntag.id=google_apis\r\n'); +}); + +test('withAvdDataPartitionSize appends a missing value without changing newline termination', () => { + expect(withAvdDataPartitionSize('hw.cpu.ncore=4', 8 * 1024 ** 3)).toBe( + 'hw.cpu.ncore=4\ndisk.dataPartition.size=8589934592', + ); +}); + +test('configureNewOwnedAvdDataPartition atomically writes and verifies config.ini', () => { + const content = join(tmpHome, 'stim-cli-app.avd'); + mkdirSync(content, { recursive: true }); + const config = join(content, 'config.ini'); + writeFileSync(config, 'hw.cpu.ncore=4\ndisk.dataPartition.size=10G\n'); + + expect(configureNewOwnedAvdDataPartition('stim-cli-app', 6, { avdDirectory: () => content })).toBe(config); + expect(readFileSync(config, 'utf8')).toBe('hw.cpu.ncore=4\ndisk.dataPartition.size=6442450944\n'); + expect(readdirSync(content).filter((name) => name.includes('.stim-cli-'))).toEqual([]); +}); + +test('configureNewOwnedAvdDataPartition removes its temporary file when replacement fails', () => { + const content = join(tmpHome, 'stim-cli-app.avd'); + mkdirSync(content, { recursive: true }); + const config = join(content, 'config.ini'); + writeFileSync(config, 'disk.dataPartition.size=10G\n'); + + expect(() => + configureNewOwnedAvdDataPartition('stim-cli-app', 6, { + avdDirectory: () => content, + rename: () => { + throw new Error('rename failed'); + }, + }), + ).toThrow(/rename failed/); + expect(readFileSync(config, 'utf8')).toBe('disk.dataPartition.size=10G\n'); + expect(readdirSync(content).filter((name) => name.includes('.stim-cli-'))).toEqual([]); +}); + test('pickDefaultSystemImage prefers highest api, then google_apis, arm64 only', () => { const images = [ { api: 35, tag: 'default', arch: 'arm64-v8a', pkg: 'system-images;android-35;default;arm64-v8a' }, diff --git a/packages/stim-cli/src/commands/android.ts b/packages/stim-cli/src/commands/android.ts index 499397a0..f26cd07d 100644 --- a/packages/stim-cli/src/commands/android.ts +++ b/packages/stim-cli/src/commands/android.ts @@ -47,6 +47,7 @@ import { } from './ios.ts'; import { REMOTE_DEVICE_BACKENDS, + androidDataPartitionSizeGbSettingError, publicUrlSetting, remoteAndroidSetting, remoteDeviceSettingError, @@ -1402,6 +1403,14 @@ export async function runAndroid(options: RunAndroidOptions = {} as RunAndroidOp gitCommonDir: gitCommonDir(root), repoRoot: repoRoot(root), }); + const dataPartitionSizeError = androidDataPartitionSizeGbSettingError(settings); + if (dataPartitionSizeError) { + return fail( + 'STIM_CLI_BAD_ARG', + dataPartitionSizeError, + 'Set android.dataPartitionSizeGb to a whole number of GiB from 6 through 16384.', + ); + } const remoteSettingError = remoteDeviceSettingError(settings); if (remoteSettingError) { return fail( diff --git a/packages/stim-cli/src/commands/guide.ts b/packages/stim-cli/src/commands/guide.ts index 9f13a13a..8aae2b5e 100644 --- a/packages/stim-cli/src/commands/guide.ts +++ b/packages/stim-cli/src/commands/guide.ts @@ -642,8 +642,9 @@ STIM_CLI_SUPERVISOR_EXITED the full records. Fix that and run \`start\` again; nothing is left running. STIM_CLI_BAD_ARG / STIM_CLI_NO_PROJECT - \`start\` refused before doing anything: an unusable --wait value, an invalid - Metro tunnel setting, or a working directory with no package.json above it. + The command refused before doing anything: an unusable --wait value, an invalid + Metro tunnel setting, an invalid android.dataPartitionSizeGb value, or a + working directory with no package.json above it. These errors are caught before the port is reserved and before anything is spawned, so nothing was started. @@ -1077,6 +1078,14 @@ Those are the only two commands that delete. \`stim-cli stop\` shuts a device DOWN and leaves it assigned, which is what makes returning to a branch cost a boot rather than a create, a provision and a reinstall. +New owned Android AVDs use an 8 GiB data partition by default. This leaves room +for repeated app installs while capping userdata growth below the 10 GiB +setting measured on the selected API 36 profile. Set +\`android.dataPartitionSizeGb\` to a whole number from 6 through 16384 when a +project needs another size. Android userdata grows but does not shrink, so the +setting applies only to a newly created AVD; recreate the environment to adopt +a changed value. + ON THE MAIN CHECKOUT git cannot remove the main working tree, and deleting the source tree is not what anyone meant -- so there, and only there, \`worktree remove\` reclaims @@ -1236,6 +1245,12 @@ KEYS STIM-CLI READS as passing \`--remote proxy\` or \`--remote eas\`. The build still runs here; only the device is elsewhere. android.systemImage e.g. "system-images;android-36;google_apis;arm64-v8a" + android.dataPartitionSizeGb + whole GiB for a newly created owned AVD's data + partition. Defaults to 8; accepts 6 through 16384. + Existing AVDs are never resized because Android + userdata grows but does not shrink. Recreate the + environment to adopt a changed value. android.variant e.g. "productionDebug" -- the gradle variant to assemble and install on a project with product flavors. A repo like tlon-mobile with diff --git a/packages/stim-cli/src/engine/device.ts b/packages/stim-cli/src/engine/device.ts index 753cfe82..b40852ce 100644 --- a/packages/stim-cli/src/engine/device.ts +++ b/packages/stim-cli/src/engine/device.ts @@ -1,9 +1,17 @@ import chalk from 'chalk'; -import { allConsolePortsAndSerials, loadConfig, setDevice, type Config, type ProjectRecord } from '../config.ts'; +import { + allConsolePortsAndSerials, + clearDevice, + loadConfig, + setDevice, + type Config, + type ProjectRecord, +} from '../config.ts'; import { isPidAlive } from '../metro.ts'; import { bootIosSim, createOwnedIosSim, listAllIosSims, listIosDeviceTypes, resolveOwnedIosSim } from '../sim/ios.ts'; import { bootAndroidEmulator, + configureNewOwnedAvdDataPartition, createOwnedAvd, listAdbDevices, listAvds, @@ -12,6 +20,8 @@ import { resolveOwnedAvdSerial, waitForBoot, } from '../sim/android.ts'; +import { androidDataPartitionSizeGbSetting } from '../settings.ts'; +import { teardownOwnedAvd } from '../teardown.ts'; export interface OwnedDeviceRecord { deviceUdid?: string; @@ -21,11 +31,12 @@ export interface OwnedDeviceRecord { avdName?: string; consolePort?: number; serial?: string; + setupIncomplete?: boolean; } interface DeviceSettings { ios?: { deviceType?: string; runtime?: string }; - android?: { systemImage?: string }; + android?: { systemImage?: string; dataPartitionSizeGb?: number }; } interface DeviceFlags { @@ -65,6 +76,8 @@ export async function ensureOwnedDevice({ out = () => {}, logFile = null, alive = isPidAlive, + configureAvd = configureNewOwnedAvdDataPartition, + teardownAvd = teardownOwnedAvd, }: { platform: string; project?: ProjectRecord | null; @@ -74,12 +87,26 @@ export async function ensureOwnedDevice({ flags?: DeviceFlags; note?: Notify; out?: Notify; + configureAvd?: typeof configureNewOwnedAvdDataPartition; + teardownAvd?: typeof teardownOwnedAvd; } & EmulatorLogging): Promise { const record = (project?.platforms?.[platform] as OwnedDeviceRecord | undefined) ?? null; if (platform === 'ios') { return ensureOwnedIosDevice({ record, projectPath, label, settings, flags, note, out }); } - return ensureOwnedAndroidDevice({ record, projectPath, label, settings, flags, note, out, logFile, alive }); + return ensureOwnedAndroidDevice({ + record, + projectPath, + label, + settings, + flags, + note, + out, + logFile, + alive, + configureAvd, + teardownAvd, + }); } function ensureOwnedIosDevice({ @@ -177,6 +204,8 @@ async function ensureOwnedAndroidDevice({ out, logFile, alive, + configureAvd, + teardownAvd, }: { record: OwnedDeviceRecord | null; projectPath: string; @@ -185,7 +214,19 @@ async function ensureOwnedAndroidDevice({ flags: DeviceFlags; note: Notify; out: Notify; + configureAvd: typeof configureNewOwnedAvdDataPartition; + teardownAvd: typeof teardownOwnedAvd; } & EmulatorLogging): Promise { + if (record?.setupIncomplete && record.avdName) { + const cleanup = teardownAvd(record.avdName, { del: true }); + if (cleanup.status === 'failed' || cleanup.status === 'skipped') { + throw new Error( + `Owned AVD ${record.avdName} has incomplete setup and could not be deleted (${cleanup.reason || cleanup.status}). Fix the cause, then retry; stim-cli kept the device record for cleanup.`, + ); + } + clearDevice(projectPath, 'android'); + record = null; + } if (record?.avdName) { if (record.owned) { const resolved = resolveOwnedAvdSerial(record.avdName); @@ -250,8 +291,10 @@ async function ensureOwnedAndroidDevice({ } let created: { avdName: string }; + let fresh = false; try { created = createOwnedAvd(label, { systemImage: flags.systemImage || settings.android?.systemImage }); + fresh = true; } catch (e) { const message = String((e as Error)?.message || e); const avdName = ownedAvdName(label); @@ -263,12 +306,42 @@ async function ensureOwnedAndroidDevice({ { cause: e }, ); } + const current = loadConfig()?.projects?.[projectPath]?.platforms?.android; + if (current?.avdName === avdName) { + const state = current.setupIncomplete ? 'has incomplete setup' : 'was registered'; + throw new Error( + `AVD ${avdName} ${state} by another concurrent stim-cli run. Retry after that run finishes so the recorded device is resolved safely.`, + { cause: e }, + ); + } created = { avdName }; out(chalk.dim(`Recovered existing owned AVD ${avdName} (unrecorded from a prior run)`)); } else { throw e; } } + if (fresh) { + setDevice(projectPath, 'android', { + avdName: created.avdName, + owned: true, + deviceName: created.avdName, + setupIncomplete: true, + }); + try { + configureAvd(created.avdName, androidDataPartitionSizeGbSetting(settings)); + } catch (error) { + const cleanup = teardownAvd(created.avdName, { del: true }); + const kept = cleanup.status === 'failed' || cleanup.status === 'skipped'; + if (!kept) clearDevice(projectPath, 'android'); + const orphan = kept + ? ` The owned AVD remains tracked for cleanup (${cleanup.reason || cleanup.status}); fix the cause, then retry or run \`stim-cli gc --delete\`.` + : ''; + throw new Error( + `Created owned AVD ${created.avdName}, but could not configure its data partition: ${String((error as Error)?.message || error)}${orphan}`, + { cause: error }, + ); + } + } out(chalk.dim(`Created owned AVD ${created.avdName}`)); return bootOwnedAvdOnFreshPort({ avdName: created.avdName, diff --git a/packages/stim-cli/src/settings.ts b/packages/stim-cli/src/settings.ts index e12cc404..3e5b3d2a 100644 --- a/packages/stim-cli/src/settings.ts +++ b/packages/stim-cli/src/settings.ts @@ -30,6 +30,7 @@ const KNOWN_SETTINGS = new Set([ 'ios.configuration', 'ios.remote', 'android.systemImage', + 'android.dataPartitionSizeGb', 'android.variant', 'android.keystore', 'android.keystorePassword', @@ -44,6 +45,48 @@ const KNOWN_SETTINGS = new Set([ 'caches', ]); +export const MIN_ANDROID_DATA_PARTITION_SIZE_GB: number = 6; +export const DEFAULT_ANDROID_DATA_PARTITION_SIZE_GB: number = 8; +export const MAX_ANDROID_DATA_PARTITION_SIZE_GB: number = 16 * 1024; + +function validateAndroidDataPartitionSizeGb(raw: unknown): number { + if ( + typeof raw !== 'number' || + !Number.isSafeInteger(raw) || + raw < MIN_ANDROID_DATA_PARTITION_SIZE_GB || + raw > MAX_ANDROID_DATA_PARTITION_SIZE_GB + ) { + throw new Error( + `Invalid android.dataPartitionSizeGb setting ${JSON.stringify(raw)}. Expected an integer from ${MIN_ANDROID_DATA_PARTITION_SIZE_GB} through ${MAX_ANDROID_DATA_PARTITION_SIZE_GB} GiB.`, + ); + } + return raw; +} + +export function androidDataPartitionSizeBytes(sizeGb: unknown): number { + return validateAndroidDataPartitionSizeGb(sizeGb) * 1024 ** 3; +} + +function androidDataPartitionSizeGbRaw(settings: unknown): unknown { + if (!isPlainObject(settings) || !isPlainObject(settings.android)) return undefined; + return settings.android.dataPartitionSizeGb; +} + +export function androidDataPartitionSizeGbSetting(settings: unknown): number { + const raw = androidDataPartitionSizeGbRaw(settings); + if (raw === undefined) return DEFAULT_ANDROID_DATA_PARTITION_SIZE_GB; + return validateAndroidDataPartitionSizeGb(raw); +} + +export function androidDataPartitionSizeGbSettingError(settings: unknown): string | null { + try { + androidDataPartitionSizeGbSetting(settings); + return null; + } catch (error) { + return String((error as Error)?.message || error); + } +} + export function unknownSettingKeys(settings: unknown, prefix = ''): string[] { if (!isPlainObject(settings)) return []; const unknown: string[] = []; diff --git a/packages/stim-cli/src/sim/android.ts b/packages/stim-cli/src/sim/android.ts index 707ea8ab..f0cca187 100644 --- a/packages/stim-cli/src/sim/android.ts +++ b/packages/stim-cli/src/sim/android.ts @@ -1,7 +1,19 @@ -import { existsSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, statSync } from 'fs'; +import { + existsSync, + mkdirSync, + openSync, + readFileSync, + readdirSync, + realpathSync, + renameSync, + rmSync, + statSync, + writeFileSync, +} from 'fs'; import { homedir } from 'os'; import { dirname, isAbsolute, join, resolve } from 'path'; import { type Executor, getExecutor } from '../exec.ts'; +import { androidDataPartitionSizeBytes } from '../settings.ts'; export interface SystemImage { api: number; @@ -337,6 +349,67 @@ export function ownedAvdDirectory( return null; } +export function withAvdDataPartitionSize(contents: string, sizeBytes: number): string { + const newline = contents.includes('\r\n') ? '\r\n' : '\n'; + const trailingNewline = /\r?\n$/.test(contents); + const lines = contents.split(/\r?\n/); + if (trailingNewline) lines.pop(); + const updated: string[] = []; + let found = false; + for (const line of lines) { + if (/^\s*disk\.dataPartition\.size\s*=/.test(line)) { + if (!found) updated.push(`disk.dataPartition.size=${sizeBytes}`); + found = true; + } else { + updated.push(line); + } + } + if (!found) updated.push(`disk.dataPartition.size=${sizeBytes}`); + return updated.join(newline) + (trailingNewline ? newline : ''); +} + +let avdConfigWriteSequence = 0; + +export function configureNewOwnedAvdDataPartition( + avdName: string, + sizeGb: number, + { + avdDirectory = ownedAvdDirectory, + readFile = (path: string) => readFileSync(path, 'utf8'), + writeFile = (path: string, contents: string) => writeFileSync(path, contents, { encoding: 'utf8', flag: 'wx' }), + rename = renameSync, + remove = (path: string) => rmSync(path, { force: true }), + }: { + avdDirectory?: typeof ownedAvdDirectory; + readFile?: (path: string) => string; + writeFile?: (path: string, contents: string) => void; + rename?: (from: string, to: string) => void; + remove?: (path: string) => void; + } = {}, +): string { + const sizeBytes = androidDataPartitionSizeBytes(sizeGb); + const directory = avdDirectory(avdName); + if (!directory) throw new Error(`Could not resolve the content directory for newly created AVD ${avdName}.`); + const configPath = join(directory, 'config.ini'); + const original = readFile(configPath); + const updated = withAvdDataPartitionSize(original, sizeBytes); + const tempPath = join(directory, `.config.ini.stim-cli-${process.pid}-${++avdConfigWriteSequence}.tmp`); + try { + writeFile(tempPath, updated); + rename(tempPath, configPath); + } catch (error) { + try { + remove(tempPath); + } catch {} + throw error; + } + const verified = readFile(configPath); + if (!verified.split(/\r?\n/).includes(`disk.dataPartition.size=${sizeBytes}`)) { + throw new Error(`Could not verify disk.dataPartition.size in ${configPath}.`); + } + return configPath; +} + export function bootAndroidEmulator( avdName: string, consolePort: number, diff --git a/packages/stim-cli/src/types.ts b/packages/stim-cli/src/types.ts index 51b5360e..c9325a9f 100644 --- a/packages/stim-cli/src/types.ts +++ b/packages/stim-cli/src/types.ts @@ -25,6 +25,7 @@ interface AndroidDeviceRecord { kind?: string; deviceName?: string | null; owned?: boolean; + setupIncomplete?: boolean; [key: string]: unknown; }