@stim-cli/ci 1.34.0 → 1.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # @stim-cli/ci
2
2
 
3
- Build and launch an app through Stim, run your test command, retain diagnostics,
4
- and stop the workspace. The library imports the public API from `stim`; it does
5
- not depend on GitHub Actions, EAS, or internal Stim modules.
3
+ Build an app once, then install and launch it through Stim, run your test
4
+ command and stop the workspace. The library imports the public API from `stim`;
5
+ it does not depend on GitHub Actions, EAS, or internal Stim modules.
6
6
 
7
7
  Requires Node.js 22.12 or newer and the native tools for the selected platform.
8
8
  Install the app's dependencies before running it. Each concurrent CI run needs
@@ -10,50 +10,62 @@ its own checkout. The run stops that checkout's workspace, including resources
10
10
  started before a build failure; an explicit API `run.slot` limits cleanup to
11
11
  that slot. Do not point CI at a developer's active checkout.
12
12
 
13
- ## Build only
13
+ ## Build, then run
14
+
15
+ `stim-ci build` is the only step that compiles. `stim-ci run` never compiles:
14
16
 
15
17
  ```sh
16
- npx --yes --package @stim-cli/ci stim-ci build --platform ios --project ./app --artifacts ./build-results
18
+ npx --yes --package @stim-cli/ci stim-ci build --platform ios --project ./app
19
+ npx --yes --package @stim-cli/ci stim-ci run --platform ios --project ./app -- pnpm test:e2e
17
20
  ```
18
21
 
19
- This compiles or restores an artifact without starting a simulator, emulator,
20
- Metro server, or app. It does not stop an existing workspace session. iOS builds
21
- target the simulator; archives, device distribution and web compilation are
22
- not supported. By default, iOS and Android builds use the same native artifact
23
- cache as `run`. A later `run` in the same job validates that cache normally; its
24
- `run.json` reports `cacheKey` and `cacheHit`, which show whether it reused the
25
- build. Android reuse requires the run's emulator system image to have the host's
26
- ABI; a configured `android.systemImage` with another ABI, or a physical device,
27
- misses. An iOS Debug build with `--arch all` is keyed without an architecture,
28
- so a later Debug `run` misses. macOS builds are not cached, so a later macOS
29
- `run` builds again.
30
-
31
- The result has `stage: "build"`, `build`, `buildPath`, and `artifactPath`.
32
- The results directory contains `build.json`, `result.json`, `diagnostics.json`,
33
- and `app.apk` for Android or `app.tar.gz` for iOS/macOS. iOS and macOS also
34
- keep the archive command's output in `artifact.stdout.log` and
35
- `artifact.stderr.log`. The archive preserves app
36
- executable permissions and symlinks when uploaded through an artifact service.
37
- Extract it with `tar -xzf app.tar.gz`. Importing a downloaded artifact into a
38
- Stim run is not part of this command.
39
-
40
- The library exports `buildCI({ projectRoot, build: { platform: "ios" }, ... })`.
41
- It accepts the same home/cache, timeout, cancellation and progress options as
42
- `runCI`. Build cancellation waits for the owned build operation to finish
43
- cleanup and retains diagnostics; it never calls workspace `stop()`. A cancel or timeout after the artifact is exported does not fail the build
44
- stage.
22
+ `build` compiles or restores the app from the native artifact cache, without
23
+ starting a simulator, emulator, Metro server, or app, and without stopping an
24
+ existing workspace session. It records the resulting app in the checkout's Stim
25
+ state (the workspace state under the Stim home) for the platform and selectors:
26
+ iOS scheme and configuration, Android variant. A later `build` with the same
27
+ selectors replaces the record. Architecture and ABI are build-only; the last build
28
+ for the selectors wins whatever its architecture.
29
+
30
+ `run` installs and launches the recorded app exactly as built, then runs the
31
+ test. It does not fingerprint, consult the cache, build or replace the app's JS:
32
+ it trusts the recorded build, so rebuild after changing the project. A Debug app
33
+ loads its JS from Metro, which `run` starts as before. With no recorded build for
34
+ the selectors (or after its app was removed), `run` refuses before touching a
35
+ device with `STIM_NO_RECORDED_BUILD`, and the failure's `remedy` names the command
36
+ to run first, such as `stim-ci build --platform ios --scheme App`. `build` and
37
+ `run` must use the same Stim home: pass the same `--home`, or keep the default.
38
+
39
+ `--eas-profile <name>` (iOS and Android, build only) downloads a completed EAS
40
+ development build instead of compiling, as `stim ios --eas-profile` does; `run`
41
+ then installs it. Stim never starts an EAS build.
42
+
43
+ macOS and web are unchanged: `run --platform macos` builds its app itself and web
44
+ has no native build. iOS builds target the simulator; archives, device
45
+ distribution and web compilation are not supported.
46
+
47
+ `build` exports nothing by default. `--export <path>` also writes the app there:
48
+ `app.apk` for Android, a tar.gz for iOS and macOS that preserves executable
49
+ permissions and symlinks (extract with `tar -xzf`). On a cache hit the archive is
50
+ the slowest part of `build`, so export only when something consumes the file.
51
+
52
+ The build result has `stage: "build"`, `build`, `buildPath`, and `artifactPath`
53
+ (the export, or null). With `--artifacts` the directory also contains
54
+ `build.json`, `result.json` and `diagnostics.json`.
55
+
56
+ The library exports `buildCI({ projectRoot, build: { platform: "ios" }, exportPath, ... })`
57
+ and `runCI`. `runCI`'s `run` option takes the selectors of the build to run; build-only
58
+ options such as `easProfile` belong to `buildCI`. Build cancellation waits for the
59
+ owned build operation to finish cleanup and retains diagnostics; it never calls
60
+ workspace `stop()`. A cancel or timeout after the export is written does not fail
61
+ the build stage.
45
62
 
46
63
  ## Command line
47
64
 
48
- Without installing:
49
-
50
- ```sh
51
- npx --yes --package @stim-cli/ci stim-ci run --platform ios --project ./app -- pnpm test:e2e
52
- ```
53
-
54
65
  Or install `npm install --global @stim-cli/ci`, then:
55
66
 
56
67
  ```sh
68
+ stim-ci build --platform android --project ./app
57
69
  stim-ci run --platform android --project ./app --artifacts ./test-results --timeout 1800 -- pnpm test:e2e
58
70
  ```
59
71
 
@@ -62,20 +74,21 @@ to the current directory. `--home` and `--build-cache` explicitly select the
62
74
  Stim home and native artifact cache; leaving them unset preserves normal Stim
63
75
  configuration.
64
76
 
65
- Without selectors, the CLI uses each project's normal build settings. `build`
66
- and `run` accept the same selectors as the public API, so a run can match a
67
- build:
77
+ Without selectors, the CLI uses each project's normal build settings. `run`
78
+ must use the selectors its `build` used to find the recorded app:
68
79
 
69
- | Option | Platform | Stage | Selects |
70
- | ------------------------ | -------- | ---------- | ------------------------------------------------------- |
71
- | `--scheme <name>` | iOS | build, run | Xcode scheme |
72
- | `--configuration <name>` | iOS | build, run | Xcode configuration |
73
- | `--variant <name>` | Android | build, run | Gradle variant |
74
- | `--arch <name>` | iOS | build | Architecture: `arm64`, `x86_64`, `all` |
75
- | `--abi <name>` | Android | build | ABI: `arm64-v8a`, `armeabi-v7a`, `x86`, `x86_64`, `all` |
80
+ | Option | Platform | Stage | Selects |
81
+ | ------------------------ | ------------ | ---------- | ------------------------------------------------------- |
82
+ | `--scheme <name>` | iOS | build, run | Xcode scheme |
83
+ | `--configuration <name>` | iOS | build, run | Xcode configuration |
84
+ | `--variant <name>` | Android | build, run | Gradle variant |
85
+ | `--arch <name>` | iOS | build | Architecture: `arm64`, `x86_64`, `all` |
86
+ | `--abi <name>` | Android | build | ABI: `arm64-v8a`, `armeabi-v7a`, `x86`, `x86_64`, `all` |
87
+ | `--eas-profile <name>` | iOS, Android | build | Download this EAS profile's development build |
88
+ | `--export <path>` | all native | build | Also write the app (APK or tar.gz) to this path |
76
89
 
77
- The library also accepts the public API's other platform-specific build and run
78
- options.
90
+ The library also accepts the public API's other platform-specific build options, and
91
+ run options other than those that build.
79
92
 
80
93
  Everything after `--` is an argument vector. No shell is inferred. To use shell
81
94
  syntax, pass a shell explicitly: `-- bash -e -o pipefail -c 'pnpm test:e2e'`.
@@ -83,25 +96,28 @@ syntax, pass a shell explicitly: `-- bash -e -o pipefail -c 'pnpm test:e2e'`.
83
96
  The test process runs in the project directory. It receives the caller's
84
97
  environment plus these values:
85
98
 
86
- | Variable | Value |
87
- | ----------------------- | ------------------------------------------------------------ |
88
- | `STIM_CI_PLATFORM` | Selected platform |
89
- | `STIM_CI_DEVICE_ID` | Exact simulator UDID or emulator serial; empty for macOS/web |
90
- | `STIM_CI_APP_ID` | App bundle/package identifier when available |
91
- | `STIM_CI_METRO_PORT` | Metro port when available |
92
- | `STIM_CI_ARTIFACTS_DIR` | Absolute results directory |
93
- | `STIM_CI_RUN_RESULT` | Path to `run.json`, written before the command starts |
99
+ | Variable | Value |
100
+ | ----------------------- | ------------------------------------------------------------------------- |
101
+ | `STIM_CI_PLATFORM` | Selected platform |
102
+ | `STIM_CI_DEVICE_ID` | Exact simulator UDID or emulator serial; empty for macOS/web |
103
+ | `STIM_CI_APP_ID` | App bundle/package identifier when available |
104
+ | `STIM_CI_METRO_PORT` | Metro port when available |
105
+ | `STIM_CI_ARTIFACTS_DIR` | Absolute results directory; empty without `--artifacts` |
106
+ | `STIM_CI_RUN_RESULT` | Path to `run.json` before the command starts; empty without `--artifacts` |
94
107
 
95
108
  `run.json` contains the public Stim result `{ platform, facts }`, including
96
- platform-specific process, target, cache, launch, and log facts. Use the exact
109
+ platform-specific process, target, launch, and log facts; `appPath` is the stable
110
+ path of the installed app. Use the exact
97
111
  reported target; do not select an arbitrary booted device. Tests own
98
112
  app-specific readiness checks. A launch can still be `bundling` or `unverified`.
99
113
 
100
- Progress and test output go to stderr. Stdout contains one JSON result. The same
101
- result is saved as `result.json` beside `run.json`, `test.stdout.log`,
102
- `test.stderr.log`, and `diagnostics.json`. Without `--artifacts`, results use a
103
- fresh temporary directory. An explicit results directory must be empty; a reused
104
- directory is refused before setup so stale evidence cannot describe a new run.
114
+ Progress and test output go to stderr. Stdout contains one JSON result. With
115
+ `--artifacts <dir>` the same result is saved as `result.json` beside `run.json`,
116
+ `test.stdout.log`, `test.stderr.log`, and `diagnostics.json`. Without it no result,
117
+ test or diagnostic file is written, no logs are copied, and the result's
118
+ `artifactsDir`, `resultPath` and `runPath` are null. An explicit results directory
119
+ must be empty; a reused directory is refused before setup so stale evidence cannot
120
+ describe a new run.
105
121
  Keep results outside fingerprinted project inputs.
106
122
 
107
123
  The result includes `version: 1`, `projectRoot`, `platform`, `artifactsDir`,
@@ -188,6 +204,9 @@ For another disposable runner dedicated to one job, explicitly provide a fresh
188
204
  job home and a job-local artifact cache:
189
205
 
190
206
  ```sh
207
+ stim-ci build --platform ios --project ./app \
208
+ --home "$RUNNER_TEMP/stim-home" \
209
+ --build-cache "$RUNNER_TEMP/stim-build-cache"
191
210
  stim-ci run --platform ios --project ./app \
192
211
  --home "$RUNNER_TEMP/stim-home" \
193
212
  --build-cache "$RUNNER_TEMP/stim-build-cache" \
@@ -1,16 +1,19 @@
1
- import { n as buildCI, t as runCI } from "./src-q52NuL5J.mjs";
1
+ import { n as buildCI, t as runCI } from "./src-Gwd9CN7Z.mjs";
2
2
  import { parseArgs } from "node:util";
3
3
  //#region src/cli.ts
4
4
  const HELP = `Usage: stim-ci build --platform <ios|android|macos> [options]
5
5
  stim-ci run --platform <ios|android|macos|web> [options] -- <command> [args...]
6
6
 
7
- Build exports an artifact without starting or stopping an app.
8
- Run builds and launches an app, runs a test command, collects diagnostics, and stops it.
9
- The final result is JSON on stdout; progress and test output go to stderr.
7
+ Build is the only step that compiles. It records the app for this checkout, platform and
8
+ selectors, and exports it only with --export.
9
+ Run installs and launches the app the last matching build recorded, runs a test command, and
10
+ stops the app. It never builds; with no recorded build it refuses and names the build to run.
11
+ The final result is JSON on stdout; progress and test output go to stderr. No result, test or
12
+ diagnostic file is written unless --artifacts is given.
10
13
 
11
14
  Options:
12
15
  --project <path> Project directory (default: current directory)
13
- --artifacts <path> Results directory (default: a fresh temporary directory)
16
+ --artifacts <path> Empty directory for result, test and diagnostic files (default: write none)
14
17
  --home <path> Explicit Stim home (default: job-local on GitHub-hosted runners)
15
18
  --build-cache <path> Native artifact cache directory
16
19
  --timeout <seconds> Setup and test timeout (cleanup has its own deadline)
@@ -22,7 +25,9 @@ Build selectors (build and run):
22
25
  --configuration <name> iOS configuration
23
26
  --variant <name> Android variant
24
27
 
25
- Build-only selectors:
28
+ Build-only options:
29
+ --eas-profile <name> Download this EAS profile's completed development build instead of compiling
30
+ --export <path> Also write the app here: an APK for Android, a tar.gz for iOS and macOS
26
31
  --arch <name> iOS architecture: arm64, x86_64, all
27
32
  --abi <name> Android ABI: arm64-v8a, armeabi-v7a, x86, x86_64, all
28
33
  `;
@@ -43,7 +48,9 @@ function parse(argv) {
43
48
  configuration: { type: "string" },
44
49
  variant: { type: "string" },
45
50
  arch: { type: "string" },
46
- abi: { type: "string" }
51
+ abi: { type: "string" },
52
+ "eas-profile": { type: "string" },
53
+ export: { type: "string" }
47
54
  }
48
55
  });
49
56
  const stage = positionals[0];
@@ -58,11 +65,17 @@ function parse(argv) {
58
65
  buildCache: values["build-cache"],
59
66
  timeoutMs
60
67
  };
61
- if (stage === "run" && (values.arch !== void 0 || values.abi !== void 0)) throw new Error("--arch and --abi only apply to build-only.");
68
+ for (const flag of [
69
+ "arch",
70
+ "abi",
71
+ "eas-profile",
72
+ "export"
73
+ ]) if (stage === "run" && values[flag] !== void 0) throw new Error(`--${flag} only applies to build.`);
74
+ if (values["eas-profile"] !== void 0 && platform !== "ios" && platform !== "android") throw new Error("--eas-profile only applies to iOS and Android.");
62
75
  if (platform !== "ios" && (values.scheme !== void 0 || values.configuration !== void 0 || values.arch !== void 0)) throw new Error("--scheme, --configuration and --arch only apply to iOS.");
63
76
  if (platform !== "android" && (values.variant !== void 0 || values.abi !== void 0)) throw new Error("--variant and --abi only apply to Android.");
64
77
  if (stage === "build") {
65
- if (separator >= 0) throw new Error("Build-only does not take a test command. Use stim-ci run to launch and test.");
78
+ if (separator >= 0) throw new Error("Build does not take a test command. Use stim-ci run to launch and test.");
66
79
  if (platform === "web") throw new Error("Web projects have no native build artifact. Use stim-ci run --platform web.");
67
80
  if (values.arch !== void 0 && ![
68
81
  "arm64",
@@ -80,17 +93,20 @@ function parse(argv) {
80
93
  platform,
81
94
  scheme: values.scheme,
82
95
  configuration: values.configuration,
83
- arch: values.arch
96
+ arch: values.arch,
97
+ easProfile: values["eas-profile"]
84
98
  } : platform === "android" ? {
85
99
  platform,
86
100
  variant: values.variant,
87
- abi: values.abi
101
+ abi: values.abi,
102
+ easProfile: values["eas-profile"]
88
103
  } : { platform };
89
104
  return {
90
105
  stage,
91
106
  options: {
92
107
  ...common,
93
- build
108
+ build,
109
+ exportPath: values.export
94
110
  }
95
111
  };
96
112
  }
@@ -115,7 +131,7 @@ function parse(argv) {
115
131
  }
116
132
  async function main(argv = process.argv.slice(2)) {
117
133
  if (argv.length === 1 && argv[0] === "--version") {
118
- process.stdout.write(`1.34.0\n`);
134
+ process.stdout.write(`1.36.0\n`);
119
135
  return;
120
136
  }
121
137
  if (argv.length === 0 || argv[0] === "--help" || (argv[0] === "run" || argv[0] === "build") && argv[1] === "--help") {
package/dist/index.d.mts CHANGED
@@ -2,6 +2,7 @@ import { StimBuildOptions, StimBuildResult, StimOptions, StimRunOptions, StimRun
2
2
  //#region src/context.d.ts
3
3
  interface CIContextOptions {
4
4
  projectRoot: string;
5
+ /** Writes result, test and diagnostic files here; without it nothing is written. */
5
6
  artifactsDir?: string;
6
7
  home?: string;
7
8
  buildCache?: string;
@@ -15,25 +16,27 @@ interface CommandResult {
15
16
  exitCode: number | null;
16
17
  signal: NodeJS.Signals | null;
17
18
  durationMs: number;
18
- stdout: string;
19
- stderr: string;
19
+ stdout: string | null;
20
+ stderr: string | null;
20
21
  error?: string;
21
22
  }
22
23
  //#endregion
23
24
  //#region src/build.d.ts
24
- type WithoutSignal$1<T> = T extends unknown ? Omit<T, "signal"> : never;
25
+ type WithoutSignal<T> = T extends unknown ? Omit<T, "signal"> : never;
25
26
  interface CIBuildOptions extends CIContextOptions {
26
- build: WithoutSignal$1<StimBuildOptions>;
27
+ build: WithoutSignal<StimBuildOptions>;
28
+ /** Also writes the app here as a portable APK (Android) or tar.gz (iOS, macOS); without it the build is only recorded for `run`. */
29
+ exportPath?: string;
27
30
  }
28
31
  interface CIBuildResult {
29
32
  version: 1;
30
33
  stage: "build";
31
34
  projectRoot: string;
32
35
  platform: StimBuildOptions["platform"];
33
- artifactsDir: string;
34
- resultPath: string;
35
- buildPath: string;
36
- /** Portable APK or tar.gz bundle preserving executable permissions and symlinks. */
36
+ artifactsDir: string | null;
37
+ resultPath: string | null;
38
+ buildPath: string | null;
39
+ /** The exported APK or tar.gz bundle preserving executable permissions and symlinks; null unless an export was requested. */
37
40
  artifactPath: string | null;
38
41
  startedAt: string;
39
42
  durationMs: number;
@@ -46,13 +49,14 @@ interface CIBuildResult {
46
49
  error?: CIFailure;
47
50
  };
48
51
  }
49
- /** Builds and exports an artifact without starting or stopping a workspace runtime. */
52
+ /** Builds the app and records it for `runCI` without starting or stopping a workspace runtime. */
50
53
  declare function buildCI(input: CIBuildOptions): Promise<CIBuildResult>;
51
54
  //#endregion
52
55
  //#region src/index.d.ts
53
- type WithoutSignal<T> = T extends unknown ? Omit<T, "signal"> : never;
56
+ type WithoutKeys<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never;
54
57
  interface CIOptions extends CIContextOptions {
55
- run: WithoutSignal<StimRunOptions>;
58
+ /** Selectors of the recorded build to run. `run` never builds, so build-time options such as `easProfile` belong to `buildCI`. */
59
+ run: WithoutKeys<StimRunOptions, "signal" | "recordedBuild" | "easProfile">;
56
60
  command: readonly [string, ...string[]];
57
61
  }
58
62
  interface CIFailure {
@@ -64,9 +68,9 @@ interface CIResult {
64
68
  version: 1;
65
69
  projectRoot: string;
66
70
  platform: StimRunOptions["platform"];
67
- artifactsDir: string;
68
- resultPath: string;
69
- runPath: string;
71
+ artifactsDir: string | null;
72
+ resultPath: string | null;
73
+ runPath: string | null;
70
74
  startedAt: string;
71
75
  durationMs: number;
72
76
  exitCode: number;
package/dist/index.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { n as buildCI, t as runCI } from "./src-q52NuL5J.mjs";
1
+ import { n as buildCI, t as runCI } from "./src-Gwd9CN7Z.mjs";
2
2
  export { buildCI, runCI };
@@ -1,6 +1,5 @@
1
1
  import { copyDiagnosticLogs } from "./artifacts.mjs";
2
- import { closeSync, copyFileSync, mkdirSync, mkdtempSync, openSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync, writeSync } from "node:fs";
3
- import { tmpdir } from "node:os";
2
+ import { closeSync, copyFileSync, mkdirSync, openSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync, writeSync } from "node:fs";
4
3
  import { basename, dirname, join, resolve } from "node:path";
5
4
  import { createStim } from "stim";
6
5
  import { setTimeout as setTimeout$1 } from "node:timers/promises";
@@ -30,9 +29,11 @@ function prepareCI(options) {
30
29
  };
31
30
  }
32
31
  const projectRoot = realpathSync(options.projectRoot);
33
- const artifactsDir = options.artifactsDir ? resolve(options.artifactsDir) : mkdtempSync(join(tmpdir(), "stim-ci-"));
34
- mkdirSync(artifactsDir, { recursive: true });
35
- if (readdirSync(artifactsDir).length > 0) throw new Error(`Artifacts directory must be empty: ${artifactsDir}. Choose a new directory for this run.`);
32
+ const artifactsDir = options.artifactsDir ? resolve(options.artifactsDir) : null;
33
+ if (artifactsDir) {
34
+ mkdirSync(artifactsDir, { recursive: true });
35
+ if (readdirSync(artifactsDir).length > 0) throw new Error(`Artifacts directory must be empty: ${artifactsDir}. Choose a new directory for this run.`);
36
+ }
36
37
  const timeout = options.timeoutMs === void 0 ? void 0 : AbortSignal.timeout(options.timeoutMs);
37
38
  const signals = [options.signal, timeout].filter((value) => value !== void 0);
38
39
  const signal = signals.length ? AbortSignal.any(signals) : void 0;
@@ -49,12 +50,12 @@ function prepareCI(options) {
49
50
  async function runCommand({ command, cwd, env, artifactsDir, logName = "test", signal, onOutput }) {
50
51
  signal?.throwIfAborted();
51
52
  const started = Date.now();
52
- const stdout = join(artifactsDir, `${logName}.stdout.log`);
53
- const stderr = join(artifactsDir, `${logName}.stderr.log`);
54
- const out = openSync(stdout, "w");
53
+ const stdout = artifactsDir ? join(artifactsDir, `${logName}.stdout.log`) : null;
54
+ const stderr = artifactsDir ? join(artifactsDir, `${logName}.stderr.log`) : null;
55
+ const out = stdout ? openSync(stdout, "w") : void 0;
55
56
  let err;
56
57
  try {
57
- err = openSync(stderr, "w");
58
+ if (stderr) err = openSync(stderr, "w");
58
59
  const child = spawn(command[0], command.slice(1), {
59
60
  cwd,
60
61
  env,
@@ -93,7 +94,7 @@ async function runCommand({ command, cwd, env, artifactsDir, logName = "test", s
93
94
  };
94
95
  const output = (stream, fd, chunk) => {
95
96
  try {
96
- writeSync(fd, chunk);
97
+ if (fd !== void 0) writeSync(fd, chunk);
97
98
  onOutput?.({
98
99
  stream,
99
100
  message: chunk.toString("utf8")
@@ -153,13 +154,13 @@ async function runCommand({ command, cwd, env, artifactsDir, logName = "test", s
153
154
  clearTimeout(killTimer);
154
155
  }
155
156
  } finally {
156
- closeSync(out);
157
+ if (out !== void 0) closeSync(out);
157
158
  if (err !== void 0) closeSync(err);
158
159
  }
159
160
  }
160
161
  //#endregion
161
162
  //#region src/build.ts
162
- /** Builds and exports an artifact without starting or stopping a workspace runtime. */
163
+ /** Builds the app and records it for `runCI` without starting or stopping a workspace runtime. */
163
164
  async function buildCI(input) {
164
165
  const { options, projectRoot, artifactsDir, timeout, signal } = prepareCI(input);
165
166
  const started = Date.now();
@@ -169,8 +170,8 @@ async function buildCI(input) {
169
170
  projectRoot,
170
171
  platform: options.build.platform,
171
172
  artifactsDir,
172
- resultPath: join(artifactsDir, "result.json"),
173
- buildPath: join(artifactsDir, "build.json"),
173
+ resultPath: artifactsDir ? join(artifactsDir, "result.json") : null,
174
+ buildPath: artifactsDir ? join(artifactsDir, "build.json") : null,
174
175
  artifactPath: null,
175
176
  startedAt: new Date(started).toISOString(),
176
177
  durationMs: 0,
@@ -203,13 +204,12 @@ async function buildCI(input) {
203
204
  signal
204
205
  });
205
206
  signal?.throwIfAborted();
206
- writeJson(result.buildPath, result.build);
207
- if (result.build.platform === "android") {
208
- exporting = join(artifactsDir, "app.apk");
209
- copyFileSync(result.build.facts.apkPath, exporting);
210
- } else {
207
+ if (result.buildPath) writeJson(result.buildPath, result.build);
208
+ if (input.exportPath) exporting = resolve(input.exportPath);
209
+ if (exporting) mkdirSync(dirname(exporting), { recursive: true });
210
+ if (exporting && result.build.platform === "android") copyFileSync(result.build.facts.apkPath, exporting);
211
+ else if (exporting && result.build.platform !== "android") {
211
212
  const bundle = result.build.platform === "ios" ? result.build.facts.appPath : result.build.facts.bundle;
212
- exporting = join(artifactsDir, "app.tar.gz");
213
213
  const packed = await runCommand({
214
214
  command: [
215
215
  "tar",
@@ -233,7 +233,7 @@ async function buildCI(input) {
233
233
  if (packed.exitCode !== 0 || packed.error) throw Object.assign(new Error(packed.error ?? `Artifact export exited with ${packed.exitCode}.`), { code: "STIM_CI_ARTIFACT_FAILED" });
234
234
  }
235
235
  signal?.throwIfAborted();
236
- result.artifactPath = exporting;
236
+ result.artifactPath = exporting ?? null;
237
237
  result.exitCode = 0;
238
238
  } catch (error) {
239
239
  result.failure = failure(error, result.build ? "STIM_CI_ARTIFACT_FAILED" : "STIM_CI_BUILD_FAILED");
@@ -253,7 +253,7 @@ async function buildCI(input) {
253
253
  } catch (error) {
254
254
  result.reportingError ??= failure(error, "STIM_CI_REPORT_FAILED");
255
255
  }
256
- if (attempted) try {
256
+ if (attempted && artifactsDir) try {
257
257
  const diagnostics = await stim.diagnostics({
258
258
  tail: 1e3,
259
259
  signal: AbortSignal.timeout(1e4)
@@ -268,7 +268,7 @@ async function buildCI(input) {
268
268
  result.failure = result.reportingError;
269
269
  }
270
270
  result.durationMs = Date.now() - started;
271
- try {
271
+ if (result.resultPath) try {
272
272
  writeJson(result.resultPath, result);
273
273
  } catch (error) {
274
274
  result.reportingError = failure(error, "STIM_CI_REPORT_FAILED");
@@ -292,8 +292,8 @@ function commandEnvironment(result, options) {
292
292
  STIM_CI_DEVICE_ID: String("udid" in facts ? facts.udid : "serial" in facts ? facts.serial ?? "" : ""),
293
293
  STIM_CI_APP_ID: String("bundleId" in facts ? facts.bundleId ?? "" : ""),
294
294
  STIM_CI_METRO_PORT: String("metroPort" in facts ? facts.metroPort ?? "" : ""),
295
- STIM_CI_ARTIFACTS_DIR: result.artifactsDir,
296
- STIM_CI_RUN_RESULT: result.runPath
295
+ STIM_CI_ARTIFACTS_DIR: result.artifactsDir ?? "",
296
+ STIM_CI_RUN_RESULT: result.runPath ?? ""
297
297
  };
298
298
  }
299
299
  /** Runs an app and argv-based test command, stops its workspace or explicit slot, and retains diagnostics. */
@@ -309,8 +309,8 @@ async function runCI(options) {
309
309
  projectRoot,
310
310
  platform: options.run.platform,
311
311
  artifactsDir,
312
- resultPath: join(artifactsDir, "result.json"),
313
- runPath: join(artifactsDir, "run.json"),
312
+ resultPath: artifactsDir ? join(artifactsDir, "result.json") : null,
313
+ runPath: artifactsDir ? join(artifactsDir, "run.json") : null,
314
314
  startedAt: new Date(started).toISOString(),
315
315
  durationMs: 0,
316
316
  exitCode: 1,
@@ -340,10 +340,11 @@ async function runCI(options) {
340
340
  attempted = true;
341
341
  result.run = await stim.run({
342
342
  ...options.run,
343
+ recordedBuild: true,
343
344
  signal
344
345
  });
345
346
  signal?.throwIfAborted();
346
- writeJson(result.runPath, result.run);
347
+ if (result.runPath) writeJson(result.runPath, result.run);
347
348
  result.test = await runCommand({
348
349
  command: options.command,
349
350
  cwd: projectRoot,
@@ -360,6 +361,7 @@ async function runCI(options) {
360
361
  };
361
362
  } catch (error) {
362
363
  result.failure = failure(error, result.run ? "STIM_CI_TEST_FAILED" : "STIM_CI_SETUP_FAILED");
364
+ if (result.failure.code === "STIM_NO_RECORDED_BUILD") result.failure.remedy = `Run ${buildCommand(options, projectRoot)} first.`;
363
365
  } finally {
364
366
  cleaningUp = true;
365
367
  if (signal?.aborted) {
@@ -383,7 +385,7 @@ async function runCI(options) {
383
385
  } catch (error) {
384
386
  result.cleanup.error = failure(error, "STIM_CI_CLEANUP_FAILED");
385
387
  }
386
- try {
388
+ if (artifactsDir) try {
387
389
  const diagnostics = await stim.diagnostics({
388
390
  tail: 1e3,
389
391
  signal: AbortSignal.timeout(1e4)
@@ -412,7 +414,7 @@ async function runCI(options) {
412
414
  result.failure = result.reportingError;
413
415
  }
414
416
  result.durationMs = Date.now() - started;
415
- try {
417
+ if (result.resultPath) try {
416
418
  writeJson(result.resultPath, result);
417
419
  } catch (error) {
418
420
  result.reportingError = failure(error, "STIM_CI_REPORT_FAILED");
@@ -424,5 +426,16 @@ async function runCI(options) {
424
426
  }
425
427
  return result;
426
428
  }
429
+ function buildCommand({ run, home, buildCache }, projectRoot) {
430
+ const flags = [`--platform ${run.platform}`];
431
+ if (projectRoot !== realpathSync(process.cwd())) flags.push(`--project ${JSON.stringify(projectRoot)}`);
432
+ if (home) flags.push(`--home ${JSON.stringify(resolve(home))}`);
433
+ if (buildCache) flags.push(`--build-cache ${JSON.stringify(resolve(buildCache))}`);
434
+ if (run.platform === "ios") {
435
+ if (run.scheme !== void 0) flags.push(`--scheme ${JSON.stringify(run.scheme)}`);
436
+ if (run.configuration !== void 0) flags.push(`--configuration ${JSON.stringify(run.configuration)}`);
437
+ } else if (run.platform === "android" && run.variant !== void 0) flags.push(`--variant ${JSON.stringify(run.variant)}`);
438
+ return `stim-ci build ${flags.join(" ")}`;
439
+ }
427
440
  //#endregion
428
441
  export { buildCI as n, runCI as t };
package/dist/stim-ci.mjs CHANGED
@@ -6,6 +6,6 @@ const minor = parts[1] ?? 0;
6
6
  if (major < 22 || major === 22 && minor < 12) {
7
7
  process.stderr.write(`stim-ci requires Node.js 22.12.0 or later; this is ${process.versions.node}.\n`);
8
8
  process.exitCode = 1;
9
- } else import("./cli-n9sEIZ8z.mjs").then(({ main }) => main());
9
+ } else import("./cli-BhFWDeM1.mjs").then(({ main }) => main());
10
10
  //#endregion
11
11
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stim-cli/ci",
3
- "version": "1.34.0",
3
+ "version": "1.36.0",
4
4
  "description": "Build Stim app artifacts or run Stim apps and test commands in CI with diagnostics and scoped cleanup.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -33,7 +33,7 @@
33
33
  },
34
34
  "dependencies": {
35
35
  "cross-spawn": "^7.0.6",
36
- "stim": "1.34.0"
36
+ "stim": "1.36.0"
37
37
  },
38
38
  "devDependencies": {
39
39
  "@types/cross-spawn": "^6.0.6"