@stim-cli/ci 0.0.0 → 1.20.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Janic Duplessis
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,228 @@
1
1
  # @stim-cli/ci
2
2
 
3
- Placeholder. The real package ships with stim 1.19.0.
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.
6
+
7
+ Requires Node.js 22.12 or newer and the native tools for the selected platform.
8
+ Install the app's dependencies before running it. Each concurrent CI run needs
9
+ its own checkout. The run stops that checkout's workspace, including resources
10
+ started before a build failure; an explicit API `run.slot` limits cleanup to
11
+ that slot. Do not point CI at a developer's active checkout.
12
+
13
+ ## Build only
14
+
15
+ ```sh
16
+ npx --yes --package @stim-cli/ci stim-ci build --platform ios --project ./app --artifacts ./build-results
17
+ ```
18
+
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.
45
+
46
+ ## Command line
47
+
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
+ Or install `npm install --global @stim-cli/ci`, then:
55
+
56
+ ```sh
57
+ stim-ci run --platform android --project ./app --artifacts ./test-results --timeout 1800 -- pnpm test:e2e
58
+ ```
59
+
60
+ `--platform` accepts `ios`, `android`, `macos`, or `web`. `--project` defaults
61
+ to the current directory. `--home` and `--build-cache` explicitly select the
62
+ Stim home and native artifact cache; leaving them unset preserves normal Stim
63
+ configuration.
64
+
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:
68
+
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` |
76
+
77
+ The library also accepts the public API's other platform-specific build and run
78
+ options.
79
+
80
+ Everything after `--` is an argument vector. No shell is inferred. To use shell
81
+ syntax, pass a shell explicitly: `-- bash -e -o pipefail -c 'pnpm test:e2e'`.
82
+
83
+ The test process runs in the project directory. It receives the caller's
84
+ environment plus these values:
85
+
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 |
94
+
95
+ `run.json` contains the public Stim result `{ platform, facts }`, including
96
+ platform-specific process, target, cache, launch, and log facts. Use the exact
97
+ reported target; do not select an arbitrary booted device. Tests own
98
+ app-specific readiness checks. A launch can still be `bundling` or `unverified`.
99
+
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.
105
+ Keep results outside fingerprinted project inputs.
106
+
107
+ The result includes `version: 1`, `projectRoot`, `platform`, `artifactsDir`,
108
+ `resultPath`, `runPath`, `startedAt`, `durationMs`, `exitCode`, the native `run`,
109
+ the `test` result, optional `failure`, `diagnostics`, and `cleanup`.
110
+ If a progress callback or writing `result.json` fails, the result includes
111
+ `reportingError`. Reporter failures cannot interrupt cleanup or replace an earlier
112
+ test failure; after a passing test, they return 1.
113
+ Setup failure leaves `run` and `test` null. Diagnostics include the last 1000
114
+ structured records and their original directory. After cleanup, regular `.log`
115
+ and `.ndjson` files (including `.1` rotated copies) directly inside that directory
116
+ are copied into `logs/` and listed in `diagnostics.files`. This retains raw
117
+ compiler and process output even when setup fails. These persisted files can
118
+ include earlier records from the same workspace. Collection does not follow
119
+ symlinks, nested directories, or paths referenced by log records. It does not
120
+ copy build binaries or caches. Collection failures appear in `diagnostics.error`
121
+ without replacing the setup or test exit code.
122
+
123
+ Providers can import `diagnosticArtifactFiles` from `@stim-cli/ci/artifacts` to
124
+ list the regular result, run, test and diagnostic files for upload. The helper
125
+ only selects those known files and the copied logs; custom test outputs need
126
+ the provider's own explicit upload configuration.
127
+
128
+ ## Library
129
+
130
+ ```ts
131
+ import { runCI } from '@stim-cli/ci';
132
+
133
+ const result = await runCI({
134
+ projectRoot: '/checkout/app',
135
+ run: { platform: 'ios', configuration: 'Debug' },
136
+ command: ['pnpm', 'test:e2e'],
137
+ artifactsDir: '/job/results',
138
+ timeoutMs: 30 * 60 * 1000,
139
+ signal: controller.signal,
140
+ onProgress: ({ message }) => process.stderr.write(message),
141
+ });
142
+
143
+ process.exitCode = result.exitCode;
144
+ ```
145
+
146
+ The library does not change `process.cwd()`, `process.env`, process signal
147
+ handlers, or the caller's exit code. Invalid arguments throw before native
148
+ setup. Setup, test, diagnostics, and cleanup outcomes are returned in the
149
+ result. The CLI alone handles SIGINT/SIGTERM and sets its exit code.
150
+
151
+ ## Failure and cancellation
152
+
153
+ A failing command preserves its exit code even when diagnostics or cleanup
154
+ also fail. A passing command followed by incomplete cleanup returns 1. The
155
+ result records both failures when relevant. Diagnostics failures are recorded
156
+ without replacing the setup or test result.
157
+
158
+ `timeoutMs` / `--timeout` covers setup and the test command; there is no timeout
159
+ by default. Timeout returns 124. Cancellation returns 130. Cancellation during
160
+ cleanup is also recorded, preserving an already completed test failure. Cancellation stops
161
+ the test process tree and aborts the native operation, then uses fresh signals
162
+ to stop the workspace (up to 60 seconds) and collect its persisted diagnostics
163
+ (up to 10 seconds). On macOS/Linux, the test command gets its own process group; children
164
+ that deliberately detach from it are outside that group. On Windows, tree
165
+ termination uses `taskkill`; commands must not leave background children after
166
+ their parent exits. Keep test resources inside the command's process tree.
167
+
168
+ Cleanup cannot run after SIGKILL, runner loss, or a provider's hard timeout.
169
+ Leave room between the package timeout and the job timeout. The result reports
170
+ incomplete cleanup; use normal Stim recovery for a persistent runner. `stop`
171
+ shuts down owned devices; it does not delete them.
172
+
173
+ ## Disposable and shared runners
174
+
175
+ GitHub-hosted jobs need no home or cache configuration. When `GITHUB_ACTIONS=true`,
176
+ `RUNNER_ENVIRONMENT=github-hosted` and `RUNNER_TEMP` is set, the package defaults to
177
+ `$RUNNER_TEMP/stim-ci/home` and `$RUNNER_TEMP/stim-ci/build-cache`. Repeated steps
178
+ in the same job reuse these paths. The public API and test command receive the
179
+ same paths without changing the caller's environment.
180
+
181
+ An explicit `home` / `--home` or `STIM_HOME` retains the caller's home and normal
182
+ cache configuration. An explicit `buildCache` / `--build-cache` or
183
+ `STIM_BUILD_CACHE` overrides the automatic cache path. Self-hosted runners,
184
+ other providers and local runs keep normal Stim configuration and configured
185
+ build/device caps. `CI=true` alone changes no defaults or coordination guarantees.
186
+
187
+ For another disposable runner dedicated to one job, explicitly provide a fresh
188
+ job home and a job-local artifact cache:
189
+
190
+ ```sh
191
+ stim-ci run --platform ios --project ./app \
192
+ --home "$RUNNER_TEMP/stim-home" \
193
+ --build-cache "$RUNNER_TEMP/stim-build-cache" \
194
+ --artifacts "$RUNNER_TEMP/stim-results" \
195
+ --timeout 1800 -- pnpm test:e2e
196
+ ```
197
+
198
+ Restore and save only the artifact cache through the provider. Never restore
199
+ workspace state, process records, claims, device ledgers, or pairings. Use cache
200
+ names that separate OS, architecture and platform; Stim still validates its
201
+ native fingerprint/configuration keys. Keep provider fallback keys within
202
+ compatible toolchains. Save complete entries, not staging directories, after
203
+ the runner finishes. Cache service failure should fall back to a build.
204
+
205
+ Do not point independent homes at a shared writable filesystem cache. Build
206
+ single-flight claims are scoped to the Stim home. Job-local restored copies
207
+ have no cross-job writers; persistent shared runners should use their normal
208
+ shared Stim home and coordination.
209
+
210
+ Current code already skips capacity admission when `concurrency.maxBuilds` and
211
+ `concurrency.maxDevices` are 0, their defaults. An explicit `STIM_HOME` defaults
212
+ device parking to 0. These are existing fast paths, not a new unsafe mode.
213
+ Ownership ledgers, atomic writes, process identity, cache publication, and
214
+ workspace claims still run. No timing study has established that this
215
+ bookkeeping is a material CI cost.
216
+
217
+ One concrete setup waste in Stim's existing Android fixture workflow is booting
218
+ an emulator through another action and immediately killing it before Stim
219
+ boots its own. SDK-only setup can avoid that extra boot without adopting an
220
+ external device. External simulator/emulator adoption is not part of this API.
221
+
222
+ Before adding coordination shortcuts, compare cold and warm runs by phase:
223
+ dependency setup, fingerprint/cache lookup, native build, device boot, install,
224
+ Metro, test, diagnostics, cleanup, and capacity waits. Validate a fresh-home
225
+ warm-cache run actually skips compilation, a deliberate test failure retains
226
+ its logs/exit code, cancellation leaves no live owned processes, and a shared
227
+ runner's unrelated workspace remains running. Native timings and cross-provider
228
+ acceptance require real tool runs; unit tests are not that evidence.
@@ -0,0 +1,7 @@
1
+ //#region src/artifacts.d.ts
2
+ /** Copies regular, non-symlink log files directly inside the public diagnostics directory. */
3
+ declare function copyDiagnosticLogs(directory: string, artifactsDir: string): Promise<string[]>;
4
+ /** Lists CI result, test and diagnostic evidence from an explicit root, excluding linked entries and app binaries. */
5
+ declare function diagnosticArtifactFiles(artifactsDir: string): Promise<string[]>;
6
+ //#endregion
7
+ export { copyDiagnosticLogs, diagnosticArtifactFiles };
@@ -0,0 +1,48 @@
1
+ import { constants } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { copyFile, lstat, mkdir, readdir, realpath } from "node:fs/promises";
4
+ //#region src/artifacts.ts
5
+ async function regularFiles(directory, resolveDirectory = false) {
6
+ try {
7
+ if (resolveDirectory) directory = await realpath(directory);
8
+ if (!(await lstat(directory)).isDirectory()) return [];
9
+ return (await readdir(directory, { withFileTypes: true })).filter((entry) => entry.isFile()).map((entry) => entry.name).toSorted();
10
+ } catch (error) {
11
+ if (error.code === "ENOENT") return [];
12
+ throw error;
13
+ }
14
+ }
15
+ function isLog(name) {
16
+ return /\.(?:log|ndjson)(?:\.1)?$/.test(name);
17
+ }
18
+ /** Copies regular, non-symlink log files directly inside the public diagnostics directory. */
19
+ async function copyDiagnosticLogs(directory, artifactsDir) {
20
+ const names = (await regularFiles(directory)).filter(isLog);
21
+ const destination = join(artifactsDir, "logs");
22
+ if (names.length) {
23
+ await mkdir(destination, { recursive: true });
24
+ if (!(await lstat(destination)).isDirectory()) throw new Error("Diagnostic logs destination must be a regular directory.");
25
+ }
26
+ const files = [];
27
+ for (const name of names) {
28
+ const target = join(destination, name);
29
+ await copyFile(join(directory, name), target, constants.COPYFILE_EXCL);
30
+ files.push(target);
31
+ }
32
+ return files;
33
+ }
34
+ /** Lists CI result, test and diagnostic evidence from an explicit root, excluding linked entries and app binaries. */
35
+ async function diagnosticArtifactFiles(artifactsDir) {
36
+ const reports = /* @__PURE__ */ new Set([
37
+ "result.json",
38
+ "run.json",
39
+ "diagnostics.json",
40
+ "test.stdout.log",
41
+ "test.stderr.log"
42
+ ]);
43
+ const files = (await regularFiles(artifactsDir, true)).filter((name) => reports.has(name)).map((name) => join(artifactsDir, name));
44
+ const logs = join(artifactsDir, "logs");
45
+ return [...files, ...(await regularFiles(logs)).filter(isLog).map((name) => join(logs, name))];
46
+ }
47
+ //#endregion
48
+ export { copyDiagnosticLogs, diagnosticArtifactFiles };
@@ -0,0 +1,155 @@
1
+ import { n as buildCI, t as runCI } from "./src-q52NuL5J.mjs";
2
+ import { parseArgs } from "node:util";
3
+ //#region src/cli.ts
4
+ const HELP = `Usage: stim-ci build --platform <ios|android|macos> [options]
5
+ stim-ci run --platform <ios|android|macos|web> [options] -- <command> [args...]
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.
10
+
11
+ Options:
12
+ --project <path> Project directory (default: current directory)
13
+ --artifacts <path> Results directory (default: a fresh temporary directory)
14
+ --home <path> Explicit Stim home (default: job-local on GitHub-hosted runners)
15
+ --build-cache <path> Native artifact cache directory
16
+ --timeout <seconds> Setup and test timeout (cleanup has its own deadline)
17
+ --help Show this help
18
+ --version Show the package version
19
+
20
+ Build selectors (build and run):
21
+ --scheme <name> iOS scheme
22
+ --configuration <name> iOS configuration
23
+ --variant <name> Android variant
24
+
25
+ Build-only selectors:
26
+ --arch <name> iOS architecture: arm64, x86_64, all
27
+ --abi <name> Android ABI: arm64-v8a, armeabi-v7a, x86, x86_64, all
28
+ `;
29
+ function parse(argv) {
30
+ const separator = argv.indexOf("--");
31
+ const command = separator < 0 ? [] : argv.slice(separator + 1);
32
+ const { values, positionals } = parseArgs({
33
+ args: separator < 0 ? argv : argv.slice(0, separator),
34
+ allowPositionals: true,
35
+ options: {
36
+ platform: { type: "string" },
37
+ project: { type: "string" },
38
+ artifacts: { type: "string" },
39
+ home: { type: "string" },
40
+ "build-cache": { type: "string" },
41
+ timeout: { type: "string" },
42
+ scheme: { type: "string" },
43
+ configuration: { type: "string" },
44
+ variant: { type: "string" },
45
+ arch: { type: "string" },
46
+ abi: { type: "string" }
47
+ }
48
+ });
49
+ const stage = positionals[0];
50
+ if (positionals.length !== 1 || stage !== "run" && stage !== "build") throw new Error("Expected stim-ci build or run.");
51
+ const platform = values.platform;
52
+ if (platform !== "ios" && platform !== "android" && platform !== "macos" && platform !== "web") throw new Error("--platform must be ios, android, macos or web.");
53
+ const timeoutMs = values.timeout === void 0 ? void 0 : Number(values.timeout) * 1e3;
54
+ const common = {
55
+ projectRoot: values.project ?? process.cwd(),
56
+ artifactsDir: values.artifacts,
57
+ home: values.home,
58
+ buildCache: values["build-cache"],
59
+ timeoutMs
60
+ };
61
+ if (stage === "run" && (values.arch !== void 0 || values.abi !== void 0)) throw new Error("--arch and --abi only apply to build-only.");
62
+ 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
+ if (platform !== "android" && (values.variant !== void 0 || values.abi !== void 0)) throw new Error("--variant and --abi only apply to Android.");
64
+ 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.");
66
+ if (platform === "web") throw new Error("Web projects have no native build artifact. Use stim-ci run --platform web.");
67
+ if (values.arch !== void 0 && ![
68
+ "arm64",
69
+ "x86_64",
70
+ "all"
71
+ ].includes(values.arch)) throw new Error("--arch must be arm64, x86_64 or all.");
72
+ if (values.abi !== void 0 && ![
73
+ "arm64-v8a",
74
+ "armeabi-v7a",
75
+ "x86",
76
+ "x86_64",
77
+ "all"
78
+ ].includes(values.abi)) throw new Error("--abi must be arm64-v8a, armeabi-v7a, x86, x86_64 or all.");
79
+ const build = platform === "ios" ? {
80
+ platform,
81
+ scheme: values.scheme,
82
+ configuration: values.configuration,
83
+ arch: values.arch
84
+ } : platform === "android" ? {
85
+ platform,
86
+ variant: values.variant,
87
+ abi: values.abi
88
+ } : { platform };
89
+ return {
90
+ stage,
91
+ options: {
92
+ ...common,
93
+ build
94
+ }
95
+ };
96
+ }
97
+ if (separator < 0) throw new Error("Separate the test command with --.");
98
+ if (!command[0]) throw new Error("A test command is required after --.");
99
+ const run = platform === "ios" ? {
100
+ platform,
101
+ scheme: values.scheme,
102
+ configuration: values.configuration
103
+ } : platform === "android" ? {
104
+ platform,
105
+ variant: values.variant
106
+ } : { platform };
107
+ return {
108
+ stage,
109
+ options: {
110
+ ...common,
111
+ run,
112
+ command
113
+ }
114
+ };
115
+ }
116
+ async function main(argv = process.argv.slice(2)) {
117
+ if (argv.length === 1 && argv[0] === "--version") {
118
+ process.stdout.write(`1.20.0\n`);
119
+ return;
120
+ }
121
+ if (argv.length === 0 || argv[0] === "--help" || (argv[0] === "run" || argv[0] === "build") && argv[1] === "--help") {
122
+ process.stdout.write(HELP);
123
+ return;
124
+ }
125
+ const controller = new AbortController();
126
+ const interrupt = () => controller.abort();
127
+ try {
128
+ const invocation = parse(argv);
129
+ process.on("SIGINT", interrupt);
130
+ process.on("SIGTERM", interrupt);
131
+ const callbacks = {
132
+ signal: controller.signal,
133
+ onProgress: ({ message }) => {
134
+ process.stderr.write(message);
135
+ }
136
+ };
137
+ const result = invocation.stage === "build" ? await buildCI({
138
+ ...invocation.options,
139
+ ...callbacks
140
+ }) : await runCI({
141
+ ...invocation.options,
142
+ ...callbacks
143
+ });
144
+ process.stdout.write(`${JSON.stringify(result)}\n`);
145
+ process.exitCode = result.exitCode;
146
+ } catch (error) {
147
+ process.stderr.write(`stim-ci: ${error.message}\n`);
148
+ process.exitCode = 1;
149
+ } finally {
150
+ process.off("SIGINT", interrupt);
151
+ process.off("SIGTERM", interrupt);
152
+ }
153
+ }
154
+ //#endregion
155
+ export { main };
@@ -0,0 +1,90 @@
1
+ import { StimBuildOptions, StimBuildResult, StimOptions, StimRunOptions, StimRunResult, StimStopResult } from "stim";
2
+ //#region src/context.d.ts
3
+ interface CIContextOptions {
4
+ projectRoot: string;
5
+ artifactsDir?: string;
6
+ home?: string;
7
+ buildCache?: string;
8
+ timeoutMs?: number;
9
+ signal?: AbortSignal;
10
+ onProgress?: StimOptions["onProgress"];
11
+ }
12
+ //#endregion
13
+ //#region src/command.d.ts
14
+ interface CommandResult {
15
+ exitCode: number | null;
16
+ signal: NodeJS.Signals | null;
17
+ durationMs: number;
18
+ stdout: string;
19
+ stderr: string;
20
+ error?: string;
21
+ }
22
+ //#endregion
23
+ //#region src/build.d.ts
24
+ type WithoutSignal$1<T> = T extends unknown ? Omit<T, "signal"> : never;
25
+ interface CIBuildOptions extends CIContextOptions {
26
+ build: WithoutSignal$1<StimBuildOptions>;
27
+ }
28
+ interface CIBuildResult {
29
+ version: 1;
30
+ stage: "build";
31
+ projectRoot: string;
32
+ platform: StimBuildOptions["platform"];
33
+ artifactsDir: string;
34
+ resultPath: string;
35
+ buildPath: string;
36
+ /** Portable APK or tar.gz bundle preserving executable permissions and symlinks. */
37
+ artifactPath: string | null;
38
+ startedAt: string;
39
+ durationMs: number;
40
+ exitCode: number;
41
+ build: StimBuildResult | null;
42
+ failure?: CIFailure;
43
+ reportingError?: CIFailure;
44
+ diagnostics: {
45
+ path: string | null;
46
+ error?: CIFailure;
47
+ };
48
+ }
49
+ /** Builds and exports an artifact without starting or stopping a workspace runtime. */
50
+ declare function buildCI(input: CIBuildOptions): Promise<CIBuildResult>;
51
+ //#endregion
52
+ //#region src/index.d.ts
53
+ type WithoutSignal<T> = T extends unknown ? Omit<T, "signal"> : never;
54
+ interface CIOptions extends CIContextOptions {
55
+ run: WithoutSignal<StimRunOptions>;
56
+ command: readonly [string, ...string[]];
57
+ }
58
+ interface CIFailure {
59
+ code: string;
60
+ message: string;
61
+ remedy?: string;
62
+ }
63
+ interface CIResult {
64
+ version: 1;
65
+ projectRoot: string;
66
+ platform: StimRunOptions["platform"];
67
+ artifactsDir: string;
68
+ resultPath: string;
69
+ runPath: string;
70
+ startedAt: string;
71
+ durationMs: number;
72
+ exitCode: number;
73
+ run: StimRunResult | null;
74
+ test: CommandResult | null;
75
+ failure?: CIFailure;
76
+ reportingError?: CIFailure;
77
+ diagnostics: {
78
+ path: string | null;
79
+ files?: string[];
80
+ error?: CIFailure;
81
+ };
82
+ cleanup: {
83
+ result: StimStopResult | null;
84
+ error?: CIFailure;
85
+ };
86
+ }
87
+ /** Runs an app and argv-based test command, stops its workspace or explicit slot, and retains diagnostics. */
88
+ declare function runCI(options: CIOptions): Promise<CIResult>;
89
+ //#endregion
90
+ export { type CIBuildOptions, type CIBuildResult, CIFailure, CIOptions, CIResult, type CommandResult, buildCI, runCI };
package/dist/index.mjs ADDED
@@ -0,0 +1,2 @@
1
+ import { n as buildCI, t as runCI } from "./src-q52NuL5J.mjs";
2
+ export { buildCI, runCI };
@@ -0,0 +1,428 @@
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";
4
+ import { basename, dirname, join, resolve } from "node:path";
5
+ import { createStim } from "stim";
6
+ import { setTimeout as setTimeout$1 } from "node:timers/promises";
7
+ import spawn from "cross-spawn";
8
+ //#region src/context.ts
9
+ function failure(error, code) {
10
+ const caught = error;
11
+ return {
12
+ code: typeof caught?.code === "string" ? caught.code : code,
13
+ message: typeof caught?.message === "string" ? caught.message : String(error),
14
+ ...typeof caught?.remedy === "string" ? { remedy: caught.remedy } : {}
15
+ };
16
+ }
17
+ function writeJson(path, value) {
18
+ const pending = `${path}.tmp`;
19
+ writeFileSync(pending, `${JSON.stringify(value, null, 2)}\n`);
20
+ renameSync(pending, path);
21
+ }
22
+ function prepareCI(options) {
23
+ if (options.timeoutMs !== void 0 && (!Number.isSafeInteger(options.timeoutMs) || options.timeoutMs <= 0)) throw new Error("timeoutMs must be a positive integer.");
24
+ if (process.env.GITHUB_ACTIONS === "true" && process.env.RUNNER_ENVIRONMENT === "github-hosted" && process.env.RUNNER_TEMP && !options.home && !process.env.STIM_HOME) {
25
+ const directory = join(resolve(process.env.RUNNER_TEMP), "stim-ci");
26
+ options = {
27
+ ...options,
28
+ home: join(directory, "home"),
29
+ buildCache: options.buildCache || (process.env.STIM_BUILD_CACHE ? void 0 : join(directory, "build-cache"))
30
+ };
31
+ }
32
+ 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.`);
36
+ const timeout = options.timeoutMs === void 0 ? void 0 : AbortSignal.timeout(options.timeoutMs);
37
+ const signals = [options.signal, timeout].filter((value) => value !== void 0);
38
+ const signal = signals.length ? AbortSignal.any(signals) : void 0;
39
+ return {
40
+ options,
41
+ projectRoot,
42
+ artifactsDir,
43
+ timeout,
44
+ signal
45
+ };
46
+ }
47
+ //#endregion
48
+ //#region src/command.ts
49
+ async function runCommand({ command, cwd, env, artifactsDir, logName = "test", signal, onOutput }) {
50
+ signal?.throwIfAborted();
51
+ 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");
55
+ let err;
56
+ try {
57
+ err = openSync(stderr, "w");
58
+ const child = spawn(command[0], command.slice(1), {
59
+ cwd,
60
+ env,
61
+ detached: process.platform !== "win32",
62
+ stdio: [
63
+ "ignore",
64
+ "pipe",
65
+ "pipe"
66
+ ],
67
+ windowsHide: true
68
+ });
69
+ let error;
70
+ let groupError;
71
+ let killTimer;
72
+ const kill = (force) => {
73
+ if (!child.pid) return;
74
+ if (process.platform === "win32") spawn.sync("taskkill", [
75
+ "/pid",
76
+ String(child.pid),
77
+ "/t",
78
+ ...force ? ["/f"] : []
79
+ ], {
80
+ stdio: "ignore",
81
+ windowsHide: true
82
+ });
83
+ else try {
84
+ process.kill(-child.pid, force ? "SIGKILL" : "SIGTERM");
85
+ } catch (caught) {
86
+ if (caught.code !== "ESRCH") groupError ??= String(caught);
87
+ }
88
+ };
89
+ const terminate = () => {
90
+ if (killTimer) return;
91
+ kill(false);
92
+ killTimer = setTimeout(() => kill(true), 1e3);
93
+ };
94
+ const output = (stream, fd, chunk) => {
95
+ try {
96
+ writeSync(fd, chunk);
97
+ onOutput?.({
98
+ stream,
99
+ message: chunk.toString("utf8")
100
+ });
101
+ } catch (caught) {
102
+ error ??= String(caught);
103
+ terminate();
104
+ }
105
+ };
106
+ child.stdout.on("data", (chunk) => output("stdout", out, chunk));
107
+ child.stderr.on("data", (chunk) => output("stderr", err, chunk));
108
+ child.on("error", (caught) => {
109
+ error ??= caught.message;
110
+ });
111
+ child.once("exit", terminate);
112
+ signal?.addEventListener("abort", terminate, { once: true });
113
+ if (signal?.aborted) terminate();
114
+ try {
115
+ const status = await new Promise((resolve) => {
116
+ child.once("close", (exitCode, exitSignal) => {
117
+ resolve({
118
+ exitCode,
119
+ signal: exitSignal
120
+ });
121
+ });
122
+ });
123
+ if (process.platform !== "win32" && child.pid) {
124
+ const deadline = Date.now() + 2e3;
125
+ while (true) {
126
+ try {
127
+ process.kill(-child.pid, 0);
128
+ } catch (caught) {
129
+ const code = caught.code;
130
+ if (code === "ESRCH") break;
131
+ if (code !== "EPERM") {
132
+ error ??= String(caught);
133
+ break;
134
+ }
135
+ groupError ??= String(caught);
136
+ }
137
+ if (Date.now() >= deadline) {
138
+ error ??= groupError ?? `${logName === "artifact" ? "Artifact export" : "Test"} process group ${child.pid} did not exit after termination`;
139
+ break;
140
+ }
141
+ await setTimeout$1(20);
142
+ }
143
+ }
144
+ return {
145
+ ...status,
146
+ durationMs: Date.now() - started,
147
+ stdout,
148
+ stderr,
149
+ ...error ? { error } : {}
150
+ };
151
+ } finally {
152
+ signal?.removeEventListener("abort", terminate);
153
+ clearTimeout(killTimer);
154
+ }
155
+ } finally {
156
+ closeSync(out);
157
+ if (err !== void 0) closeSync(err);
158
+ }
159
+ }
160
+ //#endregion
161
+ //#region src/build.ts
162
+ /** Builds and exports an artifact without starting or stopping a workspace runtime. */
163
+ async function buildCI(input) {
164
+ const { options, projectRoot, artifactsDir, timeout, signal } = prepareCI(input);
165
+ const started = Date.now();
166
+ const result = {
167
+ version: 1,
168
+ stage: "build",
169
+ projectRoot,
170
+ platform: options.build.platform,
171
+ artifactsDir,
172
+ resultPath: join(artifactsDir, "result.json"),
173
+ buildPath: join(artifactsDir, "build.json"),
174
+ artifactPath: null,
175
+ startedAt: new Date(started).toISOString(),
176
+ durationMs: 0,
177
+ exitCode: 1,
178
+ build: null,
179
+ diagnostics: { path: null }
180
+ };
181
+ let reporting = false;
182
+ const onProgress = options.onProgress ? (event) => {
183
+ try {
184
+ options.onProgress(event);
185
+ } catch (error) {
186
+ result.reportingError ??= failure(error, "STIM_CI_REPORT_FAILED");
187
+ if (!reporting) throw error;
188
+ }
189
+ } : void 0;
190
+ const stim = createStim({
191
+ projectRoot,
192
+ home: options.home ? resolve(options.home) : void 0,
193
+ buildCache: options.buildCache ? resolve(options.buildCache) : void 0,
194
+ onProgress
195
+ });
196
+ let attempted = false;
197
+ let exporting;
198
+ try {
199
+ signal?.throwIfAborted();
200
+ attempted = true;
201
+ result.build = await stim.build({
202
+ ...options.build,
203
+ signal
204
+ });
205
+ 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 {
211
+ const bundle = result.build.platform === "ios" ? result.build.facts.appPath : result.build.facts.bundle;
212
+ exporting = join(artifactsDir, "app.tar.gz");
213
+ const packed = await runCommand({
214
+ command: [
215
+ "tar",
216
+ "-czf",
217
+ exporting,
218
+ "-C",
219
+ dirname(bundle),
220
+ "--",
221
+ basename(bundle)
222
+ ],
223
+ cwd: projectRoot,
224
+ env: {
225
+ ...process.env,
226
+ COPYFILE_DISABLE: "1"
227
+ },
228
+ artifactsDir,
229
+ logName: "artifact",
230
+ signal,
231
+ onOutput: onProgress
232
+ });
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
+ }
235
+ signal?.throwIfAborted();
236
+ result.artifactPath = exporting;
237
+ result.exitCode = 0;
238
+ } catch (error) {
239
+ result.failure = failure(error, result.build ? "STIM_CI_ARTIFACT_FAILED" : "STIM_CI_BUILD_FAILED");
240
+ if (result.build && !result.failure.code.startsWith("STIM_")) result.failure.code = "STIM_CI_ARTIFACT_FAILED";
241
+ } finally {
242
+ reporting = true;
243
+ if (signal?.aborted) {
244
+ const timedOut = timeout?.aborted && !options.signal?.aborted;
245
+ result.exitCode = timedOut ? 124 : 130;
246
+ result.failure = {
247
+ code: timedOut ? "STIM_CI_TIMEOUT" : "STIM_CI_CANCELLED",
248
+ message: timedOut ? `CI build exceeded ${options.timeoutMs}ms.` : "CI build was cancelled."
249
+ };
250
+ }
251
+ if (exporting && !result.artifactPath) try {
252
+ rmSync(exporting, { force: true });
253
+ } catch (error) {
254
+ result.reportingError ??= failure(error, "STIM_CI_REPORT_FAILED");
255
+ }
256
+ if (attempted) try {
257
+ const diagnostics = await stim.diagnostics({
258
+ tail: 1e3,
259
+ signal: AbortSignal.timeout(1e4)
260
+ });
261
+ result.diagnostics.path = join(artifactsDir, "diagnostics.json");
262
+ writeJson(result.diagnostics.path, diagnostics);
263
+ } catch (error) {
264
+ result.diagnostics.error = failure(error, "STIM_CI_DIAGNOSTICS_FAILED");
265
+ }
266
+ if (result.reportingError && result.exitCode === 0) {
267
+ result.exitCode = 1;
268
+ result.failure = result.reportingError;
269
+ }
270
+ result.durationMs = Date.now() - started;
271
+ try {
272
+ writeJson(result.resultPath, result);
273
+ } catch (error) {
274
+ result.reportingError = failure(error, "STIM_CI_REPORT_FAILED");
275
+ if (result.exitCode === 0) {
276
+ result.exitCode = 1;
277
+ result.failure = result.reportingError;
278
+ }
279
+ }
280
+ }
281
+ return result;
282
+ }
283
+ //#endregion
284
+ //#region src/index.ts
285
+ function commandEnvironment(result, options) {
286
+ const facts = result.run.facts;
287
+ return {
288
+ ...process.env,
289
+ ...options.home ? { STIM_HOME: resolve(options.home) } : {},
290
+ ...options.buildCache ? { STIM_BUILD_CACHE: resolve(options.buildCache) } : {},
291
+ STIM_CI_PLATFORM: result.platform,
292
+ STIM_CI_DEVICE_ID: String("udid" in facts ? facts.udid : "serial" in facts ? facts.serial ?? "" : ""),
293
+ STIM_CI_APP_ID: String("bundleId" in facts ? facts.bundleId ?? "" : ""),
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
297
+ };
298
+ }
299
+ /** Runs an app and argv-based test command, stops its workspace or explicit slot, and retains diagnostics. */
300
+ async function runCI(options) {
301
+ if (!options.command[0]) throw new Error("A non-empty test command is required.");
302
+ for (const argument of options.command) if (typeof argument !== "string" || argument.includes("\0")) throw new Error("Test command arguments must be strings without null bytes.");
303
+ const prepared = prepareCI(options);
304
+ options = prepared.options;
305
+ const { projectRoot, artifactsDir, timeout, signal } = prepared;
306
+ const started = Date.now();
307
+ const result = {
308
+ version: 1,
309
+ projectRoot,
310
+ platform: options.run.platform,
311
+ artifactsDir,
312
+ resultPath: join(artifactsDir, "result.json"),
313
+ runPath: join(artifactsDir, "run.json"),
314
+ startedAt: new Date(started).toISOString(),
315
+ durationMs: 0,
316
+ exitCode: 1,
317
+ run: null,
318
+ test: null,
319
+ diagnostics: { path: null },
320
+ cleanup: { result: null }
321
+ };
322
+ let cleaningUp = false;
323
+ const onProgress = options.onProgress ? (event) => {
324
+ try {
325
+ options.onProgress(event);
326
+ } catch (error) {
327
+ result.reportingError ??= failure(error, "STIM_CI_REPORT_FAILED");
328
+ if (!cleaningUp) throw error;
329
+ }
330
+ } : void 0;
331
+ const stim = createStim({
332
+ projectRoot,
333
+ home: options.home ? resolve(options.home) : void 0,
334
+ buildCache: options.buildCache ? resolve(options.buildCache) : void 0,
335
+ onProgress
336
+ });
337
+ let attempted = false;
338
+ try {
339
+ signal?.throwIfAborted();
340
+ attempted = true;
341
+ result.run = await stim.run({
342
+ ...options.run,
343
+ signal
344
+ });
345
+ signal?.throwIfAborted();
346
+ writeJson(result.runPath, result.run);
347
+ result.test = await runCommand({
348
+ command: options.command,
349
+ cwd: projectRoot,
350
+ env: commandEnvironment(result, options),
351
+ artifactsDir,
352
+ signal,
353
+ onOutput: onProgress
354
+ });
355
+ result.exitCode = result.test.exitCode ?? 1;
356
+ if (result.exitCode < 0 || result.exitCode === 0 && result.test.error) result.exitCode = 1;
357
+ if (result.exitCode !== 0) result.failure = {
358
+ code: "STIM_CI_TEST_FAILED",
359
+ message: result.test.error ?? `Test command ${result.test.signal ? `was terminated by ${result.test.signal}` : `exited with ${result.exitCode}`}.`
360
+ };
361
+ } catch (error) {
362
+ result.failure = failure(error, result.run ? "STIM_CI_TEST_FAILED" : "STIM_CI_SETUP_FAILED");
363
+ } finally {
364
+ cleaningUp = true;
365
+ if (signal?.aborted) {
366
+ const timedOut = timeout?.aborted && !options.signal?.aborted;
367
+ result.exitCode = timedOut ? 124 : 130;
368
+ result.failure = {
369
+ code: timedOut ? "STIM_CI_TIMEOUT" : "STIM_CI_CANCELLED",
370
+ message: timedOut ? `CI run exceeded ${options.timeoutMs}ms.` : "CI run was cancelled."
371
+ };
372
+ }
373
+ if (attempted) {
374
+ try {
375
+ result.cleanup.result = await stim.stop({
376
+ ..."slot" in options.run ? { slot: options.run.slot } : {},
377
+ signal: AbortSignal.timeout(6e4)
378
+ });
379
+ if (!result.cleanup.result.ok) result.cleanup.error = {
380
+ code: "STIM_CI_CLEANUP_FAILED",
381
+ message: "Stim could not stop the workspace."
382
+ };
383
+ } catch (error) {
384
+ result.cleanup.error = failure(error, "STIM_CI_CLEANUP_FAILED");
385
+ }
386
+ try {
387
+ const diagnostics = await stim.diagnostics({
388
+ tail: 1e3,
389
+ signal: AbortSignal.timeout(1e4)
390
+ });
391
+ const path = join(artifactsDir, "diagnostics.json");
392
+ writeJson(path, diagnostics);
393
+ result.diagnostics.path = path;
394
+ result.diagnostics.files = await copyDiagnosticLogs(diagnostics.directory, artifactsDir);
395
+ } catch (error) {
396
+ result.diagnostics.error = failure(error, "STIM_CI_DIAGNOSTICS_FAILED");
397
+ }
398
+ if (options.signal?.aborted && result.exitCode === 0) {
399
+ result.exitCode = 130;
400
+ result.failure = {
401
+ code: "STIM_CI_CANCELLED",
402
+ message: "CI run was cancelled."
403
+ };
404
+ }
405
+ if (result.cleanup.error && result.exitCode === 0) {
406
+ result.exitCode = 1;
407
+ result.failure = result.cleanup.error;
408
+ }
409
+ }
410
+ if (result.reportingError && result.exitCode === 0) {
411
+ result.exitCode = 1;
412
+ result.failure = result.reportingError;
413
+ }
414
+ result.durationMs = Date.now() - started;
415
+ try {
416
+ writeJson(result.resultPath, result);
417
+ } catch (error) {
418
+ result.reportingError = failure(error, "STIM_CI_REPORT_FAILED");
419
+ if (result.exitCode === 0) {
420
+ result.exitCode = 1;
421
+ result.failure = result.reportingError;
422
+ }
423
+ }
424
+ }
425
+ return result;
426
+ }
427
+ //#endregion
428
+ export { buildCI as n, runCI as t };
@@ -0,0 +1 @@
1
+ export {}
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ //#region bin/node-check.ts
3
+ const parts = process.versions.node.split(".").map(Number);
4
+ const major = parts[0] ?? 0;
5
+ const minor = parts[1] ?? 0;
6
+ if (major < 22 || major === 22 && minor < 12) {
7
+ process.stderr.write(`stim-ci requires Node.js 22.12.0 or later; this is ${process.versions.node}.\n`);
8
+ process.exitCode = 1;
9
+ } else import("./cli-oSziKUiL.mjs").then(({ main }) => main());
10
+ //#endregion
11
+ export {};
package/package.json CHANGED
@@ -1,7 +1,47 @@
1
1
  {
2
2
  "name": "@stim-cli/ci",
3
- "version": "0.0.0",
4
- "description": "Placeholder for trusted publishing setup. Install stim instead.",
3
+ "version": "1.20.0",
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
- "repository": { "type": "git", "url": "git+https://github.com/appandflow/stim.git" }
7
- }
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/appandflow/stim.git",
9
+ "directory": "packages/ci"
10
+ },
11
+ "bin": {
12
+ "stim-ci": "dist/stim-ci.mjs"
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "README.md",
17
+ "LICENSE"
18
+ ],
19
+ "type": "module",
20
+ "main": "dist/index.mjs",
21
+ "types": "dist/index.d.mts",
22
+ "exports": {
23
+ ".": {
24
+ "types": "./dist/index.d.mts",
25
+ "module-sync": "./dist/index.mjs",
26
+ "default": "./dist/index.mjs"
27
+ },
28
+ "./artifacts": {
29
+ "types": "./dist/artifacts.d.mts",
30
+ "module-sync": "./dist/artifacts.mjs",
31
+ "default": "./dist/artifacts.mjs"
32
+ }
33
+ },
34
+ "dependencies": {
35
+ "cross-spawn": "^7.0.6",
36
+ "stim": "^1.20.0"
37
+ },
38
+ "devDependencies": {
39
+ "@types/cross-spawn": "^6.0.6"
40
+ },
41
+ "engines": {
42
+ "node": ">=22.12.0"
43
+ },
44
+ "scripts": {
45
+ "build": "tsdown"
46
+ }
47
+ }