shippilot 0.0.1

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.
Files changed (66) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/LICENSE +21 -0
  3. package/README.md +241 -0
  4. package/dist/auth/prepareCodexHome.d.ts +7 -0
  5. package/dist/auth/prepareCodexHome.js +52 -0
  6. package/dist/auth/prepareCodexHome.js.map +1 -0
  7. package/dist/auth/validateAuth.d.ts +2 -0
  8. package/dist/auth/validateAuth.js +19 -0
  9. package/dist/auth/validateAuth.js.map +1 -0
  10. package/dist/cases/parseCase.d.ts +13 -0
  11. package/dist/cases/parseCase.js +21 -0
  12. package/dist/cases/parseCase.js.map +1 -0
  13. package/dist/cases/resolveEnv.d.ts +6 -0
  14. package/dist/cases/resolveEnv.js +19 -0
  15. package/dist/cases/resolveEnv.js.map +1 -0
  16. package/dist/cli.d.ts +2 -0
  17. package/dist/cli.js +219 -0
  18. package/dist/cli.js.map +1 -0
  19. package/dist/codex/outputSchema.d.ts +72 -0
  20. package/dist/codex/outputSchema.js +48 -0
  21. package/dist/codex/outputSchema.js.map +1 -0
  22. package/dist/codex/promptBuilder.d.ts +8 -0
  23. package/dist/codex/promptBuilder.js +48 -0
  24. package/dist/codex/promptBuilder.js.map +1 -0
  25. package/dist/codex/runWithSdk.d.ts +11 -0
  26. package/dist/codex/runWithSdk.js +396 -0
  27. package/dist/codex/runWithSdk.js.map +1 -0
  28. package/dist/config/loadConfig.d.ts +2 -0
  29. package/dist/config/loadConfig.js +13 -0
  30. package/dist/config/loadConfig.js.map +1 -0
  31. package/dist/config/schema.d.ts +43 -0
  32. package/dist/config/schema.js +71 -0
  33. package/dist/config/schema.js.map +1 -0
  34. package/dist/exitCodes.d.ts +7 -0
  35. package/dist/exitCodes.js +7 -0
  36. package/dist/exitCodes.js.map +1 -0
  37. package/dist/ios/doctorXcode.d.ts +3 -0
  38. package/dist/ios/doctorXcode.js +33 -0
  39. package/dist/ios/doctorXcode.js.map +1 -0
  40. package/dist/ios/xcodebuildmcp.d.ts +15 -0
  41. package/dist/ios/xcodebuildmcp.js +95 -0
  42. package/dist/ios/xcodebuildmcp.js.map +1 -0
  43. package/dist/reports/jsonReport.d.ts +11 -0
  44. package/dist/reports/jsonReport.js +27 -0
  45. package/dist/reports/jsonReport.js.map +1 -0
  46. package/dist/reports/junitReport.d.ts +4 -0
  47. package/dist/reports/junitReport.js +39 -0
  48. package/dist/reports/junitReport.js.map +1 -0
  49. package/dist/reports/markdownReport.d.ts +4 -0
  50. package/dist/reports/markdownReport.js +46 -0
  51. package/dist/reports/markdownReport.js.map +1 -0
  52. package/dist/security/redact.d.ts +5 -0
  53. package/dist/security/redact.js +12 -0
  54. package/dist/security/redact.js.map +1 -0
  55. package/docs/auth.md +47 -0
  56. package/docs/bitrise.md +22 -0
  57. package/docs/github-actions.md +47 -0
  58. package/docs/personal-chatgpt-subscription.md +31 -0
  59. package/docs/plan.md +55 -0
  60. package/docs/release.md +87 -0
  61. package/docs/security.md +39 -0
  62. package/examples/bitrise/shippilot.sh +6 -0
  63. package/examples/github-actions/shippilot.yml +39 -0
  64. package/examples/qa/login.md +15 -0
  65. package/examples/shippilot.yml +24 -0
  66. package/package.json +51 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,15 @@
1
+ # Changelog
2
+
3
+ All notable changes to ShipPilot will be documented in this file.
4
+
5
+ ## 0.0.1 - 2026-05-31
6
+
7
+ ### Added
8
+
9
+ - Initial public npm release of the `shippilot` CLI.
10
+ - Markdown QA case parsing with required environment variable checks.
11
+ - Codex SDK execution path for agentic iOS QA runs.
12
+ - XcodeBuildMCP-backed iOS simulator doctor and run support.
13
+ - JSON, Markdown, and JUnit report output.
14
+ - GitHub Actions and Bitrise usage examples.
15
+ - npm release workflow for future trusted publishing releases.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mahmoud Ashraf
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 ADDED
@@ -0,0 +1,241 @@
1
+ <p align="center">
2
+ <img src="assets/shippilot-icon.png" alt="ShipPilot icon" width="180">
3
+ </p>
4
+
5
+ # ShipPilot
6
+
7
+ [![CI](https://github.com/mahmoudashraf93/ShipPilot/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/mahmoudashraf93/ShipPilot/actions/workflows/ci.yml)
8
+ [![npm version](https://img.shields.io/npm/v/shippilot.svg)](https://www.npmjs.com/package/shippilot)
9
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
10
+
11
+ ShipPilot is an open-source agentic QA runner for mobile apps. v1 focuses on iOS simulator testing: teams write Markdown QA cases, run them from GitHub Actions, Bitrise, or local CI, and fail the pipeline when Codex cannot verify expected app behavior.
12
+
13
+ ShipPilot is intentionally test-and-report only. It does not edit source files, create patches, commit, push, or open pull requests.
14
+
15
+ ## Quick Start
16
+
17
+ Prerequisites:
18
+
19
+ - macOS with Xcode for iOS simulator runs.
20
+ - Node.js 20+.
21
+ - One supported Codex auth mode.
22
+
23
+ ```bash
24
+ npx shippilot init
25
+ npx shippilot doctor
26
+ npx shippilot run --case qa/login.md --verbose
27
+ ```
28
+
29
+ ## Usage Guide
30
+
31
+ ShipPilot reads `shippilot.yml` by default.
32
+
33
+ ```yaml
34
+ codex:
35
+ engine: sdk
36
+ auth: api_key # api_key | access_token | chatgpt_hosted_experimental
37
+ model: default
38
+ sandbox: danger-full-access
39
+ fail_on: failed_or_blocked
40
+ verbose: false
41
+ allow_experimental_personal_hosted_auth: false
42
+
43
+ ios:
44
+ project: MyApp.xcodeproj
45
+ bundle_id:
46
+ scheme: MyApp
47
+ simulator: iPhone 17 Pro
48
+ backend: xcodebuildmcp
49
+ configuration: Debug
50
+
51
+ reports:
52
+ output_dir: .shippilot
53
+ markdown: true
54
+ json: true
55
+ junit: true
56
+ screenshots: true
57
+ logs: true
58
+ ```
59
+
60
+ Use either `ios.project` or `ios.workspace`, not both. Add `ios.bundle_id` when available; it avoids a separate bundle-id discovery step during CI.
61
+
62
+ ### QA Cases
63
+
64
+ QA cases are Markdown files with YAML front matter:
65
+
66
+ ```md
67
+ ---
68
+ id: login-happy-path
69
+ title: Login happy path
70
+ required_env:
71
+ - TEST_EMAIL
72
+ - TEST_PASSWORD
73
+ tags:
74
+ - release
75
+ - smoke
76
+ ---
77
+
78
+ Launch the app.
79
+ Dismiss onboarding, permission prompts, or paywalls if they appear.
80
+ Enter `${TEST_EMAIL}` and `${TEST_PASSWORD}`.
81
+ Tap Log In.
82
+ Expect the Home screen to be visible.
83
+ ```
84
+
85
+ Environment placeholders are resolved at runtime and redacted from prompts, logs, reports, and artifacts. Declare every secret placeholder in `required_env`; missing required variables fail setup before the agent runs.
86
+
87
+ ### Running Cases
88
+
89
+ ```bash
90
+ npx shippilot doctor
91
+ npx shippilot run --case qa/login.md
92
+ npx shippilot run --cases qa/
93
+ ```
94
+
95
+ Use verbose mode while developing or debugging CI:
96
+
97
+ ```bash
98
+ npx shippilot run --case qa/login.md --verbose
99
+ ```
100
+
101
+ Verbose mode streams XcodeBuildMCP output and Codex SDK events, including progress, tool calls, command executions, reasoning summaries, errors, and token usage. It does not expose private model chain-of-thought.
102
+
103
+ ## Auth Modes
104
+
105
+ Choose one auth mode:
106
+
107
+ - `api_key`: uses `OPENAI_API_KEY`. Recommended for hosted CI and open-source projects.
108
+ - `access_token`: uses `CODEX_ACCESS_TOKEN`. Recommended for trusted Business/Enterprise automation.
109
+ - `chatgpt_hosted_experimental`: restores a pre-authenticated Codex home from `CODEX_HOME_TGZ_BASE64`. This is only for experimental personal ChatGPT subscription use on trusted runners.
110
+
111
+ The personal ChatGPT hosted-runner path is sensitive and fragile. Do not cache or upload restored auth directories, and do not run it on arbitrary fork PRs.
112
+
113
+ ## Simulator Access And Security
114
+
115
+ For iOS simulator UI automation, use:
116
+
117
+ ```yaml
118
+ codex:
119
+ sandbox: danger-full-access
120
+ ```
121
+
122
+ XcodeBuildMCP needs access to CoreSimulator services outside the repository workspace. More restrictive sandbox modes can build the app but fail once the agent tries to inspect UI, tap controls, or capture screenshots.
123
+
124
+ Run ShipPilot only in trusted workflows when secrets are present. For open-source repositories, prefer `workflow_dispatch`, releases, schedules, or maintainer-approved workflows. Use `actions/checkout` with `persist-credentials: false`.
125
+
126
+ ## GitHub Actions
127
+
128
+ ```yaml
129
+ name: ShipPilot QA
130
+
131
+ on:
132
+ workflow_dispatch:
133
+ release:
134
+ types: [published]
135
+
136
+ jobs:
137
+ shippilot:
138
+ runs-on: macos-15
139
+ permissions:
140
+ contents: read
141
+ steps:
142
+ - uses: actions/checkout@v5
143
+ with:
144
+ persist-credentials: false
145
+
146
+ - uses: actions/setup-node@v4
147
+ with:
148
+ node-version: 22
149
+
150
+ - name: Run ShipPilot
151
+ env:
152
+ OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
153
+ CODEX_ACCESS_TOKEN: ${{ secrets.CODEX_ACCESS_TOKEN }}
154
+ CODEX_HOME_TGZ_BASE64: ${{ secrets.CODEX_HOME_TGZ_BASE64 }}
155
+ TEST_EMAIL: ${{ secrets.TEST_EMAIL }}
156
+ TEST_PASSWORD: ${{ secrets.TEST_PASSWORD }}
157
+ run: |
158
+ npx shippilot doctor
159
+ npx shippilot run --case qa/login.md --verbose
160
+
161
+ - name: Upload ShipPilot report
162
+ if: always()
163
+ uses: actions/upload-artifact@v4
164
+ with:
165
+ name: shippilot-report
166
+ path: .shippilot/
167
+ include-hidden-files: true
168
+ ```
169
+
170
+ ## Bitrise
171
+
172
+ Use a macOS stack with Xcode and add a Script Step:
173
+
174
+ ```bash
175
+ #!/usr/bin/env bash
176
+ set -euo pipefail
177
+
178
+ npm install -g shippilot
179
+ shippilot doctor
180
+ shippilot run --case qa/login.md --verbose
181
+ ```
182
+
183
+ Upload `.shippilot/` as build artifacts so failed QA runs still leave reports and screenshots.
184
+
185
+ ## Reports And Exit Codes
186
+
187
+ Reports are written to `.shippilot/`:
188
+
189
+ ```text
190
+ .shippilot/
191
+ run.json
192
+ report.md
193
+ junit.xml
194
+ logs/
195
+ screenshots/
196
+ ```
197
+
198
+ ShipPilot exits like a test runner:
199
+
200
+ - `0`: all cases passed
201
+ - `1`: at least one case failed
202
+ - `2`: setup/auth/project/simulator/config error
203
+ - `3`: at least one case was blocked or inconclusive
204
+
205
+ Set `codex.fail_on: never` for report-only mode.
206
+
207
+ ## Contribution Guide
208
+
209
+ Contributions should keep ShipPilot test-and-report focused. Avoid features that let the CI agent edit source, commit, push, or open PRs unless that behavior is explicitly designed behind a separate mode.
210
+
211
+ Before opening a PR:
212
+
213
+ ```bash
214
+ npm run build
215
+ npm test
216
+ ```
217
+
218
+ Useful areas to improve:
219
+
220
+ - config validation and clearer doctor checks
221
+ - QA case parsing and secret redaction
222
+ - report quality and artifact collection
223
+ - XcodeBuildMCP integration robustness
224
+ - CI examples for common hosted runners
225
+
226
+ ## Roadmap
227
+
228
+ - Add richer screenshot and log attachments to reports.
229
+ - Add sample app integration tests.
230
+ - Add Bitrise Step packaging.
231
+ - Add Android support.
232
+
233
+ ## Documentation
234
+
235
+ - [Full plan](docs/plan.md)
236
+ - [Auth modes](docs/auth.md)
237
+ - [GitHub Actions](docs/github-actions.md)
238
+ - [Bitrise](docs/bitrise.md)
239
+ - [Release process](docs/release.md)
240
+ - [Personal ChatGPT subscription](docs/personal-chatgpt-subscription.md)
241
+ - [Security](docs/security.md)
@@ -0,0 +1,7 @@
1
+ import type { ShipPilotConfig } from "../config/schema.js";
2
+ export type PreparedAuth = {
3
+ apiKey?: string;
4
+ env: Record<string, string>;
5
+ cleanup: () => void;
6
+ };
7
+ export declare function prepareCodexAuth(config: ShipPilotConfig, env?: NodeJS.ProcessEnv): PreparedAuth;
@@ -0,0 +1,52 @@
1
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { spawnSync } from "node:child_process";
5
+ function stringEnv(env = process.env) {
6
+ return Object.fromEntries(Object.entries(env).filter((entry) => typeof entry[1] === "string"));
7
+ }
8
+ function makeTempCodexHome() {
9
+ return mkdtempSync(path.join(os.tmpdir(), "shippilot-codex-home-"));
10
+ }
11
+ export function prepareCodexAuth(config, env = process.env) {
12
+ const baseEnv = stringEnv(env);
13
+ if (config.codex.auth === "api_key") {
14
+ return {
15
+ apiKey: env.OPENAI_API_KEY,
16
+ env: baseEnv,
17
+ cleanup: () => undefined,
18
+ };
19
+ }
20
+ if (config.codex.auth === "access_token") {
21
+ const codexHome = makeTempCodexHome();
22
+ const result = spawnSync("codex", ["login", "--with-access-token"], {
23
+ input: env.CODEX_ACCESS_TOKEN,
24
+ encoding: "utf8",
25
+ env: { ...baseEnv, CODEX_HOME: codexHome },
26
+ });
27
+ if (result.status !== 0) {
28
+ rmSync(codexHome, { recursive: true, force: true });
29
+ throw new Error(`codex login --with-access-token failed: ${result.stderr || result.stdout}`);
30
+ }
31
+ return {
32
+ env: { ...baseEnv, CODEX_HOME: codexHome },
33
+ cleanup: () => rmSync(codexHome, { recursive: true, force: true }),
34
+ };
35
+ }
36
+ const codexHome = makeTempCodexHome();
37
+ const archivePath = path.join(os.tmpdir(), `shippilot-codex-home-${Date.now()}.tgz`);
38
+ writeFileSync(archivePath, Buffer.from(String(env.CODEX_HOME_TGZ_BASE64), "base64"));
39
+ const result = spawnSync("tar", ["-xzf", archivePath, "-C", codexHome, "--strip-components=1"], {
40
+ encoding: "utf8",
41
+ });
42
+ rmSync(archivePath, { force: true });
43
+ if (result.status !== 0) {
44
+ rmSync(codexHome, { recursive: true, force: true });
45
+ throw new Error(`Failed to restore CODEX_HOME_TGZ_BASE64: ${result.stderr || result.stdout}`);
46
+ }
47
+ return {
48
+ env: { ...baseEnv, CODEX_HOME: codexHome },
49
+ cleanup: () => rmSync(codexHome, { recursive: true, force: true }),
50
+ };
51
+ }
52
+ //# sourceMappingURL=prepareCodexHome.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"prepareCodexHome.js","sourceRoot":"","sources":["../../src/auth/prepareCodexHome.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ import type { ShipPilotConfig } from "../config/schema.js";
2
+ export declare function validateAuthConfig(config: ShipPilotConfig, env?: NodeJS.ProcessEnv): string[];
@@ -0,0 +1,19 @@
1
+ export function validateAuthConfig(config, env = process.env) {
2
+ const issues = [];
3
+ if (config.codex.auth === "api_key" && !env.OPENAI_API_KEY) {
4
+ issues.push("OPENAI_API_KEY is required for codex.auth: api_key.");
5
+ }
6
+ if (config.codex.auth === "access_token" && !env.CODEX_ACCESS_TOKEN) {
7
+ issues.push("CODEX_ACCESS_TOKEN is required for codex.auth: access_token.");
8
+ }
9
+ if (config.codex.auth === "chatgpt_hosted_experimental") {
10
+ if (!config.codex.allow_experimental_personal_hosted_auth) {
11
+ issues.push("chatgpt_hosted_experimental requires allow_experimental_personal_hosted_auth: true.");
12
+ }
13
+ if (!env.CODEX_HOME_TGZ_BASE64) {
14
+ issues.push("CODEX_HOME_TGZ_BASE64 is required for chatgpt_hosted_experimental.");
15
+ }
16
+ }
17
+ return issues;
18
+ }
19
+ //# sourceMappingURL=validateAuth.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validateAuth.js","sourceRoot":"","sources":["../../src/auth/validateAuth.ts"],"names":[],"mappings":"AAEA,MAAM,UAAU,kBAAkB,CAAC,MAAuB,EAAE,GAAG,GAAG,OAAO,CAAC,GAAG;IAC3E,MAAM,MAAM,GAAa,EAAE,CAAC;IAE5B,IAAI,MAAM,CAAC,KAAK,CAAC,IAAI,KAAK,SAAS,IAAI,CAAC,GAAG,CAAC,cAAc,EAAE,CAAC;QAC3D,MAAM,CAAC,IAAI,CAAC,qDAAqD,CAAC,CAAC;IACrE,CAAC;IAED,IAAI,MAAM,CAAC,KAAK,CAAC,IAAI,KAAK,cAAc,IAAI,CAAC,GAAG,CAAC,kBAAkB,EAAE,CAAC;QACpE,MAAM,CAAC,IAAI,CAAC,8DAA8D,CAAC,CAAC;IAC9E,CAAC;IAED,IAAI,MAAM,CAAC,KAAK,CAAC,IAAI,KAAK,6BAA6B,EAAE,CAAC;QACxD,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,uCAAuC,EAAE,CAAC;YAC1D,MAAM,CAAC,IAAI,CACT,qFAAqF,CACtF,CAAC;QACJ,CAAC;QACD,IAAI,CAAC,GAAG,CAAC,qBAAqB,EAAE,CAAC;YAC/B,MAAM,CAAC,IAAI,CAAC,oEAAoE,CAAC,CAAC;QACpF,CAAC;IACH,CAAC;IAED,OAAO,MAAM,CAAC;AAChB,CAAC"}
@@ -0,0 +1,13 @@
1
+ import { z } from "zod";
2
+ declare const qaCaseFrontMatterSchema: z.ZodObject<{
3
+ id: z.ZodString;
4
+ title: z.ZodString;
5
+ required_env: z.ZodDefault<z.ZodArray<z.ZodString>>;
6
+ tags: z.ZodDefault<z.ZodArray<z.ZodString>>;
7
+ }, z.core.$strip>;
8
+ export type QaCase = z.infer<typeof qaCaseFrontMatterSchema> & {
9
+ body: string;
10
+ path: string;
11
+ };
12
+ export declare function parseCase(casePath: string, cwd?: string): QaCase;
13
+ export {};
@@ -0,0 +1,21 @@
1
+ import { readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import matter from "gray-matter";
4
+ import { z } from "zod";
5
+ const qaCaseFrontMatterSchema = z.object({
6
+ id: z.string().min(1),
7
+ title: z.string().min(1),
8
+ required_env: z.array(z.string().min(1)).default([]),
9
+ tags: z.array(z.string()).default([]),
10
+ });
11
+ export function parseCase(casePath, cwd = process.cwd()) {
12
+ const absolutePath = path.resolve(cwd, casePath);
13
+ const parsed = matter(readFileSync(absolutePath, "utf8"));
14
+ const frontMatter = qaCaseFrontMatterSchema.parse(parsed.data);
15
+ return {
16
+ ...frontMatter,
17
+ body: parsed.content.trim(),
18
+ path: absolutePath,
19
+ };
20
+ }
21
+ //# sourceMappingURL=parseCase.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"parseCase.js","sourceRoot":"","sources":["../../src/cases/parseCase.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AACvC,OAAO,IAAI,MAAM,WAAW,CAAC;AAC7B,OAAO,MAAM,MAAM,aAAa,CAAC;AACjC,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,MAAM,uBAAuB,GAAG,CAAC,CAAC,MAAM,CAAC;IACvC,EAAE,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC;IACrB,KAAK,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC;IACxB,YAAY,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,EAAE,CAAC;IACpD,IAAI,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC,CAAC,OAAO,CAAC,EAAE,CAAC;CACtC,CAAC,CAAC;AAOH,MAAM,UAAU,SAAS,CAAC,QAAgB,EAAE,GAAG,GAAG,OAAO,CAAC,GAAG,EAAE;IAC7D,MAAM,YAAY,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE,QAAQ,CAAC,CAAC;IACjD,MAAM,MAAM,GAAG,MAAM,CAAC,YAAY,CAAC,YAAY,EAAE,MAAM,CAAC,CAAC,CAAC;IAC1D,MAAM,WAAW,GAAG,uBAAuB,CAAC,KAAK,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;IAE/D,OAAO;QACL,GAAG,WAAW;QACd,IAAI,EAAE,MAAM,CAAC,OAAO,CAAC,IAAI,EAAE;QAC3B,IAAI,EAAE,YAAY;KACnB,CAAC;AACJ,CAAC"}
@@ -0,0 +1,6 @@
1
+ import type { QaCase } from "./parseCase.js";
2
+ export type ResolvedCase = QaCase & {
3
+ resolvedBody: string;
4
+ envValues: Record<string, string>;
5
+ };
6
+ export declare function resolveCaseEnv(qaCase: QaCase, env?: NodeJS.ProcessEnv): ResolvedCase;
@@ -0,0 +1,19 @@
1
+ export function resolveCaseEnv(qaCase, env = process.env) {
2
+ const missing = qaCase.required_env.filter((key) => !env[key]);
3
+ if (missing.length > 0) {
4
+ throw new Error(`Missing required env vars for ${qaCase.id}: ${missing.join(", ")}`);
5
+ }
6
+ const envValues = Object.fromEntries(qaCase.required_env.map((key) => [key, String(env[key] ?? "")]));
7
+ const resolvedBody = qaCase.body.replace(/\$\{([A-Z0-9_]+)\}/g, (match, key) => {
8
+ if (Object.prototype.hasOwnProperty.call(envValues, key)) {
9
+ return envValues[key];
10
+ }
11
+ return match;
12
+ });
13
+ return {
14
+ ...qaCase,
15
+ resolvedBody,
16
+ envValues,
17
+ };
18
+ }
19
+ //# sourceMappingURL=resolveEnv.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resolveEnv.js","sourceRoot":"","sources":["../../src/cases/resolveEnv.ts"],"names":[],"mappings":"AAOA,MAAM,UAAU,cAAc,CAAC,MAAc,EAAE,GAAG,GAAG,OAAO,CAAC,GAAG;IAC9D,MAAM,OAAO,GAAG,MAAM,CAAC,YAAY,CAAC,MAAM,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC;IAC/D,IAAI,OAAO,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QACvB,MAAM,IAAI,KAAK,CAAC,iCAAiC,MAAM,CAAC,EAAE,KAAK,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IACvF,CAAC;IAED,MAAM,SAAS,GAAG,MAAM,CAAC,WAAW,CAClC,MAAM,CAAC,YAAY,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC,GAAG,EAAE,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,CAChE,CAAC;IAEF,MAAM,YAAY,GAAG,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,qBAAqB,EAAE,CAAC,KAAK,EAAE,GAAW,EAAE,EAAE;QACrF,IAAI,MAAM,CAAC,SAAS,CAAC,cAAc,CAAC,IAAI,CAAC,SAAS,EAAE,GAAG,CAAC,EAAE,CAAC;YACzD,OAAO,SAAS,CAAC,GAAG,CAAC,CAAC;QACxB,CAAC;QACD,OAAO,KAAK,CAAC;IACf,CAAC,CAAC,CAAC;IAEH,OAAO;QACL,GAAG,MAAM;QACT,YAAY;QACZ,SAAS;KACV,CAAC;AACJ,CAAC"}
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,219 @@
1
+ #!/usr/bin/env node
2
+ import { mkdirSync, writeFileSync } from "node:fs";
3
+ import { Command } from "commander";
4
+ import fg from "fast-glob";
5
+ import { loadConfig } from "./config/loadConfig.js";
6
+ import { validateAuthConfig } from "./auth/validateAuth.js";
7
+ import { parseCase } from "./cases/parseCase.js";
8
+ import { resolveCaseEnv } from "./cases/resolveEnv.js";
9
+ import { createRedactor } from "./security/redact.js";
10
+ import { doctorXcode } from "./ios/doctorXcode.js";
11
+ import { runCaseWithSdk } from "./codex/runWithSdk.js";
12
+ import { writeJsonReport, readJsonReport } from "./reports/jsonReport.js";
13
+ import { writeMarkdownReport } from "./reports/markdownReport.js";
14
+ import { writeJunitReport } from "./reports/junitReport.js";
15
+ import { ExitCodes } from "./exitCodes.js";
16
+ function writeReports(configPath, report) {
17
+ const config = loadConfig(configPath);
18
+ if (config.reports.markdown)
19
+ writeMarkdownReport(config, report);
20
+ if (config.reports.junit)
21
+ writeJunitReport(config, report);
22
+ }
23
+ function exitCodeFor(report, failOn) {
24
+ if (failOn === "never")
25
+ return ExitCodes.success;
26
+ if (report.status === "failed")
27
+ return ExitCodes.failed;
28
+ if (report.status === "blocked")
29
+ return ExitCodes.blocked;
30
+ return ExitCodes.success;
31
+ }
32
+ function printCheck(name, ok, detail) {
33
+ const marker = ok ? "PASS" : "FAIL";
34
+ console.log(`${marker} ${name}${detail ? `: ${detail.split("\n")[0]}` : ""}`);
35
+ }
36
+ async function main() {
37
+ const program = new Command();
38
+ program
39
+ .name("shippilot")
40
+ .description("ShipPilot: agentic iOS QA runner for Codex")
41
+ .version("0.0.1")
42
+ .option("-c, --config <path>", "config file path", "shippilot.yml");
43
+ program.command("init").description("Scaffold ShipPilot config, sample case, and CI templates").action(() => {
44
+ mkdirSync("qa", { recursive: true });
45
+ mkdirSync(".github/workflows", { recursive: true });
46
+ mkdirSync("bitrise", { recursive: true });
47
+ writeFileSync("shippilot.yml", `codex:
48
+ engine: sdk
49
+ auth: api_key
50
+ model: default
51
+ sandbox: danger-full-access
52
+ fail_on: failed_or_blocked
53
+ allow_experimental_personal_hosted_auth: false
54
+ verbose: false
55
+
56
+ ios:
57
+ project: MyApp.xcodeproj
58
+ bundle_id:
59
+ scheme: MyApp
60
+ simulator: iPhone 17 Pro
61
+ backend: xcodebuildmcp
62
+ configuration: Debug
63
+
64
+ reports:
65
+ output_dir: .shippilot
66
+ markdown: true
67
+ json: true
68
+ junit: true
69
+ screenshots: true
70
+ logs: true
71
+ `);
72
+ writeFileSync("qa/login.md", `---
73
+ id: login-happy-path
74
+ title: Login happy path
75
+ required_env:
76
+ - TEST_EMAIL
77
+ - TEST_PASSWORD
78
+ tags:
79
+ - release
80
+ - smoke
81
+ ---
82
+
83
+ Launch the app.
84
+ Enter \${TEST_EMAIL} and \${TEST_PASSWORD}.
85
+ Tap Log In.
86
+ Expect the Home screen to be visible.
87
+ `);
88
+ writeFileSync(".github/workflows/shippilot.yml", `name: ShipPilot QA
89
+
90
+ on:
91
+ workflow_dispatch:
92
+ release:
93
+ types: [published]
94
+
95
+ jobs:
96
+ shippilot:
97
+ runs-on: macos-15
98
+ permissions:
99
+ contents: read
100
+ steps:
101
+ - uses: actions/checkout@v5
102
+ with:
103
+ persist-credentials: false
104
+
105
+ - uses: actions/setup-node@v4
106
+ with:
107
+ node-version: 22
108
+
109
+ - name: Run ShipPilot
110
+ env:
111
+ OPENAI_API_KEY: \${{ secrets.OPENAI_API_KEY }}
112
+ CODEX_ACCESS_TOKEN: \${{ secrets.CODEX_ACCESS_TOKEN }}
113
+ CODEX_HOME_TGZ_BASE64: \${{ secrets.CODEX_HOME_TGZ_BASE64 }}
114
+ TEST_EMAIL: \${{ secrets.TEST_EMAIL }}
115
+ TEST_PASSWORD: \${{ secrets.TEST_PASSWORD }}
116
+ run: |
117
+ npx shippilot doctor
118
+ npx shippilot run --case qa/login.md
119
+
120
+ - name: Upload ShipPilot report
121
+ if: always()
122
+ uses: actions/upload-artifact@v4
123
+ with:
124
+ name: shippilot-report
125
+ path: .shippilot/
126
+ `);
127
+ writeFileSync("bitrise/shippilot.sh", `#!/usr/bin/env bash
128
+ set -euo pipefail
129
+
130
+ npm install -g shippilot
131
+ shippilot doctor
132
+ shippilot run --case qa/login.md
133
+ `, { mode: 0o755 });
134
+ console.log("Created shippilot.yml, qa/login.md, and CI templates.");
135
+ });
136
+ program.command("doctor").description("Validate config, auth, Xcode, XcodeBuildMCP, and project inputs").action(() => {
137
+ const options = program.opts();
138
+ try {
139
+ const config = loadConfig(options.config);
140
+ const authIssues = validateAuthConfig(config);
141
+ for (const issue of authIssues)
142
+ printCheck("auth", false, issue);
143
+ const checks = doctorXcode(config);
144
+ for (const check of checks)
145
+ printCheck(check.name, check.ok, check.detail);
146
+ if (authIssues.length > 0 || checks.some((check) => !check.ok)) {
147
+ process.exitCode = ExitCodes.setupError;
148
+ return;
149
+ }
150
+ printCheck("ShipPilot doctor", true, "ready");
151
+ }
152
+ catch (error) {
153
+ console.error(error instanceof Error ? error.message : String(error));
154
+ process.exitCode = ExitCodes.setupError;
155
+ }
156
+ });
157
+ program
158
+ .command("run")
159
+ .description("Run one or more ShipPilot QA cases")
160
+ .option("--case <path>", "single QA case Markdown file")
161
+ .option("--cases <glob>", "QA case glob or directory")
162
+ .option("--verbose", "stream XcodeBuildMCP and Codex SDK events")
163
+ .action(async (runOptions) => {
164
+ const options = program.opts();
165
+ const startedAt = new Date().toISOString();
166
+ try {
167
+ const config = loadConfig(options.config);
168
+ const authIssues = validateAuthConfig(config);
169
+ if (authIssues.length > 0)
170
+ throw new Error(authIssues.join("\n"));
171
+ const casePaths = runOptions.case
172
+ ? [runOptions.case]
173
+ : await fg(runOptions.cases ? `${runOptions.cases.replace(/\/$/, "")}/**/*.md` : "qa/**/*.md");
174
+ if (casePaths.length === 0)
175
+ throw new Error("No QA cases found.");
176
+ const records = [];
177
+ for (const casePath of casePaths) {
178
+ const qaCase = parseCase(casePath);
179
+ const resolved = resolveCaseEnv(qaCase);
180
+ const redactor = createRedactor(Object.values(resolved.envValues));
181
+ console.log(`Running ${qaCase.id}: ${qaCase.title}`);
182
+ records.push(await runCaseWithSdk(config, resolved, redactor, process.cwd(), runOptions.verbose ?? config.codex.verbose));
183
+ }
184
+ const report = writeJsonReport(config, records, startedAt);
185
+ if (config.reports.markdown)
186
+ writeMarkdownReport(config, report);
187
+ if (config.reports.junit)
188
+ writeJunitReport(config, report);
189
+ console.log(`ShipPilot completed with status: ${report.status}`);
190
+ process.exitCode = exitCodeFor(report, config.codex.fail_on);
191
+ }
192
+ catch (error) {
193
+ console.error(error instanceof Error ? error.message : String(error));
194
+ process.exitCode = ExitCodes.setupError;
195
+ }
196
+ });
197
+ program
198
+ .command("report")
199
+ .description("Regenerate reports from a saved run.json")
200
+ .requiredOption("--run <path>", "path to run.json")
201
+ .action((reportOptions) => {
202
+ const options = program.opts();
203
+ try {
204
+ const report = readJsonReport(reportOptions.run);
205
+ writeReports(options.config, report);
206
+ console.log("Regenerated ShipPilot reports.");
207
+ }
208
+ catch (error) {
209
+ console.error(error instanceof Error ? error.message : String(error));
210
+ process.exitCode = ExitCodes.setupError;
211
+ }
212
+ });
213
+ await program.parseAsync(process.argv);
214
+ }
215
+ main().catch((error) => {
216
+ console.error(error instanceof Error ? error.message : String(error));
217
+ process.exitCode = ExitCodes.setupError;
218
+ });
219
+ //# sourceMappingURL=cli.js.map