agemu 0.1.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 +143 -0
- package/dist/artifacts/runs.js +46 -0
- package/dist/cli/main.js +122 -0
- package/dist/commands/app.js +78 -0
- package/dist/commands/build.js +100 -0
- package/dist/commands/diagnostics.js +166 -0
- package/dist/commands/ui.js +115 -0
- package/dist/config/config.js +71 -0
- package/dist/core/errors.js +10 -0
- package/dist/core/output.js +18 -0
- package/dist/core/redact.js +4 -0
- package/dist/doctor/doctor.js +91 -0
- package/dist/native/simctl.js +104 -0
- package/dist/native/xcodebuild.js +49 -0
- package/dist/process/run-process.js +62 -0
- package/package.json +31 -0
- package/runner/AgentRunner/AgentRunner.swift +82 -0
- package/runner/AgentRunner.xcodeproj/project.pbxproj +26 -0
- package/runner/AgentRunner.xcodeproj/xcshareddata/xcschemes/AgentRunner.xcscheme +6 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Illia Puzanov
|
|
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,143 @@
|
|
|
1
|
+
# agemu
|
|
2
|
+
|
|
3
|
+
`agemu` builds, runs, inspects, and controls an iOS app in Simulator. Commands return JSON, so people and coding agents can use the same interface.
|
|
4
|
+
|
|
5
|
+
> [!NOTE]
|
|
6
|
+
> `agemu` is under active development. The command and configuration formats may change before 1.0.
|
|
7
|
+
|
|
8
|
+
## Requirements
|
|
9
|
+
|
|
10
|
+
- macOS with Xcode and an iOS Simulator runtime
|
|
11
|
+
- Node.js 24 or later
|
|
12
|
+
- pnpm
|
|
13
|
+
|
|
14
|
+
## Install from source
|
|
15
|
+
|
|
16
|
+
Clone this repository, then run:
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
cd agemu
|
|
20
|
+
pnpm install
|
|
21
|
+
pnpm build
|
|
22
|
+
pnpm link --global
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Run `agemu --version` to confirm the installation.
|
|
26
|
+
|
|
27
|
+
## Install the agent skill
|
|
28
|
+
|
|
29
|
+
Install the skill for Codex, Claude Code, OpenCode, or another supported agent:
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
npx skills add zoilorys/agemu --skill agemu
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The installer prompts for the target agents and installation scope. Start a new agent session after installation. The skill activates for repositories that contain `.agemu.json`.
|
|
36
|
+
|
|
37
|
+
Codex users can instead install the native plugin:
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
codex plugin marketplace add zoilorys/agemu
|
|
41
|
+
codex plugin add agemu@agemu
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Claude Code users can instead install its native plugin:
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
claude plugin marketplace add zoilorys/agemu
|
|
48
|
+
claude plugin install agemu@agemu
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Configure an app
|
|
52
|
+
|
|
53
|
+
Add `.agemu.json` to the root of the iOS app repository:
|
|
54
|
+
|
|
55
|
+
```json
|
|
56
|
+
{
|
|
57
|
+
"version": 1,
|
|
58
|
+
"project": "App.xcodeproj",
|
|
59
|
+
"scheme": "App",
|
|
60
|
+
"configuration": "Debug",
|
|
61
|
+
"bundleId": "com.example.App",
|
|
62
|
+
"simulator": {
|
|
63
|
+
"name": "iPhone 17",
|
|
64
|
+
"runtime": "iOS-26-0"
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Use `workspace` instead of `project` for an `.xcworkspace`. Select a simulator by `udid`, or by `name` with an optional `runtime`.
|
|
70
|
+
|
|
71
|
+
Check the configuration and native tools:
|
|
72
|
+
|
|
73
|
+
```sh
|
|
74
|
+
agemu doctor --pretty
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Build and run an app
|
|
78
|
+
|
|
79
|
+
```sh
|
|
80
|
+
agemu simulator list --pretty
|
|
81
|
+
agemu simulator boot
|
|
82
|
+
agemu build
|
|
83
|
+
agemu app install
|
|
84
|
+
agemu app launch --arg=VALUE --env=NAME=VALUE
|
|
85
|
+
agemu observe
|
|
86
|
+
agemu logs show --last=1m --level=info --limit=200
|
|
87
|
+
agemu diagnose --last=1m --level=info --limit=200
|
|
88
|
+
agemu app terminate
|
|
89
|
+
agemu simulator shutdown
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
`app launch`, `app restart`, and `app terminate` target the configured bundle ID. Only `app install` requires a prior build.
|
|
93
|
+
|
|
94
|
+
`agemu` writes build state, logs, screenshots, and test results to `.agemu/`. Add that directory to the app repository's `.gitignore`.
|
|
95
|
+
|
|
96
|
+
## Run a UI plan
|
|
97
|
+
|
|
98
|
+
Build and install the app first. Then create `ui-plan.json`:
|
|
99
|
+
|
|
100
|
+
```json
|
|
101
|
+
{
|
|
102
|
+
"version": 1,
|
|
103
|
+
"actions": [
|
|
104
|
+
{ "launch": { "arguments": ["--ui-testing"], "environment": { "RUN_ID": "example" } } },
|
|
105
|
+
{ "wait": { "identifier": "email", "timeout": 5 } },
|
|
106
|
+
{ "type": { "identifier": "email", "text": "agent@example.com" } },
|
|
107
|
+
{ "tap": { "identifier": "save" } },
|
|
108
|
+
{ "assertVisible": { "label": "Saved" } },
|
|
109
|
+
{ "screenshot": { "name": "saved" } }
|
|
110
|
+
]
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Run the plan:
|
|
115
|
+
|
|
116
|
+
```sh
|
|
117
|
+
agemu ui run --plan=ui-plan.json
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Targets accept an accessibility `identifier` or an exact `label`. The `tap` action also accepts `x` and `y` screen coordinates. The `inspect` action returns the XCTest accessibility hierarchy in `runnerResult.trees`.
|
|
121
|
+
|
|
122
|
+
The bundled XCTest runner needs no macOS Accessibility or Screen Recording permission. Each run saves an `.xcresult` bundle and `xcodebuild.log` under `.agemu/runs/`.
|
|
123
|
+
|
|
124
|
+
## Develop
|
|
125
|
+
|
|
126
|
+
```sh
|
|
127
|
+
pnpm install
|
|
128
|
+
pnpm test
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
The native integration test needs an available Simulator:
|
|
132
|
+
|
|
133
|
+
```sh
|
|
134
|
+
AGEMU_NATIVE_SIMULATOR_UDID=<udid> pnpm test:integration:native
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Contribute
|
|
138
|
+
|
|
139
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
140
|
+
|
|
141
|
+
## License
|
|
142
|
+
|
|
143
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { appendFile, mkdir, rm } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { redact } from '../core/redact.js';
|
|
4
|
+
export async function createRun(root, now = new Date()) {
|
|
5
|
+
const id = `${now.toISOString().replaceAll(':', '-')}-${process.pid}-${Math.random().toString(16).slice(2, 10)}`;
|
|
6
|
+
const directory = path.join(root, '.agemu', 'runs', id);
|
|
7
|
+
await mkdir(directory, { recursive: true });
|
|
8
|
+
return { id, directory, relativeDirectory: path.relative(root, directory) };
|
|
9
|
+
}
|
|
10
|
+
export function redactValue(value, secrets) {
|
|
11
|
+
if (typeof value === 'string')
|
|
12
|
+
return redact(value, secrets);
|
|
13
|
+
if (Array.isArray(value))
|
|
14
|
+
return value.map((item) => redactValue(item, secrets));
|
|
15
|
+
if (value && typeof value === 'object') {
|
|
16
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [redact(key, secrets), redactValue(item, secrets)]));
|
|
17
|
+
}
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
async function acquireLock(directory) {
|
|
21
|
+
const lock = path.join(directory, 'events.lock');
|
|
22
|
+
const deadline = Date.now() + 10_000;
|
|
23
|
+
while (true) {
|
|
24
|
+
try {
|
|
25
|
+
await mkdir(lock);
|
|
26
|
+
return () => rm(lock, { recursive: true, force: true });
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
if (error.code !== 'EEXIST' || Date.now() >= deadline)
|
|
30
|
+
throw error;
|
|
31
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export async function appendEvent(root, event, secrets) {
|
|
36
|
+
const directory = path.join(root, '.agemu');
|
|
37
|
+
await mkdir(directory, { recursive: true });
|
|
38
|
+
const line = `${JSON.stringify(redactValue(event, secrets))}\n`;
|
|
39
|
+
const release = await acquireLock(directory);
|
|
40
|
+
try {
|
|
41
|
+
await appendFile(path.join(directory, 'events.jsonl'), line, { encoding: 'utf8', mode: 0o600, flag: 'a' });
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
await release();
|
|
45
|
+
}
|
|
46
|
+
}
|
package/dist/cli/main.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { errorResult, writeResult } from '../core/output.js';
|
|
5
|
+
import { CliError } from '../core/errors.js';
|
|
6
|
+
import { loadConfig } from '../config/config.js';
|
|
7
|
+
import { doctor } from '../doctor/doctor.js';
|
|
8
|
+
import { bootDevice, listDevices, resolveDevice, shutdownDevice } from '../native/simctl.js';
|
|
9
|
+
import { buildApp } from '../commands/build.js';
|
|
10
|
+
import { controlApp } from '../commands/app.js';
|
|
11
|
+
import { diagnose, observe, showLogs } from '../commands/diagnostics.js';
|
|
12
|
+
import { buildUiRunner, runUiPlan } from '../commands/ui.js';
|
|
13
|
+
const args = process.argv.slice(2);
|
|
14
|
+
const packageVersion = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version;
|
|
15
|
+
const pretty = args.includes('--pretty');
|
|
16
|
+
const debug = args.includes('--debug');
|
|
17
|
+
const command = args.find((arg) => !arg.startsWith('--'));
|
|
18
|
+
const option = (name) => {
|
|
19
|
+
const inline = args.find((arg) => arg.startsWith(`--${name}=`));
|
|
20
|
+
if (inline)
|
|
21
|
+
return inline.slice(name.length + 3);
|
|
22
|
+
const index = args.indexOf(`--${name}`);
|
|
23
|
+
return index >= 0 ? args[index + 1] : undefined;
|
|
24
|
+
};
|
|
25
|
+
const requiredString = (name) => {
|
|
26
|
+
const value = option(name);
|
|
27
|
+
if (value === undefined || value.length === 0 || (args.includes(`--${name}`) && value.startsWith('--'))) {
|
|
28
|
+
throw new CliError('UI_VALIDATION_FAILED', `--${name} requires a non-empty value`);
|
|
29
|
+
}
|
|
30
|
+
return value;
|
|
31
|
+
};
|
|
32
|
+
if (args.includes('--help')) {
|
|
33
|
+
writeResult({ ok: true, data: { help: 'agemu [--pretty] [--debug] <command>' } }, pretty);
|
|
34
|
+
}
|
|
35
|
+
else if (args.includes('--version')) {
|
|
36
|
+
writeResult({ ok: true, data: { version: packageVersion } }, pretty);
|
|
37
|
+
}
|
|
38
|
+
else if (!command) {
|
|
39
|
+
writeResult(errorResult(new CliError('COMMAND_INVALID', 'A command is required'), debug), pretty);
|
|
40
|
+
process.exitCode = 1;
|
|
41
|
+
}
|
|
42
|
+
else
|
|
43
|
+
try {
|
|
44
|
+
let data;
|
|
45
|
+
if (command === 'config' && args.includes('show')) {
|
|
46
|
+
const config = await loadConfig();
|
|
47
|
+
const { root: _, redactions: __, ...safe } = config;
|
|
48
|
+
data = safe;
|
|
49
|
+
}
|
|
50
|
+
else if (command === 'simulator' && args.includes('list')) {
|
|
51
|
+
data = { devices: await listDevices() };
|
|
52
|
+
}
|
|
53
|
+
else if (command === 'simulator' && (args.includes('boot') || args.includes('shutdown'))) {
|
|
54
|
+
const config = await loadConfig();
|
|
55
|
+
const explicitUdid = args.find((arg) => arg.startsWith('--udid='))?.slice('--udid='.length);
|
|
56
|
+
const explicitName = args.find((arg) => arg.startsWith('--name='))?.slice('--name='.length);
|
|
57
|
+
const explicitRuntime = args.find((arg) => arg.startsWith('--runtime='))?.slice('--runtime='.length);
|
|
58
|
+
const selector = explicitUdid
|
|
59
|
+
? { udid: explicitUdid }
|
|
60
|
+
: config.simulator.udid
|
|
61
|
+
? { udid: config.simulator.udid }
|
|
62
|
+
: explicitName
|
|
63
|
+
? { name: explicitName, ...(explicitRuntime ? { runtime: explicitRuntime } : {}) }
|
|
64
|
+
: config.simulator;
|
|
65
|
+
const device = resolveDevice(await listDevices(), selector);
|
|
66
|
+
const action = args.includes('boot') ? 'boot' : 'shutdown';
|
|
67
|
+
const controlled = action === 'boot' ? await bootDevice(device) : await shutdownDevice(device);
|
|
68
|
+
data = { action, device: controlled };
|
|
69
|
+
}
|
|
70
|
+
else if (command === 'observe') {
|
|
71
|
+
data = await observe(await loadConfig());
|
|
72
|
+
}
|
|
73
|
+
else if (command === 'ui') {
|
|
74
|
+
const plan = args.includes('run') ? path.resolve(requiredString('plan')) : undefined;
|
|
75
|
+
const config = await loadConfig();
|
|
76
|
+
if (args.includes('build-runner'))
|
|
77
|
+
data = await buildUiRunner(config, {}, true);
|
|
78
|
+
else if (plan)
|
|
79
|
+
data = await runUiPlan(config, plan);
|
|
80
|
+
else
|
|
81
|
+
throw new CliError('COMMAND_INVALID', 'ui requires build-runner or run --plan=<file>');
|
|
82
|
+
}
|
|
83
|
+
else if (command === 'build') {
|
|
84
|
+
const config = await loadConfig();
|
|
85
|
+
data = await buildApp(config);
|
|
86
|
+
}
|
|
87
|
+
else if (command === 'app') {
|
|
88
|
+
const config = await loadConfig();
|
|
89
|
+
const action = ['install', 'launch', 'terminate', 'restart', 'open-url'].find((name) => args.includes(name));
|
|
90
|
+
if (!action)
|
|
91
|
+
throw new Error('app requires install, launch, terminate, or restart');
|
|
92
|
+
data = await controlApp(config, action, {
|
|
93
|
+
arguments: args.filter((arg) => arg.startsWith('--arg=')).map((arg) => arg.slice('--arg='.length)),
|
|
94
|
+
environment: args.filter((arg) => arg.startsWith('--env=')).map((arg) => arg.slice('--env='.length)),
|
|
95
|
+
url: args.find((arg) => arg.startsWith('--url='))?.slice('--url='.length),
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
else if (command === 'logs') {
|
|
99
|
+
const config = await loadConfig();
|
|
100
|
+
if (!args.includes('show'))
|
|
101
|
+
throw new CliError('COMMAND_INVALID', 'logs requires show');
|
|
102
|
+
data = await showLogs(config, {
|
|
103
|
+
last: option('last'), level: option('level'), limit: option('limit') === undefined ? undefined : Number(option('limit')),
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
else if (command === 'diagnose') {
|
|
107
|
+
const config = await loadConfig();
|
|
108
|
+
data = await diagnose(config, {
|
|
109
|
+
last: option('last'), level: option('level'), limit: option('limit') === undefined ? undefined : Number(option('limit')),
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
else if (command === 'doctor') {
|
|
113
|
+
data = await doctor();
|
|
114
|
+
}
|
|
115
|
+
else
|
|
116
|
+
throw new CliError('COMMAND_INVALID', `Unknown command: ${command}`);
|
|
117
|
+
writeResult({ ok: true, data }, pretty);
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
writeResult(errorResult(error, debug), pretty);
|
|
121
|
+
process.exitCode = 1;
|
|
122
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { access, readFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { CliError } from '../core/errors.js';
|
|
4
|
+
import { redact } from '../core/redact.js';
|
|
5
|
+
import { listDevices, resolveDevice, simctl } from '../native/simctl.js';
|
|
6
|
+
const stopped = (value) => /not running|no such process|found nothing to terminate/i.test(value);
|
|
7
|
+
async function checked(runner, args, secrets, allowStopped = false, options) {
|
|
8
|
+
const result = await runner(args, options);
|
|
9
|
+
if (result.exitCode !== 0 && !(allowStopped && stopped(result.stderr))) {
|
|
10
|
+
throw new CliError('PROCESS_FAILED', redact(result.stderr.trim() || `simctl ${args[0]} failed`, secrets), {
|
|
11
|
+
command: ['xcrun', 'simctl', ...args].map((value) => redact(value, secrets)), exitCode: result.exitCode, signal: result.signal,
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function launchEnvironment(values) {
|
|
16
|
+
const environment = { ...process.env };
|
|
17
|
+
for (const value of values) {
|
|
18
|
+
const separator = value.indexOf('=');
|
|
19
|
+
const name = value.slice(0, separator);
|
|
20
|
+
if (separator < 1 || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
|
|
21
|
+
throw new CliError('COMMAND_INVALID', '--env must use KEY=VALUE with a valid environment variable name');
|
|
22
|
+
}
|
|
23
|
+
environment[`SIMCTL_CHILD_${name}`] = value.slice(separator + 1);
|
|
24
|
+
}
|
|
25
|
+
return environment;
|
|
26
|
+
}
|
|
27
|
+
async function defaultState(file, secrets) {
|
|
28
|
+
try {
|
|
29
|
+
return JSON.parse(await readFile(file, 'utf8'));
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
throw new CliError('APP_NOT_BUILT', redact(`Cannot read app state: ${error instanceof Error ? error.message : String(error)}`, secrets));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export async function controlApp(config, action, options = {}, dependencies = {}) {
|
|
36
|
+
const runner = dependencies.runner ?? simctl;
|
|
37
|
+
const udid = await (dependencies.resolveUdid
|
|
38
|
+
? dependencies.resolveUdid(config)
|
|
39
|
+
: config.simulator.udid ?? listDevices().then((devices) => resolveDevice(devices, config.simulator).udid));
|
|
40
|
+
const stateFile = path.join(config.root, '.agemu', 'state.json');
|
|
41
|
+
const secrets = config.redactions ?? [];
|
|
42
|
+
const bundleId = config.bundleId;
|
|
43
|
+
const terminate = () => checked(runner, ['terminate', udid, bundleId], secrets, true);
|
|
44
|
+
const launch = () => checked(runner, [
|
|
45
|
+
'launch', udid, bundleId, ...(options.arguments ?? []),
|
|
46
|
+
], secrets, false, { env: launchEnvironment(options.environment ?? []) });
|
|
47
|
+
if (action === 'install') {
|
|
48
|
+
const state = dependencies.readState
|
|
49
|
+
? await dependencies.readState(stateFile)
|
|
50
|
+
: await defaultState(stateFile, secrets);
|
|
51
|
+
if (state.bundleId !== bundleId || state.udid !== udid || state.configuration !== config.configuration) {
|
|
52
|
+
throw new CliError('APP_NOT_BUILT', 'Cached app state does not match the configured app, simulator, and configuration');
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
await (dependencies.appExists ?? access)(state.appPath);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
throw new CliError('APP_NOT_BUILT', redact(`Cached app product does not exist: ${state.appPath}`, secrets));
|
|
59
|
+
}
|
|
60
|
+
await checked(runner, ['install', udid, state.appPath], secrets);
|
|
61
|
+
}
|
|
62
|
+
else if (action === 'launch') {
|
|
63
|
+
await launch();
|
|
64
|
+
}
|
|
65
|
+
else if (action === 'terminate') {
|
|
66
|
+
await terminate();
|
|
67
|
+
}
|
|
68
|
+
else if (action === 'restart') {
|
|
69
|
+
await terminate();
|
|
70
|
+
await launch();
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
if (!options.url)
|
|
74
|
+
throw new CliError('COMMAND_INVALID', 'app open-url requires --url=<url>');
|
|
75
|
+
await checked(runner, ['openurl', udid, options.url], secrets);
|
|
76
|
+
}
|
|
77
|
+
return { action, udid, bundleId };
|
|
78
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { mkdir, rename, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { CliError } from '../core/errors.js';
|
|
4
|
+
import { redact } from '../core/redact.js';
|
|
5
|
+
import { buildArguments, selectBuildProduct } from '../native/xcodebuild.js';
|
|
6
|
+
import { listDevices, resolveDevice } from '../native/simctl.js';
|
|
7
|
+
import { runProcess } from '../process/run-process.js';
|
|
8
|
+
function runId(date) {
|
|
9
|
+
return date.toISOString().replaceAll(':', '-');
|
|
10
|
+
}
|
|
11
|
+
async function writeAtomic(file, state) {
|
|
12
|
+
const temporary = `${file}.${process.pid}.${Math.random().toString(16).slice(2)}.tmp`;
|
|
13
|
+
await writeFile(temporary, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
|
|
14
|
+
await rename(temporary, file);
|
|
15
|
+
}
|
|
16
|
+
function logFromError(error, secrets) {
|
|
17
|
+
const result = error instanceof CliError ? error.details?.result : undefined;
|
|
18
|
+
const output = typeof result === 'object' && result !== null ? result : {};
|
|
19
|
+
return {
|
|
20
|
+
stdout: redact(typeof output.stdout === 'string' ? output.stdout : '', secrets),
|
|
21
|
+
stderr: redact(typeof output.stderr === 'string' ? output.stderr : '', secrets),
|
|
22
|
+
executionError: redact(error instanceof Error ? error.message : String(error), secrets),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
async function writeLog(file, log) {
|
|
26
|
+
await writeFile(file, `${JSON.stringify(log, null, 2)}\n`, { mode: 0o600 });
|
|
27
|
+
}
|
|
28
|
+
export async function buildApp(config, dependencies = {}) {
|
|
29
|
+
const run = dependencies.run ?? runProcess;
|
|
30
|
+
const now = dependencies.now?.() ?? new Date();
|
|
31
|
+
const udid = await (dependencies.resolveUdid
|
|
32
|
+
? dependencies.resolveUdid(config)
|
|
33
|
+
: config.simulator.udid ?? listDevices().then((devices) => resolveDevice(devices, config.simulator).udid));
|
|
34
|
+
const stateDirectory = path.join(config.root, '.agemu');
|
|
35
|
+
const runDirectory = path.join(stateDirectory, 'runs', runId(now));
|
|
36
|
+
await mkdir(runDirectory, { recursive: true });
|
|
37
|
+
const secrets = config.redactions ?? [];
|
|
38
|
+
const buildLog = path.join(runDirectory, 'xcodebuild.log');
|
|
39
|
+
let build;
|
|
40
|
+
try {
|
|
41
|
+
build = await run('xcodebuild', buildArguments(config, udid, 'build'));
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
await writeLog(buildLog, logFromError(error, secrets));
|
|
45
|
+
throw new CliError('BUILD_FAILED', 'Unable to execute xcodebuild', { log: redact(path.relative(config.root, buildLog), secrets) });
|
|
46
|
+
}
|
|
47
|
+
await writeLog(buildLog, { stdout: redact(build.stdout, secrets), stderr: redact(build.stderr, secrets) });
|
|
48
|
+
if (build.exitCode !== 0) {
|
|
49
|
+
throw new CliError('BUILD_FAILED', 'xcodebuild failed', {
|
|
50
|
+
exitCode: build.exitCode,
|
|
51
|
+
signal: build.signal,
|
|
52
|
+
log: redact(path.relative(config.root, buildLog), secrets),
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
const settingsLog = path.join(runDirectory, 'build-settings.log');
|
|
56
|
+
let settings;
|
|
57
|
+
try {
|
|
58
|
+
settings = await run('xcodebuild', buildArguments(config, udid, 'settings'));
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
await writeLog(settingsLog, logFromError(error, secrets));
|
|
62
|
+
throw new CliError('BUILD_FAILED', 'Unable to execute xcodebuild for build settings', { log: redact(path.relative(config.root, settingsLog), secrets) });
|
|
63
|
+
}
|
|
64
|
+
await writeLog(settingsLog, { stdout: redact(settings.stdout, secrets), stderr: redact(settings.stderr, secrets) });
|
|
65
|
+
if (settings.exitCode !== 0) {
|
|
66
|
+
throw new CliError('BUILD_FAILED', 'Unable to read build settings', {
|
|
67
|
+
exitCode: settings.exitCode,
|
|
68
|
+
signal: settings.signal,
|
|
69
|
+
log: redact(path.relative(config.root, settingsLog), secrets),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
let product;
|
|
73
|
+
try {
|
|
74
|
+
product = selectBuildProduct(settings.stdout, config.bundleId);
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
throw new CliError('BUILD_FAILED', redact(error instanceof Error ? error.message : String(error), secrets), {
|
|
78
|
+
log: redact(path.relative(config.root, settingsLog), secrets),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
const state = {
|
|
82
|
+
appPath: product.appPath,
|
|
83
|
+
bundleId: product.bundleId,
|
|
84
|
+
executableName: product.executableName,
|
|
85
|
+
udid,
|
|
86
|
+
configuration: config.configuration,
|
|
87
|
+
updatedAt: now.toISOString(),
|
|
88
|
+
};
|
|
89
|
+
await writeAtomic(path.join(stateDirectory, 'state.json'), state);
|
|
90
|
+
return {
|
|
91
|
+
appPath: redact(product.appPath, secrets),
|
|
92
|
+
bundleId: redact(product.bundleId, secrets),
|
|
93
|
+
executableName: redact(product.executableName, secrets),
|
|
94
|
+
target: redact(product.target, secrets),
|
|
95
|
+
udid: redact(udid, secrets),
|
|
96
|
+
configuration: redact(config.configuration, secrets),
|
|
97
|
+
derivedData: redact(path.join(stateDirectory, 'DerivedData'), secrets),
|
|
98
|
+
logs: { build: redact(path.relative(config.root, buildLog), secrets), settings: redact(path.relative(config.root, settingsLog), secrets) },
|
|
99
|
+
};
|
|
100
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { appendEvent, createRun, redactValue } from '../artifacts/runs.js';
|
|
4
|
+
import { CliError } from '../core/errors.js';
|
|
5
|
+
import { redact } from '../core/redact.js';
|
|
6
|
+
import { listDevices, resolveDevice, simctl } from '../native/simctl.js';
|
|
7
|
+
const allowedLevels = new Set(['default', 'info', 'debug', 'error', 'fault']);
|
|
8
|
+
async function configuredDevice(config) {
|
|
9
|
+
return resolveDevice(await listDevices(), config.simulator);
|
|
10
|
+
}
|
|
11
|
+
async function state(config, dependencies) {
|
|
12
|
+
const file = path.join(config.root, '.agemu', 'state.json');
|
|
13
|
+
try {
|
|
14
|
+
return dependencies.readState ? await dependencies.readState(file) : JSON.parse(await readFile(file, 'utf8'));
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
throw new CliError('APP_NOT_BUILT', `Cannot read app state: ${error instanceof Error ? error.message : String(error)}`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function safeRelative(root, file, secrets) {
|
|
21
|
+
return redact(path.relative(root, file), secrets);
|
|
22
|
+
}
|
|
23
|
+
async function runContext(config, dependencies) {
|
|
24
|
+
const now = dependencies.now?.() ?? new Date();
|
|
25
|
+
const [run, device] = await Promise.all([
|
|
26
|
+
createRun(config.root, now),
|
|
27
|
+
dependencies.resolveDevice ? dependencies.resolveDevice(config) : configuredDevice(config),
|
|
28
|
+
]);
|
|
29
|
+
return { run, now, device };
|
|
30
|
+
}
|
|
31
|
+
function failure(error, secrets) {
|
|
32
|
+
const normalized = error instanceof CliError ? error : new CliError('PROCESS_FAILED', error instanceof Error ? error.message : String(error));
|
|
33
|
+
return { code: normalized.code, message: redact(normalized.message, secrets) };
|
|
34
|
+
}
|
|
35
|
+
export async function observe(config, dependencies = {}) {
|
|
36
|
+
const secrets = config.redactions ?? [];
|
|
37
|
+
const { run, now, device } = await runContext(config, dependencies);
|
|
38
|
+
const directory = path.join(run.directory, 'screenshots');
|
|
39
|
+
const screenshot = path.join(directory, 'screen.png');
|
|
40
|
+
await mkdir(directory, { recursive: true });
|
|
41
|
+
const args = ['io', device.udid, 'screenshot', screenshot];
|
|
42
|
+
try {
|
|
43
|
+
const result = await (dependencies.runner ?? simctl)(args);
|
|
44
|
+
if (result.exitCode !== 0)
|
|
45
|
+
throw new CliError('PROCESS_FAILED', result.stderr.trim() || 'Screenshot capture failed');
|
|
46
|
+
const data = {
|
|
47
|
+
run: run.relativeDirectory, screenshot: safeRelative(config.root, screenshot, secrets), capturedAt: now.toISOString(),
|
|
48
|
+
simulator: redactValue(device, secrets), bundleId: redact(config.bundleId, secrets),
|
|
49
|
+
};
|
|
50
|
+
await appendEvent(config.root, { at: now.toISOString(), command: 'observe', status: 'ok', data }, secrets);
|
|
51
|
+
return data;
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
const details = { run: run.relativeDirectory, simulator: redactValue(device, secrets), bundleId: redact(config.bundleId, secrets) };
|
|
55
|
+
await appendEvent(config.root, { at: now.toISOString(), command: 'observe', status: 'error', error: failure(error, secrets), details }, secrets);
|
|
56
|
+
throw new CliError('PROCESS_FAILED', redact(error instanceof Error ? error.message : String(error), secrets), details);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function predicate(app) {
|
|
60
|
+
if (!app.executableName)
|
|
61
|
+
throw new CliError('APP_NOT_BUILT', 'Cached app state does not contain an executable name; rebuild the app');
|
|
62
|
+
const processName = app.executableName.replaceAll('\\', '\\\\').replaceAll('"', '\\"');
|
|
63
|
+
return `process == "${processName}"`;
|
|
64
|
+
}
|
|
65
|
+
function logArguments(level, app) {
|
|
66
|
+
const processPredicate = predicate(app);
|
|
67
|
+
if (level === 'info')
|
|
68
|
+
return ['--info', '--predicate', processPredicate];
|
|
69
|
+
if (level === 'debug')
|
|
70
|
+
return ['--debug', '--predicate', processPredicate];
|
|
71
|
+
if (level === 'error')
|
|
72
|
+
return ['--predicate', `${processPredicate} AND (messageType == error OR messageType == fault)`];
|
|
73
|
+
if (level === 'fault')
|
|
74
|
+
return ['--predicate', `${processPredicate} AND messageType == fault`];
|
|
75
|
+
return ['--predicate', processPredicate];
|
|
76
|
+
}
|
|
77
|
+
export async function showLogs(config, options = {}, dependencies = {}) {
|
|
78
|
+
const secrets = config.redactions ?? [];
|
|
79
|
+
const last = options.last ?? '30s';
|
|
80
|
+
const level = options.level ?? 'default';
|
|
81
|
+
const limit = options.limit ?? 100;
|
|
82
|
+
if (!/^\d+[smhd]$/.test(last))
|
|
83
|
+
throw new CliError('COMMAND_INVALID', '--last must be a number followed by s, m, h, or d');
|
|
84
|
+
if (!allowedLevels.has(level))
|
|
85
|
+
throw new CliError('COMMAND_INVALID', '--level must be default, info, debug, error, or fault');
|
|
86
|
+
if (!Number.isSafeInteger(limit) || limit < 0 || limit > 10_000)
|
|
87
|
+
throw new CliError('COMMAND_INVALID', '--limit must be an integer from 0 to 10000');
|
|
88
|
+
const { run, now, device } = await runContext(config, dependencies);
|
|
89
|
+
const app = await state(config, dependencies);
|
|
90
|
+
if (app.bundleId !== config.bundleId || app.udid !== device.udid)
|
|
91
|
+
throw new CliError('APP_NOT_BUILT', 'Cached app state does not match the configured app and simulator');
|
|
92
|
+
const artifact = path.join(run.directory, 'logs.txt');
|
|
93
|
+
const args = ['spawn', device.udid, 'log', 'show', '--last', last, ...logArguments(level, app), '--style', 'compact'];
|
|
94
|
+
let result;
|
|
95
|
+
try {
|
|
96
|
+
result = await (dependencies.runner ?? simctl)(args);
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
const captured = error instanceof CliError && typeof error.details?.result === 'object' && error.details.result !== null
|
|
100
|
+
? error.details.result
|
|
101
|
+
: {};
|
|
102
|
+
const full = redact(`${typeof captured.stdout === 'string' ? captured.stdout : ''}${typeof captured.stderr === 'string' ? captured.stderr : ''}`, secrets);
|
|
103
|
+
await writeFile(artifact, full, { mode: 0o600 });
|
|
104
|
+
const details = { artifact: safeRelative(config.root, artifact, secrets), run: run.relativeDirectory };
|
|
105
|
+
await appendEvent(config.root, { at: now.toISOString(), command: 'logs show', status: 'error', args, error: failure(error, secrets), details }, secrets);
|
|
106
|
+
throw new CliError(error instanceof CliError ? error.code : 'PROCESS_FAILED', redact(error instanceof Error ? error.message : String(error), secrets), details);
|
|
107
|
+
}
|
|
108
|
+
const full = redact(`${result.stdout}${result.stderr}`, secrets);
|
|
109
|
+
await writeFile(artifact, full, { mode: 0o600 });
|
|
110
|
+
if (result.exitCode !== 0) {
|
|
111
|
+
const details = { artifact: safeRelative(config.root, artifact, secrets), run: run.relativeDirectory };
|
|
112
|
+
const error = new CliError('PROCESS_FAILED', redact(result.stderr.trim() || 'Log collection failed', secrets), details);
|
|
113
|
+
await appendEvent(config.root, { at: now.toISOString(), command: 'logs show', status: 'error', args, error: failure(error, secrets), details }, secrets);
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
const lines = full.split(/\r?\n/).filter((line, index, all) => line || index < all.length - 1);
|
|
117
|
+
const data = {
|
|
118
|
+
run: run.relativeDirectory, udid: redact(device.udid, secrets), bundleId: redact(config.bundleId, secrets), last, level,
|
|
119
|
+
logs: limit === 0 ? [] : lines.slice(-limit), truncated: lines.length > limit,
|
|
120
|
+
artifact: safeRelative(config.root, artifact, secrets), capturedAt: now.toISOString(),
|
|
121
|
+
};
|
|
122
|
+
await appendEvent(config.root, { at: now.toISOString(), command: 'logs show', status: 'ok', args, data }, secrets);
|
|
123
|
+
return data;
|
|
124
|
+
}
|
|
125
|
+
export async function diagnose(config, options = {}, dependencies = {}) {
|
|
126
|
+
const now = dependencies.now?.() ?? new Date();
|
|
127
|
+
const secrets = config.redactions ?? [];
|
|
128
|
+
const evidence = {};
|
|
129
|
+
const failures = {};
|
|
130
|
+
try {
|
|
131
|
+
evidence.simulator = redactValue(await (dependencies.resolveDevice ? dependencies.resolveDevice(config) : configuredDevice(config)), secrets);
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
failures.simulator = failure(error, secrets);
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
evidence.build = redactValue(await state(config, dependencies), secrets);
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
failures.build = failure(error, secrets);
|
|
141
|
+
}
|
|
142
|
+
try {
|
|
143
|
+
evidence.observation = await observe(config, { ...dependencies, now: () => now });
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
failures.observation = failure(error, secrets);
|
|
147
|
+
}
|
|
148
|
+
try {
|
|
149
|
+
evidence.logs = await showLogs(config, options, { ...dependencies, now: () => now });
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
failures.logs = failure(error, secrets);
|
|
153
|
+
}
|
|
154
|
+
try {
|
|
155
|
+
const eventsFile = path.join(config.root, '.agemu', 'events.jsonl');
|
|
156
|
+
const contents = dependencies.readEvents ? await dependencies.readEvents(eventsFile) : await readFile(eventsFile, 'utf8');
|
|
157
|
+
evidence.recentErrors = redactValue(String(contents).split('\n').filter(Boolean).map((line) => JSON.parse(line))
|
|
158
|
+
.filter((event) => event.status === 'error').slice(-10), secrets);
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
evidence.recentErrors = [];
|
|
162
|
+
}
|
|
163
|
+
const data = { generatedAt: now.toISOString(), bundleId: redact(config.bundleId, secrets), partial: Object.keys(failures).length > 0, evidence, failures };
|
|
164
|
+
await appendEvent(config.root, { at: now.toISOString(), command: 'diagnose', status: data.partial ? 'partial' : 'ok', data }, secrets);
|
|
165
|
+
return data;
|
|
166
|
+
}
|