@debugswift/ios-simulator-cli 1.6.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 +21 -0
- package/README.md +124 -0
- package/build/index.js +694 -0
- package/package.json +44 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Joshua Yoes
|
|
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,124 @@
|
|
|
1
|
+
# iOS Simulator CLI
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/ios-simulator-cli)
|
|
4
|
+
|
|
5
|
+
A command-line tool for interacting with iOS simulators. Control UI, capture screenshots and video, install apps, and inspect accessibility elements from your terminal or automation scripts.
|
|
6
|
+
|
|
7
|
+
> **Security Notice**: Command injection vulnerabilities present in versions < 1.3.3 have been fixed. Please update to v1.3.3 or later. See [SECURITY.md](SECURITY.md) for details.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
### Homebrew (recommended)
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
brew install DebugSwift/ios-simulator-cli/ios-simulator-cli
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
To install the latest from `main`:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
brew install --HEAD DebugSwift/ios-simulator-cli/ios-simulator-cli
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
### npm
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npm install -g ios-simulator-cli
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### From source
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
git clone https://github.com/DebugSwift/ios-simulator-cli
|
|
33
|
+
cd ios-simulator-cli
|
|
34
|
+
npm install
|
|
35
|
+
npm run build
|
|
36
|
+
npm link
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Prerequisites
|
|
40
|
+
|
|
41
|
+
- macOS (iOS simulators are only available on macOS)
|
|
42
|
+
- [Xcode](https://developer.apple.com/xcode/resources/) and iOS simulators installed
|
|
43
|
+
- Facebook [IDB](https://fbidb.io/) tool ([install guide](https://fbidb.io/docs/installation))
|
|
44
|
+
|
|
45
|
+
## Usage
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
ios-simulator-cli --help
|
|
49
|
+
ios-simulator-cli --version
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Commands
|
|
53
|
+
|
|
54
|
+
| Command | Description |
|
|
55
|
+
| --- | --- |
|
|
56
|
+
| `get-booted-sim-id` | Get the booted simulator UUID |
|
|
57
|
+
| `open` | Open Simulator.app |
|
|
58
|
+
| `ui describe-all` | Describe all UI accessibility elements |
|
|
59
|
+
| `ui tap` | Tap at coordinates |
|
|
60
|
+
| `ui type` | Input text |
|
|
61
|
+
| `ui swipe` | Swipe gesture |
|
|
62
|
+
| `ui describe-point` | Get element at coordinates |
|
|
63
|
+
| `ui find-element` | Search accessibility tree |
|
|
64
|
+
| `ui view` | Capture compressed screenshot (JPEG base64 or file) |
|
|
65
|
+
| `screenshot` | Save screenshot to file |
|
|
66
|
+
| `record-video` | Start video recording |
|
|
67
|
+
| `stop-recording` | Stop video recording |
|
|
68
|
+
| `install-app` | Install an app bundle (.app or .ipa) |
|
|
69
|
+
| `launch-app` | Launch an app by bundle identifier |
|
|
70
|
+
|
|
71
|
+
### Examples
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
# Get the booted simulator
|
|
75
|
+
ios-simulator-cli get-booted-sim-id
|
|
76
|
+
|
|
77
|
+
# Open Simulator.app
|
|
78
|
+
ios-simulator-cli open
|
|
79
|
+
|
|
80
|
+
# Describe the current screen
|
|
81
|
+
ios-simulator-cli ui describe-all
|
|
82
|
+
|
|
83
|
+
# Tap at coordinates
|
|
84
|
+
ios-simulator-cli ui tap --x 200 --y 400
|
|
85
|
+
|
|
86
|
+
# Type text
|
|
87
|
+
ios-simulator-cli ui type "Hello World"
|
|
88
|
+
|
|
89
|
+
# Swipe down
|
|
90
|
+
ios-simulator-cli ui swipe --x-start 200 --y-start 600 --x-end 200 --y-end 200
|
|
91
|
+
|
|
92
|
+
# Find a button by label
|
|
93
|
+
ios-simulator-cli ui find-element --search "Search" --type Button
|
|
94
|
+
|
|
95
|
+
# Save a screenshot
|
|
96
|
+
ios-simulator-cli screenshot --output home.png
|
|
97
|
+
|
|
98
|
+
# Capture a compressed view to a file
|
|
99
|
+
ios-simulator-cli ui view --output screen.jpg
|
|
100
|
+
|
|
101
|
+
# Start and stop recording
|
|
102
|
+
ios-simulator-cli record-video --output demo.mp4
|
|
103
|
+
ios-simulator-cli stop-recording
|
|
104
|
+
|
|
105
|
+
# Install and launch an app
|
|
106
|
+
ios-simulator-cli install-app --app-path ./MyApp.app
|
|
107
|
+
ios-simulator-cli launch-app --bundle-id com.apple.mobilesafari --terminate-running
|
|
108
|
+
ios-simulator-cli launch-app --bundle-id com.example.app --env FOO=bar --env BAZ=qux
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Configuration
|
|
112
|
+
|
|
113
|
+
### Environment Variables
|
|
114
|
+
|
|
115
|
+
| Variable | Description | Example |
|
|
116
|
+
| --- | --- | --- |
|
|
117
|
+
| `IOS_SIMULATOR_CLI_DEFAULT_OUTPUT_DIR` | Default directory for relative output paths (screenshots, recordings). Defaults to `~/Downloads`. | `~/Code/project/tmp` |
|
|
118
|
+
| `IOS_SIMULATOR_CLI_IDB_PATH` | Custom path to the IDB executable. Defaults to `idb` on PATH. | `~/bin/idb` |
|
|
119
|
+
|
|
120
|
+
Legacy `IOS_SIMULATOR_MCP_*` environment variables are still supported for output directory and IDB path.
|
|
121
|
+
|
|
122
|
+
## License
|
|
123
|
+
|
|
124
|
+
MIT
|
package/build/index.js
ADDED
|
@@ -0,0 +1,694 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
4
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
5
|
+
};
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
exports.buildLaunchArgs = buildLaunchArgs;
|
|
8
|
+
const child_process_1 = require("child_process");
|
|
9
|
+
const util_1 = require("util");
|
|
10
|
+
const node_util_1 = require("node:util");
|
|
11
|
+
const zod_1 = require("zod");
|
|
12
|
+
const path_1 = __importDefault(require("path"));
|
|
13
|
+
const os_1 = __importDefault(require("os"));
|
|
14
|
+
const fs_1 = __importDefault(require("fs"));
|
|
15
|
+
const VERSION = require("../package.json").version;
|
|
16
|
+
function buildLaunchArgs({ udid, bundleId, terminateRunning, env, }) {
|
|
17
|
+
const args = ["launch"];
|
|
18
|
+
if (terminateRunning) {
|
|
19
|
+
args.push("--terminate-running-process");
|
|
20
|
+
}
|
|
21
|
+
const simctlEnv = {};
|
|
22
|
+
if (env) {
|
|
23
|
+
const entries = Object.entries(env)
|
|
24
|
+
.map(([key, value]) => [key.trim(), value])
|
|
25
|
+
.sort(([a], [b]) => a.localeCompare(b));
|
|
26
|
+
for (const [key, value] of entries) {
|
|
27
|
+
if (!key) {
|
|
28
|
+
throw new Error("Environment variable keys must be non-empty.");
|
|
29
|
+
}
|
|
30
|
+
simctlEnv[`SIMCTL_CHILD_${key}`] = value;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
args.push(udid, bundleId);
|
|
34
|
+
return { args, env: simctlEnv };
|
|
35
|
+
}
|
|
36
|
+
const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
|
|
37
|
+
const UDID_REGEX = /^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$/;
|
|
38
|
+
const udidSchema = zod_1.z.string().regex(UDID_REGEX).optional();
|
|
39
|
+
const TMP_ROOT_DIR = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), "ios-simulator-cli-"));
|
|
40
|
+
async function run(cmd, args, options = {}) {
|
|
41
|
+
const mergedEnv = options.env
|
|
42
|
+
? { ...process.env, ...options.env }
|
|
43
|
+
: process.env;
|
|
44
|
+
const { stdout, stderr } = await execFileAsync(cmd, args, {
|
|
45
|
+
shell: false,
|
|
46
|
+
env: mergedEnv,
|
|
47
|
+
});
|
|
48
|
+
return {
|
|
49
|
+
stdout: stdout.trim(),
|
|
50
|
+
stderr: stderr.trim(),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
function getIdbPath() {
|
|
54
|
+
const customPath = process.env.IOS_SIMULATOR_CLI_IDB_PATH ??
|
|
55
|
+
process.env.IOS_SIMULATOR_MCP_IDB_PATH;
|
|
56
|
+
if (customPath) {
|
|
57
|
+
const expandedPath = customPath.startsWith("~/")
|
|
58
|
+
? path_1.default.join(os_1.default.homedir(), customPath.slice(2))
|
|
59
|
+
: customPath;
|
|
60
|
+
if (!fs_1.default.existsSync(expandedPath)) {
|
|
61
|
+
throw new Error(`Custom IDB path specified in IOS_SIMULATOR_CLI_IDB_PATH does not exist: ${expandedPath}`);
|
|
62
|
+
}
|
|
63
|
+
return expandedPath;
|
|
64
|
+
}
|
|
65
|
+
return "idb";
|
|
66
|
+
}
|
|
67
|
+
async function idb(...args) {
|
|
68
|
+
return run(getIdbPath(), args);
|
|
69
|
+
}
|
|
70
|
+
function toError(input) {
|
|
71
|
+
if (input instanceof Error)
|
|
72
|
+
return input;
|
|
73
|
+
if (typeof input === "object" &&
|
|
74
|
+
input &&
|
|
75
|
+
"message" in input &&
|
|
76
|
+
typeof input.message === "string")
|
|
77
|
+
return new Error(input.message);
|
|
78
|
+
return new Error(JSON.stringify(input));
|
|
79
|
+
}
|
|
80
|
+
function troubleshootingLink() {
|
|
81
|
+
return "https://github.com/DebugSwift/ios-simulator-cli/blob/main/TROUBLESHOOTING.md";
|
|
82
|
+
}
|
|
83
|
+
function errorWithTroubleshooting(message) {
|
|
84
|
+
return `${message}\n\nFor help, see ${troubleshootingLink()}`;
|
|
85
|
+
}
|
|
86
|
+
async function getBootedDevice() {
|
|
87
|
+
const { stdout, stderr } = await run("xcrun", ["simctl", "list", "devices"]);
|
|
88
|
+
if (stderr)
|
|
89
|
+
throw new Error(stderr);
|
|
90
|
+
const lines = stdout.split("\n");
|
|
91
|
+
for (const line of lines) {
|
|
92
|
+
if (line.includes("Booted")) {
|
|
93
|
+
const match = line.match(/\(([-0-9A-F]+)\)/);
|
|
94
|
+
if (match) {
|
|
95
|
+
const deviceId = match[1];
|
|
96
|
+
const deviceName = line.split("(")[0].trim();
|
|
97
|
+
return {
|
|
98
|
+
name: deviceName,
|
|
99
|
+
id: deviceId,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
throw Error("No booted simulator found");
|
|
105
|
+
}
|
|
106
|
+
async function getBootedDeviceId(deviceId) {
|
|
107
|
+
let actualDeviceId = deviceId;
|
|
108
|
+
if (!actualDeviceId) {
|
|
109
|
+
const { id } = await getBootedDevice();
|
|
110
|
+
actualDeviceId = id;
|
|
111
|
+
}
|
|
112
|
+
if (!actualDeviceId) {
|
|
113
|
+
throw new Error("No booted simulator found and no deviceId provided");
|
|
114
|
+
}
|
|
115
|
+
return actualDeviceId;
|
|
116
|
+
}
|
|
117
|
+
function ensureAbsolutePath(filePath) {
|
|
118
|
+
if (path_1.default.isAbsolute(filePath)) {
|
|
119
|
+
return filePath;
|
|
120
|
+
}
|
|
121
|
+
if (filePath.startsWith("~/")) {
|
|
122
|
+
return path_1.default.join(os_1.default.homedir(), filePath.slice(2));
|
|
123
|
+
}
|
|
124
|
+
let defaultDir = path_1.default.join(os_1.default.homedir(), "Downloads");
|
|
125
|
+
const customDefaultDir = process.env.IOS_SIMULATOR_CLI_DEFAULT_OUTPUT_DIR ??
|
|
126
|
+
process.env.IOS_SIMULATOR_MCP_DEFAULT_OUTPUT_DIR;
|
|
127
|
+
if (customDefaultDir) {
|
|
128
|
+
if (customDefaultDir.startsWith("~/")) {
|
|
129
|
+
defaultDir = path_1.default.join(os_1.default.homedir(), customDefaultDir.slice(2));
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
defaultDir = customDefaultDir;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return path_1.default.join(defaultDir, filePath);
|
|
136
|
+
}
|
|
137
|
+
function parseEnvPairs(values) {
|
|
138
|
+
const env = {};
|
|
139
|
+
for (const pair of values ?? []) {
|
|
140
|
+
const separator = pair.indexOf("=");
|
|
141
|
+
if (separator <= 0) {
|
|
142
|
+
throw new Error(`Invalid env pair "${pair}". Expected format KEY=VALUE.`);
|
|
143
|
+
}
|
|
144
|
+
env[pair.slice(0, separator)] = pair.slice(separator + 1);
|
|
145
|
+
}
|
|
146
|
+
return env;
|
|
147
|
+
}
|
|
148
|
+
async function cmdGetBootedSimId() {
|
|
149
|
+
const { id, name } = await getBootedDevice();
|
|
150
|
+
console.log(`Booted Simulator: "${name}". UUID: "${id}"`);
|
|
151
|
+
}
|
|
152
|
+
async function cmdOpenSimulator() {
|
|
153
|
+
await run("open", ["-a", "Simulator.app"]);
|
|
154
|
+
console.log("Simulator.app opened successfully");
|
|
155
|
+
}
|
|
156
|
+
async function cmdUiDescribeAll(udid) {
|
|
157
|
+
const actualUdid = await getBootedDeviceId(udidSchema.parse(udid));
|
|
158
|
+
const { stdout } = await idb("ui", "describe-all", "--udid", actualUdid, "--json", "--nested");
|
|
159
|
+
console.log(stdout);
|
|
160
|
+
}
|
|
161
|
+
async function cmdUiTap(options) {
|
|
162
|
+
const actualUdid = await getBootedDeviceId(udidSchema.parse(options.udid));
|
|
163
|
+
const { stderr } = await idb("ui", "tap", "--udid", actualUdid, ...(options.duration ? ["--duration", options.duration] : []), "--json", "--", String(options.x), String(options.y));
|
|
164
|
+
if (stderr)
|
|
165
|
+
throw new Error(stderr);
|
|
166
|
+
console.log("Tapped successfully");
|
|
167
|
+
}
|
|
168
|
+
async function cmdUiType(text, udid) {
|
|
169
|
+
zod_1.z.string().max(500).regex(/^[\x20-\x7E]+$/).parse(text);
|
|
170
|
+
const actualUdid = await getBootedDeviceId(udidSchema.parse(udid));
|
|
171
|
+
const { stderr } = await idb("ui", "text", "--udid", actualUdid, "--", text);
|
|
172
|
+
if (stderr)
|
|
173
|
+
throw new Error(stderr);
|
|
174
|
+
console.log("Typed successfully");
|
|
175
|
+
}
|
|
176
|
+
async function cmdUiSwipe(options) {
|
|
177
|
+
const actualUdid = await getBootedDeviceId(udidSchema.parse(options.udid));
|
|
178
|
+
const { stderr } = await idb("ui", "swipe", "--udid", actualUdid, ...(options.duration ? ["--duration", options.duration] : []), ...(options.delta != null ? ["--delta", String(options.delta)] : []), "--json", "--", String(options.xStart), String(options.yStart), String(options.xEnd), String(options.yEnd));
|
|
179
|
+
if (stderr)
|
|
180
|
+
throw new Error(stderr);
|
|
181
|
+
console.log("Swiped successfully");
|
|
182
|
+
}
|
|
183
|
+
async function cmdUiDescribePoint(options) {
|
|
184
|
+
const actualUdid = await getBootedDeviceId(udidSchema.parse(options.udid));
|
|
185
|
+
const { stdout, stderr } = await idb("ui", "describe-point", "--udid", actualUdid, "--json", "--", String(options.x), String(options.y));
|
|
186
|
+
if (stderr)
|
|
187
|
+
throw new Error(stderr);
|
|
188
|
+
console.log(stdout);
|
|
189
|
+
}
|
|
190
|
+
async function cmdUiFindElement(options) {
|
|
191
|
+
const actualUdid = await getBootedDeviceId(udidSchema.parse(options.udid));
|
|
192
|
+
const { stdout } = await idb("ui", "describe-all", "--udid", actualUdid, "--json", "--nested");
|
|
193
|
+
const uiData = JSON.parse(stdout);
|
|
194
|
+
function matchesSearch(value, term, mode, sensitive) {
|
|
195
|
+
if (value == null)
|
|
196
|
+
return false;
|
|
197
|
+
const v = sensitive ? value : value.toLowerCase();
|
|
198
|
+
const t = sensitive ? term : term.toLowerCase();
|
|
199
|
+
return mode === "exact" ? v === t : v.includes(t);
|
|
200
|
+
}
|
|
201
|
+
function findElements(elements) {
|
|
202
|
+
const results = [];
|
|
203
|
+
for (const element of elements) {
|
|
204
|
+
const label = element.AXLabel;
|
|
205
|
+
const uniqueId = element.AXUniqueId;
|
|
206
|
+
const elementType = element.type;
|
|
207
|
+
const matchesAnySearch = options.search.some((term) => matchesSearch(label, term, options.matchMode, options.caseSensitive) ||
|
|
208
|
+
matchesSearch(uniqueId, term, options.matchMode, options.caseSensitive));
|
|
209
|
+
const matchesType = options.type == null ||
|
|
210
|
+
(elementType != null &&
|
|
211
|
+
elementType.toLowerCase() === options.type.toLowerCase());
|
|
212
|
+
if (matchesAnySearch && matchesType) {
|
|
213
|
+
results.push(element);
|
|
214
|
+
}
|
|
215
|
+
const children = element.children;
|
|
216
|
+
if (children && children.length > 0) {
|
|
217
|
+
results.push(...findElements(children));
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return results;
|
|
221
|
+
}
|
|
222
|
+
console.log(JSON.stringify(findElements(uiData)));
|
|
223
|
+
}
|
|
224
|
+
async function cmdUiView(options) {
|
|
225
|
+
const actualUdid = await getBootedDeviceId(udidSchema.parse(options.udid));
|
|
226
|
+
const { stdout: uiDescribeOutput } = await idb("ui", "describe-all", "--udid", actualUdid, "--json", "--nested");
|
|
227
|
+
let uiData;
|
|
228
|
+
try {
|
|
229
|
+
uiData = JSON.parse(uiDescribeOutput);
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
throw new Error("Failed to parse screen dimensions: idb returned invalid JSON");
|
|
233
|
+
}
|
|
234
|
+
const screenFrame = uiData[0]?.frame;
|
|
235
|
+
if (!screenFrame ||
|
|
236
|
+
typeof screenFrame.width !== "number" ||
|
|
237
|
+
typeof screenFrame.height !== "number" ||
|
|
238
|
+
screenFrame.width <= 0 ||
|
|
239
|
+
screenFrame.height <= 0) {
|
|
240
|
+
throw new Error("Could not determine valid screen dimensions from idb output");
|
|
241
|
+
}
|
|
242
|
+
const ts = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
243
|
+
const rawPng = path_1.default.join(TMP_ROOT_DIR, `ui-view-${ts}-raw.png`);
|
|
244
|
+
const compressedJpg = path_1.default.join(TMP_ROOT_DIR, `ui-view-${ts}-compressed.jpg`);
|
|
245
|
+
await run("xcrun", [
|
|
246
|
+
"simctl",
|
|
247
|
+
"io",
|
|
248
|
+
actualUdid,
|
|
249
|
+
"screenshot",
|
|
250
|
+
"--type=png",
|
|
251
|
+
"--",
|
|
252
|
+
rawPng,
|
|
253
|
+
]);
|
|
254
|
+
await run("sips", [
|
|
255
|
+
"-z",
|
|
256
|
+
String(screenFrame.height),
|
|
257
|
+
String(screenFrame.width),
|
|
258
|
+
"-s",
|
|
259
|
+
"format",
|
|
260
|
+
"jpeg",
|
|
261
|
+
"-s",
|
|
262
|
+
"formatOptions",
|
|
263
|
+
"80",
|
|
264
|
+
rawPng,
|
|
265
|
+
"--out",
|
|
266
|
+
compressedJpg,
|
|
267
|
+
]);
|
|
268
|
+
if (options.output) {
|
|
269
|
+
const outputPath = ensureAbsolutePath(options.output);
|
|
270
|
+
fs_1.default.copyFileSync(compressedJpg, outputPath);
|
|
271
|
+
console.log(`Screenshot saved to ${outputPath}`);
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
const base64Data = fs_1.default.readFileSync(compressedJpg).toString("base64");
|
|
275
|
+
console.log(base64Data);
|
|
276
|
+
}
|
|
277
|
+
try {
|
|
278
|
+
fs_1.default.unlinkSync(rawPng);
|
|
279
|
+
fs_1.default.unlinkSync(compressedJpg);
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
// ignore cleanup errors
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
async function cmdScreenshot(options) {
|
|
286
|
+
const actualUdid = await getBootedDeviceId(udidSchema.parse(options.udid));
|
|
287
|
+
const absolutePath = ensureAbsolutePath(options.outputPath);
|
|
288
|
+
const { stderr: stdout } = await run("xcrun", [
|
|
289
|
+
"simctl",
|
|
290
|
+
"io",
|
|
291
|
+
actualUdid,
|
|
292
|
+
"screenshot",
|
|
293
|
+
...(options.type ? [`--type=${options.type}`] : []),
|
|
294
|
+
...(options.display ? [`--display=${options.display}`] : []),
|
|
295
|
+
...(options.mask ? [`--mask=${options.mask}`] : []),
|
|
296
|
+
"--",
|
|
297
|
+
absolutePath,
|
|
298
|
+
]);
|
|
299
|
+
if (stdout && !stdout.includes("Wrote screenshot to")) {
|
|
300
|
+
throw new Error(stdout);
|
|
301
|
+
}
|
|
302
|
+
console.log(stdout || `Screenshot saved to ${absolutePath}`);
|
|
303
|
+
}
|
|
304
|
+
async function cmdRecordVideo(options) {
|
|
305
|
+
const actualUdid = await getBootedDeviceId(udidSchema.parse(options.udid));
|
|
306
|
+
const defaultFileName = `simulator_recording_${Date.now()}.mp4`;
|
|
307
|
+
const outputFile = ensureAbsolutePath(options.outputPath ?? defaultFileName);
|
|
308
|
+
const recordingProcess = (0, child_process_1.spawn)("xcrun", [
|
|
309
|
+
"simctl",
|
|
310
|
+
"io",
|
|
311
|
+
actualUdid,
|
|
312
|
+
"recordVideo",
|
|
313
|
+
...(options.codec ? [`--codec=${options.codec}`] : []),
|
|
314
|
+
...(options.display ? [`--display=${options.display}`] : []),
|
|
315
|
+
...(options.mask ? [`--mask=${options.mask}`] : []),
|
|
316
|
+
...(options.force ? ["--force"] : []),
|
|
317
|
+
"--",
|
|
318
|
+
outputFile,
|
|
319
|
+
]);
|
|
320
|
+
await new Promise((resolve, reject) => {
|
|
321
|
+
let errorOutput = "";
|
|
322
|
+
let resolved = false;
|
|
323
|
+
recordingProcess.stderr.on("data", (data) => {
|
|
324
|
+
const message = data.toString();
|
|
325
|
+
if (message.includes("Recording started")) {
|
|
326
|
+
resolved = true;
|
|
327
|
+
resolve(true);
|
|
328
|
+
}
|
|
329
|
+
else {
|
|
330
|
+
errorOutput += message;
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
recordingProcess.on("exit", (code) => {
|
|
334
|
+
if (!resolved) {
|
|
335
|
+
reject(new Error(errorOutput.trim() ||
|
|
336
|
+
`Recording process exited early with code ${code}`));
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
setTimeout(() => {
|
|
340
|
+
if (!resolved) {
|
|
341
|
+
if (recordingProcess.killed || recordingProcess.exitCode !== null) {
|
|
342
|
+
reject(new Error(errorOutput.trim() ||
|
|
343
|
+
"Recording process terminated unexpectedly"));
|
|
344
|
+
}
|
|
345
|
+
else {
|
|
346
|
+
resolve(true);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}, 5000);
|
|
350
|
+
});
|
|
351
|
+
console.log(`Recording started. The video will be saved to: ${outputFile}\nTo stop recording, run: ios-simulator-cli stop-recording`);
|
|
352
|
+
}
|
|
353
|
+
async function cmdStopRecording() {
|
|
354
|
+
await run("pkill", ["-SIGINT", "-f", "simctl.*recordVideo"]);
|
|
355
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
356
|
+
console.log("Recording stopped successfully.");
|
|
357
|
+
}
|
|
358
|
+
async function cmdInstallApp(appPath, udid) {
|
|
359
|
+
const actualUdid = await getBootedDeviceId(udidSchema.parse(udid));
|
|
360
|
+
const absolutePath = path_1.default.isAbsolute(appPath)
|
|
361
|
+
? appPath
|
|
362
|
+
: path_1.default.resolve(appPath);
|
|
363
|
+
if (!fs_1.default.existsSync(absolutePath)) {
|
|
364
|
+
throw new Error(`App bundle not found at: ${absolutePath}`);
|
|
365
|
+
}
|
|
366
|
+
await run("xcrun", ["simctl", "install", actualUdid, absolutePath]);
|
|
367
|
+
console.log(`App installed successfully from: ${absolutePath}`);
|
|
368
|
+
}
|
|
369
|
+
async function cmdLaunchApp(options) {
|
|
370
|
+
const actualUdid = await getBootedDeviceId(udidSchema.parse(options.udid));
|
|
371
|
+
const { args, env: simctlEnv } = buildLaunchArgs({
|
|
372
|
+
udid: actualUdid,
|
|
373
|
+
bundleId: options.bundleId,
|
|
374
|
+
terminateRunning: options.terminateRunning,
|
|
375
|
+
env: options.env,
|
|
376
|
+
});
|
|
377
|
+
const { stdout } = await run("xcrun", ["simctl", ...args], {
|
|
378
|
+
env: simctlEnv,
|
|
379
|
+
});
|
|
380
|
+
const pidMatch = stdout.match(/^(\d+)/);
|
|
381
|
+
const pid = pidMatch ? pidMatch[1] : null;
|
|
382
|
+
console.log(pid
|
|
383
|
+
? `App ${options.bundleId} launched successfully with PID: ${pid}`
|
|
384
|
+
: `App ${options.bundleId} launched successfully`);
|
|
385
|
+
}
|
|
386
|
+
function printHelp() {
|
|
387
|
+
console.log(`ios-simulator-cli v${VERSION}
|
|
388
|
+
|
|
389
|
+
A command-line tool for interacting with iOS simulators.
|
|
390
|
+
|
|
391
|
+
Usage:
|
|
392
|
+
ios-simulator-cli <command> [options]
|
|
393
|
+
|
|
394
|
+
Commands:
|
|
395
|
+
get-booted-sim-id Get the booted simulator UUID
|
|
396
|
+
open Open Simulator.app
|
|
397
|
+
ui describe-all [--udid <uuid>] Describe all UI elements
|
|
398
|
+
ui tap --x <n> --y <n> [--duration <s>] [--udid <uuid>]
|
|
399
|
+
ui type <text> [--udid <uuid>] Type text into the simulator
|
|
400
|
+
ui swipe --x-start <n> --y-start <n> --x-end <n> --y-end <n> [--duration <s>] [--delta <n>] [--udid <uuid>]
|
|
401
|
+
ui describe-point --x <n> --y <n> [--udid <uuid>]
|
|
402
|
+
ui find-element --search <term> [--search <term>] [--type <type>] [--match-mode substring|exact] [--case-sensitive] [--udid <uuid>]
|
|
403
|
+
ui view [--output <path>] [--udid <uuid>]
|
|
404
|
+
screenshot --output <path> [--type png|jpeg|...] [--display internal|external] [--mask ignored|alpha|black] [--udid <uuid>]
|
|
405
|
+
record-video [--output <path>] [--codec h264|hevc] [--display internal|external] [--mask ignored|alpha|black] [--force] [--udid <uuid>]
|
|
406
|
+
stop-recording Stop an active simulator recording
|
|
407
|
+
install-app --app-path <path> [--udid <uuid>]
|
|
408
|
+
launch-app --bundle-id <id> [--terminate-running] [--env KEY=VALUE] [--udid <uuid>]
|
|
409
|
+
|
|
410
|
+
Global options:
|
|
411
|
+
-h, --help Show this help
|
|
412
|
+
-v, --version Show version
|
|
413
|
+
|
|
414
|
+
Environment variables:
|
|
415
|
+
IOS_SIMULATOR_CLI_DEFAULT_OUTPUT_DIR Default directory for relative output paths (default: ~/Downloads)
|
|
416
|
+
IOS_SIMULATOR_CLI_IDB_PATH Custom path to the idb executable
|
|
417
|
+
|
|
418
|
+
Examples:
|
|
419
|
+
ios-simulator-cli get-booted-sim-id
|
|
420
|
+
ios-simulator-cli ui tap --x 200 --y 400
|
|
421
|
+
ios-simulator-cli screenshot --output home.png
|
|
422
|
+
ios-simulator-cli launch-app --bundle-id com.apple.mobilesafari
|
|
423
|
+
`);
|
|
424
|
+
}
|
|
425
|
+
async function handleUiCommand(args) {
|
|
426
|
+
const subcommand = args[0];
|
|
427
|
+
const rest = args.slice(1);
|
|
428
|
+
switch (subcommand) {
|
|
429
|
+
case "describe-all": {
|
|
430
|
+
const { values } = (0, node_util_1.parseArgs)({
|
|
431
|
+
args: rest,
|
|
432
|
+
options: { udid: { type: "string" } },
|
|
433
|
+
allowPositionals: false,
|
|
434
|
+
});
|
|
435
|
+
await cmdUiDescribeAll(values.udid);
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
case "tap": {
|
|
439
|
+
const { values } = (0, node_util_1.parseArgs)({
|
|
440
|
+
args: rest,
|
|
441
|
+
options: {
|
|
442
|
+
udid: { type: "string" },
|
|
443
|
+
x: { type: "string" },
|
|
444
|
+
y: { type: "string" },
|
|
445
|
+
duration: { type: "string" },
|
|
446
|
+
},
|
|
447
|
+
allowPositionals: false,
|
|
448
|
+
});
|
|
449
|
+
if (!values.x || !values.y) {
|
|
450
|
+
throw new Error("ui tap requires --x and --y");
|
|
451
|
+
}
|
|
452
|
+
if (values.duration) {
|
|
453
|
+
zod_1.z.string().regex(/^\d+(\.\d+)?$/).parse(values.duration);
|
|
454
|
+
}
|
|
455
|
+
await cmdUiTap({
|
|
456
|
+
udid: values.udid,
|
|
457
|
+
x: Number(values.x),
|
|
458
|
+
y: Number(values.y),
|
|
459
|
+
duration: values.duration,
|
|
460
|
+
});
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
case "type": {
|
|
464
|
+
const { values, positionals } = (0, node_util_1.parseArgs)({
|
|
465
|
+
args: rest,
|
|
466
|
+
options: { udid: { type: "string" } },
|
|
467
|
+
allowPositionals: true,
|
|
468
|
+
});
|
|
469
|
+
const text = positionals.join(" ");
|
|
470
|
+
if (!text) {
|
|
471
|
+
throw new Error("ui type requires text");
|
|
472
|
+
}
|
|
473
|
+
await cmdUiType(text, values.udid);
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
case "swipe": {
|
|
477
|
+
const { values } = (0, node_util_1.parseArgs)({
|
|
478
|
+
args: rest,
|
|
479
|
+
options: {
|
|
480
|
+
udid: { type: "string" },
|
|
481
|
+
"x-start": { type: "string" },
|
|
482
|
+
"y-start": { type: "string" },
|
|
483
|
+
"x-end": { type: "string" },
|
|
484
|
+
"y-end": { type: "string" },
|
|
485
|
+
duration: { type: "string" },
|
|
486
|
+
delta: { type: "string" },
|
|
487
|
+
},
|
|
488
|
+
allowPositionals: false,
|
|
489
|
+
});
|
|
490
|
+
if (!values["x-start"] || !values["y-start"] || !values["x-end"] || !values["y-end"]) {
|
|
491
|
+
throw new Error("ui swipe requires --x-start, --y-start, --x-end, and --y-end");
|
|
492
|
+
}
|
|
493
|
+
await cmdUiSwipe({
|
|
494
|
+
udid: values.udid,
|
|
495
|
+
xStart: Number(values["x-start"]),
|
|
496
|
+
yStart: Number(values["y-start"]),
|
|
497
|
+
xEnd: Number(values["x-end"]),
|
|
498
|
+
yEnd: Number(values["y-end"]),
|
|
499
|
+
duration: values.duration,
|
|
500
|
+
delta: values.delta ? Number(values.delta) : undefined,
|
|
501
|
+
});
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
case "describe-point": {
|
|
505
|
+
const { values } = (0, node_util_1.parseArgs)({
|
|
506
|
+
args: rest,
|
|
507
|
+
options: {
|
|
508
|
+
udid: { type: "string" },
|
|
509
|
+
x: { type: "string" },
|
|
510
|
+
y: { type: "string" },
|
|
511
|
+
},
|
|
512
|
+
allowPositionals: false,
|
|
513
|
+
});
|
|
514
|
+
if (!values.x || !values.y) {
|
|
515
|
+
throw new Error("ui describe-point requires --x and --y");
|
|
516
|
+
}
|
|
517
|
+
await cmdUiDescribePoint({
|
|
518
|
+
udid: values.udid,
|
|
519
|
+
x: Number(values.x),
|
|
520
|
+
y: Number(values.y),
|
|
521
|
+
});
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
case "find-element": {
|
|
525
|
+
const { values } = (0, node_util_1.parseArgs)({
|
|
526
|
+
args: rest,
|
|
527
|
+
options: {
|
|
528
|
+
udid: { type: "string" },
|
|
529
|
+
search: { type: "string", multiple: true },
|
|
530
|
+
type: { type: "string" },
|
|
531
|
+
"match-mode": { type: "string" },
|
|
532
|
+
"case-sensitive": { type: "boolean", default: false },
|
|
533
|
+
},
|
|
534
|
+
allowPositionals: false,
|
|
535
|
+
});
|
|
536
|
+
if (!values.search || values.search.length === 0) {
|
|
537
|
+
throw new Error("ui find-element requires at least one --search value");
|
|
538
|
+
}
|
|
539
|
+
const matchMode = zod_1.z.enum(["substring", "exact"]).parse(values["match-mode"] ?? "substring");
|
|
540
|
+
await cmdUiFindElement({
|
|
541
|
+
udid: values.udid,
|
|
542
|
+
search: values.search,
|
|
543
|
+
type: values.type,
|
|
544
|
+
matchMode,
|
|
545
|
+
caseSensitive: values["case-sensitive"] ?? false,
|
|
546
|
+
});
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
case "view": {
|
|
550
|
+
const { values } = (0, node_util_1.parseArgs)({
|
|
551
|
+
args: rest,
|
|
552
|
+
options: {
|
|
553
|
+
udid: { type: "string" },
|
|
554
|
+
output: { type: "string" },
|
|
555
|
+
},
|
|
556
|
+
allowPositionals: false,
|
|
557
|
+
});
|
|
558
|
+
await cmdUiView({ udid: values.udid, output: values.output });
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
default:
|
|
562
|
+
throw new Error(`Unknown ui subcommand: ${subcommand ?? "(none)"}. Run ios-simulator-cli --help for usage.`);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
async function main() {
|
|
566
|
+
const args = process.argv.slice(2);
|
|
567
|
+
if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
|
|
568
|
+
printHelp();
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
if (args.includes("--version") || args.includes("-v")) {
|
|
572
|
+
console.log(VERSION);
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
const command = args[0];
|
|
576
|
+
const rest = args.slice(1);
|
|
577
|
+
switch (command) {
|
|
578
|
+
case "get-booted-sim-id":
|
|
579
|
+
await cmdGetBootedSimId();
|
|
580
|
+
return;
|
|
581
|
+
case "open":
|
|
582
|
+
await cmdOpenSimulator();
|
|
583
|
+
return;
|
|
584
|
+
case "ui":
|
|
585
|
+
await handleUiCommand(rest);
|
|
586
|
+
return;
|
|
587
|
+
case "screenshot": {
|
|
588
|
+
const { values } = (0, node_util_1.parseArgs)({
|
|
589
|
+
args: rest,
|
|
590
|
+
options: {
|
|
591
|
+
udid: { type: "string" },
|
|
592
|
+
output: { type: "string" },
|
|
593
|
+
type: { type: "string" },
|
|
594
|
+
display: { type: "string" },
|
|
595
|
+
mask: { type: "string" },
|
|
596
|
+
},
|
|
597
|
+
allowPositionals: false,
|
|
598
|
+
});
|
|
599
|
+
if (!values.output) {
|
|
600
|
+
throw new Error("screenshot requires --output");
|
|
601
|
+
}
|
|
602
|
+
await cmdScreenshot({
|
|
603
|
+
udid: values.udid,
|
|
604
|
+
outputPath: values.output,
|
|
605
|
+
type: zod_1.z
|
|
606
|
+
.enum(["png", "tiff", "bmp", "gif", "jpeg"])
|
|
607
|
+
.optional()
|
|
608
|
+
.parse(values.type),
|
|
609
|
+
display: zod_1.z.enum(["internal", "external"]).optional().parse(values.display),
|
|
610
|
+
mask: zod_1.z.enum(["ignored", "alpha", "black"]).optional().parse(values.mask),
|
|
611
|
+
});
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
case "record-video": {
|
|
615
|
+
const { values } = (0, node_util_1.parseArgs)({
|
|
616
|
+
args: rest,
|
|
617
|
+
options: {
|
|
618
|
+
udid: { type: "string" },
|
|
619
|
+
output: { type: "string" },
|
|
620
|
+
codec: { type: "string" },
|
|
621
|
+
display: { type: "string" },
|
|
622
|
+
mask: { type: "string" },
|
|
623
|
+
force: { type: "boolean", default: false },
|
|
624
|
+
},
|
|
625
|
+
allowPositionals: false,
|
|
626
|
+
});
|
|
627
|
+
await cmdRecordVideo({
|
|
628
|
+
udid: values.udid,
|
|
629
|
+
outputPath: values.output,
|
|
630
|
+
codec: zod_1.z.enum(["h264", "hevc"]).optional().parse(values.codec),
|
|
631
|
+
display: zod_1.z.enum(["internal", "external"]).optional().parse(values.display),
|
|
632
|
+
mask: zod_1.z.enum(["ignored", "alpha", "black"]).optional().parse(values.mask),
|
|
633
|
+
force: values.force,
|
|
634
|
+
});
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
case "stop-recording":
|
|
638
|
+
await cmdStopRecording();
|
|
639
|
+
return;
|
|
640
|
+
case "install-app": {
|
|
641
|
+
const { values } = (0, node_util_1.parseArgs)({
|
|
642
|
+
args: rest,
|
|
643
|
+
options: {
|
|
644
|
+
udid: { type: "string" },
|
|
645
|
+
"app-path": { type: "string" },
|
|
646
|
+
},
|
|
647
|
+
allowPositionals: false,
|
|
648
|
+
});
|
|
649
|
+
if (!values["app-path"]) {
|
|
650
|
+
throw new Error("install-app requires --app-path");
|
|
651
|
+
}
|
|
652
|
+
await cmdInstallApp(values["app-path"], values.udid);
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
case "launch-app": {
|
|
656
|
+
const { values } = (0, node_util_1.parseArgs)({
|
|
657
|
+
args: rest,
|
|
658
|
+
options: {
|
|
659
|
+
udid: { type: "string" },
|
|
660
|
+
"bundle-id": { type: "string" },
|
|
661
|
+
"terminate-running": { type: "boolean", default: false },
|
|
662
|
+
env: { type: "string", multiple: true },
|
|
663
|
+
},
|
|
664
|
+
allowPositionals: false,
|
|
665
|
+
});
|
|
666
|
+
if (!values["bundle-id"]) {
|
|
667
|
+
throw new Error("launch-app requires --bundle-id");
|
|
668
|
+
}
|
|
669
|
+
await cmdLaunchApp({
|
|
670
|
+
udid: values.udid,
|
|
671
|
+
bundleId: values["bundle-id"],
|
|
672
|
+
terminateRunning: values["terminate-running"],
|
|
673
|
+
env: parseEnvPairs(values.env),
|
|
674
|
+
});
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
default:
|
|
678
|
+
throw new Error(`Unknown command: ${command}. Run ios-simulator-cli --help for usage.`);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
function cleanup() {
|
|
682
|
+
try {
|
|
683
|
+
fs_1.default.rmSync(TMP_ROOT_DIR, { recursive: true, force: true });
|
|
684
|
+
}
|
|
685
|
+
catch {
|
|
686
|
+
// ignore cleanup errors
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
main()
|
|
690
|
+
.catch((error) => {
|
|
691
|
+
console.error(errorWithTroubleshooting(`Error: ${toError(error).message}`));
|
|
692
|
+
process.exitCode = 1;
|
|
693
|
+
})
|
|
694
|
+
.finally(cleanup);
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@debugswift/ios-simulator-cli",
|
|
3
|
+
"publishConfig": {
|
|
4
|
+
"access": "public"
|
|
5
|
+
},
|
|
6
|
+
"version": "1.6.0",
|
|
7
|
+
"description": "CLI for interacting with the iOS simulator",
|
|
8
|
+
"bin": {
|
|
9
|
+
"ios-simulator-cli": "build/index.js"
|
|
10
|
+
},
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/DebugSwift/ios-simulator-cli.git"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"build",
|
|
17
|
+
"package.json"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"prepare": "npm run build",
|
|
21
|
+
"build": "tsc && node -e \"require('fs').chmodSync('build/index.js', '755')\"",
|
|
22
|
+
"start": "node build/index.js",
|
|
23
|
+
"watch": "tsc --watch"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"ios",
|
|
27
|
+
"simulator",
|
|
28
|
+
"cli",
|
|
29
|
+
"simctl",
|
|
30
|
+
"idb"
|
|
31
|
+
],
|
|
32
|
+
"author": {
|
|
33
|
+
"name": "Joshua Yoes",
|
|
34
|
+
"url": "https://joshuayoes.com"
|
|
35
|
+
},
|
|
36
|
+
"license": "MIT",
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"zod": "^3.25.0"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@types/node": "^22.12.0",
|
|
42
|
+
"typescript": "^5.5.4"
|
|
43
|
+
}
|
|
44
|
+
}
|