@stim-cli/ci 0.0.0-stage → 1.19.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,181 @@
1
- # Temporary Holding Version
1
+ # @stim-cli/ci
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+ ## Command line
14
+
15
+ Without installing:
16
+
17
+ ```sh
18
+ npx --yes --package @stim-cli/ci stim-ci run --platform ios --project ./app -- pnpm test:e2e
19
+ ```
20
+
21
+ Or install `npm install --global @stim-cli/ci`, then:
22
+
23
+ ```sh
24
+ stim-ci run --platform android --project ./app --artifacts ./test-results --timeout 1800 -- pnpm test:e2e
25
+ ```
26
+
27
+ `--platform` accepts `ios`, `android`, `macos`, or `web`. `--project` defaults
28
+ to the current directory. `--home` and `--build-cache` explicitly select the
29
+ Stim home and native artifact cache; leaving them unset preserves normal Stim
30
+ configuration. The CLI uses each project's normal build settings. The library
31
+ also accepts the public API's platform-specific run options.
32
+
33
+ Everything after `--` is an argument vector. No shell is inferred. To use shell
34
+ syntax, pass a shell explicitly: `-- bash -e -o pipefail -c 'pnpm test:e2e'`.
35
+
36
+ The test process runs in the project directory. It receives the caller's
37
+ environment plus these values:
38
+
39
+ | Variable | Value |
40
+ | ----------------------- | ------------------------------------------------------------ |
41
+ | `STIM_CI_PLATFORM` | Selected platform |
42
+ | `STIM_CI_DEVICE_ID` | Exact simulator UDID or emulator serial; empty for macOS/web |
43
+ | `STIM_CI_APP_ID` | App bundle/package identifier when available |
44
+ | `STIM_CI_METRO_PORT` | Metro port when available |
45
+ | `STIM_CI_ARTIFACTS_DIR` | Absolute results directory |
46
+ | `STIM_CI_RUN_RESULT` | Path to `run.json`, written before the command starts |
47
+
48
+ `run.json` contains the public Stim result `{ platform, facts }`, including
49
+ platform-specific process, target, cache, launch, and log facts. Use the exact
50
+ reported target; do not select an arbitrary booted device. Tests own
51
+ app-specific readiness checks. A launch can still be `bundling` or `unverified`.
52
+
53
+ Progress and test output go to stderr. Stdout contains one JSON result. The same
54
+ result is saved as `result.json` beside `run.json`, `test.stdout.log`,
55
+ `test.stderr.log`, and `diagnostics.json`. Without `--artifacts`, results use a
56
+ fresh temporary directory. An explicit results directory must be empty; a reused
57
+ directory is refused before setup so stale evidence cannot describe a new run.
58
+ Keep results outside fingerprinted project inputs.
59
+
60
+ The result includes `version: 1`, `projectRoot`, `platform`, `artifactsDir`,
61
+ `resultPath`, `runPath`, `startedAt`, `durationMs`, `exitCode`, the native `run`,
62
+ the `test` result, optional `failure`, `diagnostics`, and `cleanup`.
63
+ If a progress callback or writing `result.json` fails, the result includes
64
+ `reportingError`. Reporter failures cannot interrupt cleanup or replace an earlier
65
+ test failure; after a passing test, they return 1.
66
+ Setup failure leaves `run` and `test` null. Diagnostics include the last 1000
67
+ structured records and their original directory. After cleanup, regular `.log`
68
+ and `.ndjson` files (including `.1` rotated copies) directly inside that directory
69
+ are copied into `logs/` and listed in `diagnostics.files`. This retains raw
70
+ compiler and process output even when setup fails. These persisted files can
71
+ include earlier records from the same workspace. Collection does not follow
72
+ symlinks, nested directories, or paths referenced by log records. It does not
73
+ copy build binaries or caches. Collection failures appear in `diagnostics.error`
74
+ without replacing the setup or test exit code.
75
+
76
+ Providers can import `diagnosticArtifactFiles` from `@stim-cli/ci/artifacts` to
77
+ list the regular result, run, test and diagnostic files for upload. The helper
78
+ only selects those known files and the copied logs; custom test outputs need
79
+ the provider's own explicit upload configuration.
80
+
81
+ ## Library
82
+
83
+ ```ts
84
+ import { runCI } from '@stim-cli/ci';
85
+
86
+ const result = await runCI({
87
+ projectRoot: '/checkout/app',
88
+ run: { platform: 'ios', configuration: 'Debug' },
89
+ command: ['pnpm', 'test:e2e'],
90
+ artifactsDir: '/job/results',
91
+ timeoutMs: 30 * 60 * 1000,
92
+ signal: controller.signal,
93
+ onProgress: ({ message }) => process.stderr.write(message),
94
+ });
95
+
96
+ process.exitCode = result.exitCode;
97
+ ```
98
+
99
+ The library does not change `process.cwd()`, `process.env`, process signal
100
+ handlers, or the caller's exit code. Invalid arguments throw before native
101
+ setup. Setup, test, diagnostics, and cleanup outcomes are returned in the
102
+ result. The CLI alone handles SIGINT/SIGTERM and sets its exit code.
103
+
104
+ ## Failure and cancellation
105
+
106
+ A failing command preserves its exit code even when diagnostics or cleanup
107
+ also fail. A passing command followed by incomplete cleanup returns 1. The
108
+ result records both failures when relevant. Diagnostics failures are recorded
109
+ without replacing the setup or test result.
110
+
111
+ `timeoutMs` / `--timeout` covers setup and the test command; there is no timeout
112
+ by default. Timeout returns 124. Cancellation returns 130. Cancellation during
113
+ cleanup is also recorded, preserving an already completed test failure. Cancellation stops
114
+ the test process tree and aborts the native operation, then uses fresh signals
115
+ to stop the workspace (up to 60 seconds) and collect its persisted diagnostics
116
+ (up to 10 seconds). On macOS/Linux, the test command gets its own process group; children
117
+ that deliberately detach from it are outside that group. On Windows, tree
118
+ termination uses `taskkill`; commands must not leave background children after
119
+ their parent exits. Keep test resources inside the command's process tree.
120
+
121
+ Cleanup cannot run after SIGKILL, runner loss, or a provider's hard timeout.
122
+ Leave room between the package timeout and the job timeout. The result reports
123
+ incomplete cleanup; use normal Stim recovery for a persistent runner. `stop`
124
+ shuts down owned devices; it does not delete them.
125
+
126
+ ## Disposable and shared runners
127
+
128
+ GitHub-hosted jobs need no home or cache configuration. When `GITHUB_ACTIONS=true`,
129
+ `RUNNER_ENVIRONMENT=github-hosted` and `RUNNER_TEMP` is set, the package defaults to
130
+ `$RUNNER_TEMP/stim-ci/home` and `$RUNNER_TEMP/stim-ci/build-cache`. Repeated steps
131
+ in the same job reuse these paths. The public API and test command receive the
132
+ same paths without changing the caller's environment.
133
+
134
+ An explicit `home` / `--home` or `STIM_HOME` retains the caller's home and normal
135
+ cache configuration. An explicit `buildCache` / `--build-cache` or
136
+ `STIM_BUILD_CACHE` overrides the automatic cache path. Self-hosted runners,
137
+ other providers and local runs keep normal Stim configuration and configured
138
+ build/device caps. `CI=true` alone changes no defaults or coordination guarantees.
139
+
140
+ For another disposable runner dedicated to one job, explicitly provide a fresh
141
+ job home and a job-local artifact cache:
142
+
143
+ ```sh
144
+ stim-ci run --platform ios --project ./app \
145
+ --home "$RUNNER_TEMP/stim-home" \
146
+ --build-cache "$RUNNER_TEMP/stim-build-cache" \
147
+ --artifacts "$RUNNER_TEMP/stim-results" \
148
+ --timeout 1800 -- pnpm test:e2e
149
+ ```
150
+
151
+ Restore and save only the artifact cache through the provider. Never restore
152
+ workspace state, process records, claims, device ledgers, or pairings. Use cache
153
+ names that separate OS, architecture and platform; Stim still validates its
154
+ native fingerprint/configuration keys. Keep provider fallback keys within
155
+ compatible toolchains. Save complete entries, not staging directories, after
156
+ the runner finishes. Cache service failure should fall back to a build.
157
+
158
+ Do not point independent homes at a shared writable filesystem cache. Build
159
+ single-flight claims are scoped to the Stim home. Job-local restored copies
160
+ have no cross-job writers; persistent shared runners should use their normal
161
+ shared Stim home and coordination.
162
+
163
+ Current code already skips capacity admission when `concurrency.maxBuilds` and
164
+ `concurrency.maxDevices` are 0, their defaults. An explicit `STIM_HOME` defaults
165
+ device parking to 0. These are existing fast paths, not a new unsafe mode.
166
+ Ownership ledgers, atomic writes, process identity, cache publication, and
167
+ workspace claims still run. No timing study has established that this
168
+ bookkeeping is a material CI cost.
169
+
170
+ One concrete setup waste in Stim's existing Android fixture workflow is booting
171
+ an emulator through another action and immediately killing it before Stim
172
+ boots its own. SDK-only setup can avoid that extra boot without adopting an
173
+ external device. External simulator/emulator adoption is not part of this API.
174
+
175
+ Before adding coordination shortcuts, compare cold and warm runs by phase:
176
+ dependency setup, fingerprint/cache lookup, native build, device boot, install,
177
+ Metro, test, diagnostics, cleanup, and capacity waits. Validate a fresh-home
178
+ warm-cache run actually skips compilation, a deliberate test failure retains
179
+ its logs/exit code, cancellation leaves no live owned processes, and a shared
180
+ runner's unrelated workspace remains running. Native timings and cross-provider
181
+ 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,80 @@
1
+ import { t as runCI } from "./src-BdobdbxB.mjs";
2
+ import { parseArgs } from "node:util";
3
+ //#region src/cli.ts
4
+ const HELP = `Usage: stim-ci run --platform <ios|android|macos|web> [options] -- <command> [args...]
5
+
6
+ Build and launch an app, run a test command, collect diagnostics, and stop it.
7
+ The final result is JSON on stdout; progress and test output go to stderr.
8
+
9
+ Options:
10
+ --project <path> Project directory (default: current directory)
11
+ --artifacts <path> Results directory (default: a fresh temporary directory)
12
+ --home <path> Explicit Stim home (default: job-local on GitHub-hosted runners)
13
+ --build-cache <path> Native artifact cache directory
14
+ --timeout <seconds> Setup and test timeout (cleanup has its own deadline)
15
+ --help Show this help
16
+ --version Show the package version
17
+ `;
18
+ function parse(argv) {
19
+ const separator = argv.indexOf("--");
20
+ if (separator < 0) throw new Error("Separate the test command with --.");
21
+ const command = argv.slice(separator + 1);
22
+ if (!command[0]) throw new Error("A test command is required after --.");
23
+ const { values, positionals } = parseArgs({
24
+ args: argv.slice(0, separator),
25
+ allowPositionals: true,
26
+ options: {
27
+ platform: { type: "string" },
28
+ project: { type: "string" },
29
+ artifacts: { type: "string" },
30
+ home: { type: "string" },
31
+ "build-cache": { type: "string" },
32
+ timeout: { type: "string" }
33
+ }
34
+ });
35
+ if (positionals.length !== 1 || positionals[0] !== "run") throw new Error("Expected stim-ci run.");
36
+ const platform = values.platform;
37
+ if (platform !== "ios" && platform !== "android" && platform !== "macos" && platform !== "web") throw new Error("--platform must be ios, android, macos or web.");
38
+ const timeoutMs = values.timeout === void 0 ? void 0 : Number(values.timeout) * 1e3;
39
+ return {
40
+ projectRoot: values.project ?? process.cwd(),
41
+ run: { platform },
42
+ command,
43
+ artifactsDir: values.artifacts,
44
+ home: values.home,
45
+ buildCache: values["build-cache"],
46
+ timeoutMs
47
+ };
48
+ }
49
+ async function main(argv = process.argv.slice(2)) {
50
+ if (argv.length === 1 && argv[0] === "--version") {
51
+ process.stdout.write(`1.19.0\n`);
52
+ return;
53
+ }
54
+ if (argv.length === 0 || argv[0] === "--help" || argv[0] === "run" && argv[1] === "--help") {
55
+ process.stdout.write(HELP);
56
+ return;
57
+ }
58
+ const controller = new AbortController();
59
+ const interrupt = () => controller.abort();
60
+ try {
61
+ const options = parse(argv);
62
+ process.on("SIGINT", interrupt);
63
+ process.on("SIGTERM", interrupt);
64
+ const result = await runCI({
65
+ ...options,
66
+ signal: controller.signal,
67
+ onProgress: ({ message }) => process.stderr.write(message)
68
+ });
69
+ process.stdout.write(`${JSON.stringify(result)}\n`);
70
+ process.exitCode = result.exitCode;
71
+ } catch (error) {
72
+ process.stderr.write(`stim-ci: ${error.message}\n`);
73
+ process.exitCode = 1;
74
+ } finally {
75
+ process.off("SIGINT", interrupt);
76
+ process.off("SIGTERM", interrupt);
77
+ }
78
+ }
79
+ //#endregion
80
+ export { main };
@@ -0,0 +1,57 @@
1
+ import { StimOptions, StimRunOptions, StimRunResult, StimStopResult } from "stim";
2
+ //#region src/command.d.ts
3
+ interface CommandResult {
4
+ exitCode: number | null;
5
+ signal: NodeJS.Signals | null;
6
+ durationMs: number;
7
+ stdout: string;
8
+ stderr: string;
9
+ error?: string;
10
+ }
11
+ //#endregion
12
+ //#region src/index.d.ts
13
+ type WithoutSignal<T> = T extends unknown ? Omit<T, "signal"> : never;
14
+ interface CIOptions {
15
+ projectRoot: string;
16
+ run: WithoutSignal<StimRunOptions>;
17
+ command: readonly [string, ...string[]];
18
+ artifactsDir?: string;
19
+ home?: string;
20
+ buildCache?: string;
21
+ timeoutMs?: number;
22
+ signal?: AbortSignal;
23
+ onProgress?: StimOptions["onProgress"];
24
+ }
25
+ interface CIFailure {
26
+ code: string;
27
+ message: string;
28
+ remedy?: string;
29
+ }
30
+ interface CIResult {
31
+ version: 1;
32
+ projectRoot: string;
33
+ platform: StimRunOptions["platform"];
34
+ artifactsDir: string;
35
+ resultPath: string;
36
+ runPath: string;
37
+ startedAt: string;
38
+ durationMs: number;
39
+ exitCode: number;
40
+ run: StimRunResult | null;
41
+ test: CommandResult | null;
42
+ failure?: CIFailure;
43
+ reportingError?: CIFailure;
44
+ diagnostics: {
45
+ path: string | null;
46
+ files?: string[];
47
+ error?: CIFailure;
48
+ };
49
+ cleanup: {
50
+ result: StimStopResult | null;
51
+ error?: CIFailure;
52
+ };
53
+ }
54
+ /** Runs an app and argv-based test command, stops its workspace or explicit slot, and retains diagnostics. */
55
+ declare function runCI(options: CIOptions): Promise<CIResult>;
56
+ //#endregion
57
+ export { CIFailure, CIOptions, CIResult, type CommandResult, runCI };
package/dist/index.mjs ADDED
@@ -0,0 +1,2 @@
1
+ import { t as runCI } from "./src-BdobdbxB.mjs";
2
+ export { runCI };
@@ -0,0 +1,291 @@
1
+ import { copyDiagnosticLogs } from "./artifacts.mjs";
2
+ import { closeSync, mkdirSync, mkdtempSync, openSync, readdirSync, realpathSync, renameSync, writeFileSync, writeSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { 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/command.ts
9
+ async function runCommand({ command, cwd, env, artifactsDir, signal, onOutput }) {
10
+ signal?.throwIfAborted();
11
+ const started = Date.now();
12
+ const stdout = join(artifactsDir, "test.stdout.log");
13
+ const stderr = join(artifactsDir, "test.stderr.log");
14
+ const out = openSync(stdout, "w");
15
+ let err;
16
+ try {
17
+ err = openSync(stderr, "w");
18
+ const child = spawn(command[0], command.slice(1), {
19
+ cwd,
20
+ env,
21
+ detached: process.platform !== "win32",
22
+ stdio: [
23
+ "ignore",
24
+ "pipe",
25
+ "pipe"
26
+ ],
27
+ windowsHide: true
28
+ });
29
+ let error;
30
+ let groupError;
31
+ let killTimer;
32
+ const kill = (force) => {
33
+ if (!child.pid) return;
34
+ if (process.platform === "win32") spawn.sync("taskkill", [
35
+ "/pid",
36
+ String(child.pid),
37
+ "/t",
38
+ ...force ? ["/f"] : []
39
+ ], {
40
+ stdio: "ignore",
41
+ windowsHide: true
42
+ });
43
+ else try {
44
+ process.kill(-child.pid, force ? "SIGKILL" : "SIGTERM");
45
+ } catch (caught) {
46
+ if (caught.code !== "ESRCH") groupError ??= String(caught);
47
+ }
48
+ };
49
+ const terminate = () => {
50
+ if (killTimer) return;
51
+ kill(false);
52
+ killTimer = setTimeout(() => kill(true), 1e3);
53
+ };
54
+ const output = (stream, fd, chunk) => {
55
+ try {
56
+ writeSync(fd, chunk);
57
+ onOutput?.({
58
+ stream,
59
+ message: chunk.toString("utf8")
60
+ });
61
+ } catch (caught) {
62
+ error ??= String(caught);
63
+ terminate();
64
+ }
65
+ };
66
+ child.stdout.on("data", (chunk) => output("stdout", out, chunk));
67
+ child.stderr.on("data", (chunk) => output("stderr", err, chunk));
68
+ child.on("error", (caught) => {
69
+ error ??= caught.message;
70
+ });
71
+ child.once("exit", terminate);
72
+ signal?.addEventListener("abort", terminate, { once: true });
73
+ if (signal?.aborted) terminate();
74
+ try {
75
+ const status = await new Promise((resolve) => {
76
+ child.once("close", (exitCode, exitSignal) => {
77
+ resolve({
78
+ exitCode,
79
+ signal: exitSignal
80
+ });
81
+ });
82
+ });
83
+ if (process.platform !== "win32" && child.pid) {
84
+ const deadline = Date.now() + 2e3;
85
+ while (true) {
86
+ try {
87
+ process.kill(-child.pid, 0);
88
+ } catch (caught) {
89
+ const code = caught.code;
90
+ if (code === "ESRCH") break;
91
+ if (code !== "EPERM") {
92
+ error ??= String(caught);
93
+ break;
94
+ }
95
+ groupError ??= String(caught);
96
+ }
97
+ if (Date.now() >= deadline) {
98
+ error ??= groupError ?? `Test process group ${child.pid} did not exit after termination`;
99
+ break;
100
+ }
101
+ await setTimeout$1(20);
102
+ }
103
+ }
104
+ return {
105
+ ...status,
106
+ durationMs: Date.now() - started,
107
+ stdout,
108
+ stderr,
109
+ ...error ? { error } : {}
110
+ };
111
+ } finally {
112
+ signal?.removeEventListener("abort", terminate);
113
+ clearTimeout(killTimer);
114
+ }
115
+ } finally {
116
+ closeSync(out);
117
+ if (err !== void 0) closeSync(err);
118
+ }
119
+ }
120
+ //#endregion
121
+ //#region src/index.ts
122
+ function failure(error, code) {
123
+ const caught = error;
124
+ return {
125
+ code: typeof caught?.code === "string" ? caught.code : code,
126
+ message: typeof caught?.message === "string" ? caught.message : String(error),
127
+ ...typeof caught?.remedy === "string" ? { remedy: caught.remedy } : {}
128
+ };
129
+ }
130
+ function writeJson(path, value) {
131
+ const pending = `${path}.tmp`;
132
+ writeFileSync(pending, `${JSON.stringify(value, null, 2)}\n`);
133
+ renameSync(pending, path);
134
+ }
135
+ function commandEnvironment(result, options) {
136
+ const facts = result.run.facts;
137
+ return {
138
+ ...process.env,
139
+ ...options.home ? { STIM_HOME: resolve(options.home) } : {},
140
+ ...options.buildCache ? { STIM_BUILD_CACHE: resolve(options.buildCache) } : {},
141
+ STIM_CI_PLATFORM: result.platform,
142
+ STIM_CI_DEVICE_ID: String("udid" in facts ? facts.udid : "serial" in facts ? facts.serial ?? "" : ""),
143
+ STIM_CI_APP_ID: String("bundleId" in facts ? facts.bundleId ?? "" : ""),
144
+ STIM_CI_METRO_PORT: String("metroPort" in facts ? facts.metroPort ?? "" : ""),
145
+ STIM_CI_ARTIFACTS_DIR: result.artifactsDir,
146
+ STIM_CI_RUN_RESULT: result.runPath
147
+ };
148
+ }
149
+ /** Runs an app and argv-based test command, stops its workspace or explicit slot, and retains diagnostics. */
150
+ async function runCI(options) {
151
+ if (!options.command[0]) throw new Error("A non-empty test command is required.");
152
+ 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.");
153
+ if (options.timeoutMs !== void 0 && (!Number.isSafeInteger(options.timeoutMs) || options.timeoutMs <= 0)) throw new Error("timeoutMs must be a positive integer.");
154
+ if (process.env.GITHUB_ACTIONS === "true" && process.env.RUNNER_ENVIRONMENT === "github-hosted" && process.env.RUNNER_TEMP && !options.home && !process.env.STIM_HOME) {
155
+ const directory = join(resolve(process.env.RUNNER_TEMP), "stim-ci");
156
+ options = {
157
+ ...options,
158
+ home: join(directory, "home"),
159
+ buildCache: options.buildCache || (process.env.STIM_BUILD_CACHE ? void 0 : join(directory, "build-cache"))
160
+ };
161
+ }
162
+ const projectRoot = realpathSync(options.projectRoot);
163
+ const artifactsDir = options.artifactsDir ? resolve(options.artifactsDir) : mkdtempSync(join(tmpdir(), "stim-ci-"));
164
+ mkdirSync(artifactsDir, { recursive: true });
165
+ if (readdirSync(artifactsDir).length > 0) throw new Error(`Artifacts directory must be empty: ${artifactsDir}. Choose a new directory for this run.`);
166
+ const timeout = options.timeoutMs === void 0 ? void 0 : AbortSignal.timeout(options.timeoutMs);
167
+ const signals = [options.signal, timeout].filter((value) => value !== void 0);
168
+ const signal = signals.length ? AbortSignal.any(signals) : void 0;
169
+ const started = Date.now();
170
+ const result = {
171
+ version: 1,
172
+ projectRoot,
173
+ platform: options.run.platform,
174
+ artifactsDir,
175
+ resultPath: join(artifactsDir, "result.json"),
176
+ runPath: join(artifactsDir, "run.json"),
177
+ startedAt: new Date(started).toISOString(),
178
+ durationMs: 0,
179
+ exitCode: 1,
180
+ run: null,
181
+ test: null,
182
+ diagnostics: { path: null },
183
+ cleanup: { result: null }
184
+ };
185
+ let cleaningUp = false;
186
+ const onProgress = options.onProgress ? (event) => {
187
+ try {
188
+ options.onProgress(event);
189
+ } catch (error) {
190
+ result.reportingError ??= failure(error, "STIM_CI_REPORT_FAILED");
191
+ if (!cleaningUp) throw error;
192
+ }
193
+ } : void 0;
194
+ const stim = createStim({
195
+ projectRoot,
196
+ home: options.home ? resolve(options.home) : void 0,
197
+ buildCache: options.buildCache ? resolve(options.buildCache) : void 0,
198
+ onProgress
199
+ });
200
+ let attempted = false;
201
+ try {
202
+ signal?.throwIfAborted();
203
+ attempted = true;
204
+ result.run = await stim.run({
205
+ ...options.run,
206
+ signal
207
+ });
208
+ signal?.throwIfAborted();
209
+ writeJson(result.runPath, result.run);
210
+ result.test = await runCommand({
211
+ command: options.command,
212
+ cwd: projectRoot,
213
+ env: commandEnvironment(result, options),
214
+ artifactsDir,
215
+ signal,
216
+ onOutput: onProgress
217
+ });
218
+ result.exitCode = result.test.exitCode ?? 1;
219
+ if (result.exitCode < 0 || result.exitCode === 0 && result.test.error) result.exitCode = 1;
220
+ if (result.exitCode !== 0) result.failure = {
221
+ code: "STIM_CI_TEST_FAILED",
222
+ message: result.test.error ?? `Test command ${result.test.signal ? `was terminated by ${result.test.signal}` : `exited with ${result.exitCode}`}.`
223
+ };
224
+ } catch (error) {
225
+ result.failure = failure(error, result.run ? "STIM_CI_TEST_FAILED" : "STIM_CI_SETUP_FAILED");
226
+ } finally {
227
+ cleaningUp = true;
228
+ if (signal?.aborted) {
229
+ const timedOut = timeout?.aborted && !options.signal?.aborted;
230
+ result.exitCode = timedOut ? 124 : 130;
231
+ result.failure = {
232
+ code: timedOut ? "STIM_CI_TIMEOUT" : "STIM_CI_CANCELLED",
233
+ message: timedOut ? `CI run exceeded ${options.timeoutMs}ms.` : "CI run was cancelled."
234
+ };
235
+ }
236
+ if (attempted) {
237
+ try {
238
+ result.cleanup.result = await stim.stop({
239
+ ..."slot" in options.run ? { slot: options.run.slot } : {},
240
+ signal: AbortSignal.timeout(6e4)
241
+ });
242
+ if (!result.cleanup.result.ok) result.cleanup.error = {
243
+ code: "STIM_CI_CLEANUP_FAILED",
244
+ message: "Stim could not stop the workspace."
245
+ };
246
+ } catch (error) {
247
+ result.cleanup.error = failure(error, "STIM_CI_CLEANUP_FAILED");
248
+ }
249
+ try {
250
+ const diagnostics = await stim.diagnostics({
251
+ tail: 1e3,
252
+ signal: AbortSignal.timeout(1e4)
253
+ });
254
+ const path = join(artifactsDir, "diagnostics.json");
255
+ writeJson(path, diagnostics);
256
+ result.diagnostics.path = path;
257
+ result.diagnostics.files = await copyDiagnosticLogs(diagnostics.directory, artifactsDir);
258
+ } catch (error) {
259
+ result.diagnostics.error = failure(error, "STIM_CI_DIAGNOSTICS_FAILED");
260
+ }
261
+ if (options.signal?.aborted && result.exitCode === 0) {
262
+ result.exitCode = 130;
263
+ result.failure = {
264
+ code: "STIM_CI_CANCELLED",
265
+ message: "CI run was cancelled."
266
+ };
267
+ }
268
+ if (result.cleanup.error && result.exitCode === 0) {
269
+ result.exitCode = 1;
270
+ result.failure = result.cleanup.error;
271
+ }
272
+ }
273
+ if (result.reportingError && result.exitCode === 0) {
274
+ result.exitCode = 1;
275
+ result.failure = result.reportingError;
276
+ }
277
+ result.durationMs = Date.now() - started;
278
+ try {
279
+ writeJson(result.resultPath, result);
280
+ } catch (error) {
281
+ result.reportingError = failure(error, "STIM_CI_REPORT_FAILED");
282
+ if (result.exitCode === 0) {
283
+ result.exitCode = 1;
284
+ result.failure = result.reportingError;
285
+ }
286
+ }
287
+ }
288
+ return result;
289
+ }
290
+ //#endregion
291
+ export { 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-RvPAkat7.mjs").then(({ main }) => main());
10
+ //#endregion
11
+ export {};
package/package.json CHANGED
@@ -1,6 +1,47 @@
1
1
  {
2
2
  "name": "@stim-cli/ci",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "1.19.0",
4
+ "description": "Run Stim apps and test commands in CI with diagnostics and scoped cleanup.",
5
+ "license": "MIT",
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.19.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
+ }
6
47
  }