runcloud 0.1.108 → 0.1.110
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/README.md +33 -0
- package/dist/api.js +111 -37
- package/dist/commands/run-cloud.js +680 -6
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/runcloud-0.1.110.tgz +0 -0
- package/skills/run-cloud-ios-simulator/SKILL.md +115 -124
package/README.md
CHANGED
|
@@ -64,12 +64,22 @@ runcloud ios create --model iphone --install ./MyApp.app # boot a simulator an
|
|
|
64
64
|
runcloud ios list # list active sessions
|
|
65
65
|
runcloud ios get <id> # inspect a session (viewer URL, status)
|
|
66
66
|
runcloud ios open-url myapp://path --id <id> # open a URL or deep link
|
|
67
|
+
runcloud ios tap <id> 0.5 0.75 # normalized top-left display coordinates
|
|
68
|
+
runcloud ios type-text <id> "Hello from iOS!" # printable US ASCII, tab, and line feed
|
|
69
|
+
runcloud ios screenshot <id> --output ios.png # save a PNG without exposing the viewer URL
|
|
70
|
+
runcloud ios recording start <id> --json # start an idempotent MP4 screen recording
|
|
71
|
+
runcloud ios recording stop <id> <recording-id> --json # stop and finalize it
|
|
72
|
+
runcloud ios recording download <id> <recording-id> \
|
|
73
|
+
--output ios.mp4 --json # download through the authenticated API
|
|
67
74
|
runcloud ios logs <id> --tail 200 # read logs from this lease
|
|
68
75
|
runcloud ios logs <id> --follow # follow new log entries
|
|
69
76
|
runcloud ios delete <id> # release the session
|
|
70
77
|
|
|
71
78
|
# Android works the same under `runcloud android …`
|
|
72
79
|
runcloud android create --model pixel
|
|
80
|
+
runcloud android swipe <id> 0.5 0.8 0.5 0.2 --duration 250
|
|
81
|
+
runcloud android press-key <id> enter
|
|
82
|
+
runcloud android screenshot <id> --output android.png --json
|
|
73
83
|
```
|
|
74
84
|
|
|
75
85
|
Useful `create` flags: `--region`, `--display-name`, `--inactivity-timeout 3m`,
|
|
@@ -80,6 +90,29 @@ The same `logs` commands work for Android. Snapshots are limited to the active
|
|
|
80
90
|
lease and accept 1 to 1,000 lines. In follow mode, `--json` emits one JSON object
|
|
81
91
|
per line.
|
|
82
92
|
|
|
93
|
+
Both platforms also expose `gesture`, `press-button`, `rotate`, `reload`,
|
|
94
|
+
`scroll`, `toggle-software-keyboard`, `simulate-memory-warning`,
|
|
95
|
+
`rotate-digital-crown`, and `set-render-debug`. Run `runcloud ios --help` or
|
|
96
|
+
`runcloud android --help` for the full surface and per-command input choices.
|
|
97
|
+
Current mobile sessions return an unsupported-action error for Digital Crown
|
|
98
|
+
input; render-debug controls are iOS-only. Android Emulator sessions also
|
|
99
|
+
report `capsLock`, `numLock`, and `scrollLock` as unsupported keys.
|
|
100
|
+
Coordinates are inclusive normalized display coordinates: `(0, 0)` is the
|
|
101
|
+
top-left and `(1, 1)` is the bottom-right.
|
|
102
|
+
|
|
103
|
+
The `recording` group is shared by iOS and Android. It provides `start`,
|
|
104
|
+
`list`, `status` (`get` alias), `stop`, and `download`. Pass a stable
|
|
105
|
+
`--idempotency-key` to retry `start` without creating a second recording.
|
|
106
|
+
`download --output <path>` validates the MP4 and reports its byte size and
|
|
107
|
+
SHA-256 digest without printing a signed storage URL.
|
|
108
|
+
|
|
109
|
+
Interaction commands wait for a correlated simulator acknowledgement. They
|
|
110
|
+
accept `--timeout <milliseconds>` (15 seconds by default), `--request-id <id>`,
|
|
111
|
+
and `--json`. Successful JSON is the API's typed completion envelope. Failures
|
|
112
|
+
write a stable `{ ok: false, error: { code, message, retryable } }` envelope to
|
|
113
|
+
stderr and exit non-zero. Ctrl-C cancels only the in-flight command, leaves the
|
|
114
|
+
session active, and exits with code 130.
|
|
115
|
+
|
|
83
116
|
### Tunnel a local dev server into a simulator
|
|
84
117
|
|
|
85
118
|
```bash
|
package/dist/api.js
CHANGED
|
@@ -3,28 +3,84 @@ export class ApiError extends Error {
|
|
|
3
3
|
status;
|
|
4
4
|
detail;
|
|
5
5
|
path;
|
|
6
|
-
|
|
6
|
+
code;
|
|
7
|
+
retryable;
|
|
8
|
+
details;
|
|
9
|
+
requestId;
|
|
10
|
+
sessionId;
|
|
11
|
+
platform;
|
|
12
|
+
action;
|
|
13
|
+
interactionStatus;
|
|
14
|
+
acceptedAt;
|
|
15
|
+
completedAt;
|
|
16
|
+
durationMs;
|
|
17
|
+
constructor(status, detail, path, context = {}) {
|
|
7
18
|
super(`API ${status}: ${detail}`);
|
|
8
19
|
this.status = status;
|
|
9
20
|
this.detail = detail;
|
|
10
21
|
this.path = path;
|
|
22
|
+
this.name = 'ApiError';
|
|
23
|
+
Object.assign(this, context);
|
|
11
24
|
}
|
|
12
25
|
}
|
|
13
|
-
|
|
26
|
+
export class ApiTransportError extends Error {
|
|
27
|
+
path;
|
|
28
|
+
originalError;
|
|
29
|
+
constructor(path, originalError) {
|
|
30
|
+
super('Could not reach the run.cloud API');
|
|
31
|
+
this.path = path;
|
|
32
|
+
this.originalError = originalError;
|
|
33
|
+
this.name = 'ApiTransportError';
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function parsedError(body) {
|
|
14
37
|
try {
|
|
15
38
|
const parsed = JSON.parse(body);
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
39
|
+
const nested = parsed.error && typeof parsed.error === 'object'
|
|
40
|
+
? parsed.error
|
|
41
|
+
: undefined;
|
|
42
|
+
const rawDetail = nested?.message ?? parsed.detail ?? parsed.error ?? parsed.message;
|
|
43
|
+
const detail = typeof rawDetail === 'string'
|
|
44
|
+
? rawDetail
|
|
45
|
+
: rawDetail === undefined ? '' : JSON.stringify(rawDetail);
|
|
46
|
+
const retryable = nested?.retryable ?? parsed.retryable;
|
|
47
|
+
const interactionStatus = parsed.status === 'failed'
|
|
48
|
+
|| parsed.status === 'cancelled'
|
|
49
|
+
|| parsed.status === 'timed_out'
|
|
50
|
+
? parsed.status
|
|
51
|
+
: undefined;
|
|
52
|
+
const platform = parsed.platform === 'ios' || parsed.platform === 'android'
|
|
53
|
+
? parsed.platform
|
|
54
|
+
: undefined;
|
|
55
|
+
return {
|
|
56
|
+
detail,
|
|
57
|
+
...(typeof (nested?.code ?? parsed.code) === 'string'
|
|
58
|
+
? { code: (nested?.code ?? parsed.code) }
|
|
59
|
+
: {}),
|
|
60
|
+
...(typeof retryable === 'boolean' ? { retryable } : {}),
|
|
61
|
+
...((nested?.details ?? parsed.details) === undefined
|
|
62
|
+
? {}
|
|
63
|
+
: { details: nested?.details ?? parsed.details }),
|
|
64
|
+
...(typeof parsed.requestId === 'string' ? { requestId: parsed.requestId } : {}),
|
|
65
|
+
...(typeof parsed.sessionId === 'string' ? { sessionId: parsed.sessionId } : {}),
|
|
66
|
+
...(platform ? { platform } : {}),
|
|
67
|
+
...(typeof parsed.action === 'string' ? { action: parsed.action } : {}),
|
|
68
|
+
...(interactionStatus ? { interactionStatus } : {}),
|
|
69
|
+
...(typeof parsed.acceptedAt === 'string' ? { acceptedAt: parsed.acceptedAt } : {}),
|
|
70
|
+
...(typeof parsed.completedAt === 'string' ? { completedAt: parsed.completedAt } : {}),
|
|
71
|
+
...(typeof parsed.durationMs === 'number' && Number.isFinite(parsed.durationMs)
|
|
72
|
+
? { durationMs: parsed.durationMs }
|
|
73
|
+
: {}),
|
|
74
|
+
};
|
|
21
75
|
}
|
|
22
76
|
catch {
|
|
23
77
|
}
|
|
24
78
|
if (/<(?:!doctype|html)\b/i.test(body)) {
|
|
25
|
-
return
|
|
79
|
+
return {
|
|
80
|
+
detail: 'the API edge returned an HTML error page; retry the request or use the streaming/direct-transfer command',
|
|
81
|
+
};
|
|
26
82
|
}
|
|
27
|
-
return body;
|
|
83
|
+
return { detail: body };
|
|
28
84
|
}
|
|
29
85
|
export class ApiClient {
|
|
30
86
|
baseUrl;
|
|
@@ -33,18 +89,27 @@ export class ApiClient {
|
|
|
33
89
|
this.baseUrl = baseUrl;
|
|
34
90
|
this.token = token;
|
|
35
91
|
}
|
|
36
|
-
async request(method, path, body) {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
92
|
+
async request(method, path, body, options = {}) {
|
|
93
|
+
let res;
|
|
94
|
+
try {
|
|
95
|
+
res = await fetch(`${this.baseUrl}${path}`, {
|
|
96
|
+
method,
|
|
97
|
+
headers: {
|
|
98
|
+
Authorization: `Bearer ${this.token}`,
|
|
99
|
+
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
|
|
100
|
+
},
|
|
101
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
102
|
+
signal: options.signal,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
if (options.signal?.aborted)
|
|
107
|
+
throw options.signal.reason ?? error;
|
|
108
|
+
throw new ApiTransportError(path, error);
|
|
109
|
+
}
|
|
45
110
|
if (!res.ok) {
|
|
46
|
-
const detail =
|
|
47
|
-
throw new ApiError(res.status, detail, path);
|
|
111
|
+
const { detail, ...context } = parsedError(await res.text());
|
|
112
|
+
throw new ApiError(res.status, detail, path, context);
|
|
48
113
|
}
|
|
49
114
|
const contentType = res.headers.get('content-type') ?? '';
|
|
50
115
|
return contentType.includes('application/json') ? res.json() : res;
|
|
@@ -56,8 +121,8 @@ export class ApiClient {
|
|
|
56
121
|
const response = await this.request('GET', path);
|
|
57
122
|
return response.text();
|
|
58
123
|
}
|
|
59
|
-
post(path, body) {
|
|
60
|
-
return this.request('POST', path, body);
|
|
124
|
+
post(path, body, options = {}) {
|
|
125
|
+
return this.request('POST', path, body, options);
|
|
61
126
|
}
|
|
62
127
|
async postWithHeaders(path, body, headers) {
|
|
63
128
|
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
@@ -70,8 +135,8 @@ export class ApiClient {
|
|
|
70
135
|
body: JSON.stringify(body),
|
|
71
136
|
});
|
|
72
137
|
if (!res.ok) {
|
|
73
|
-
const detail =
|
|
74
|
-
throw new ApiError(res.status, detail, path);
|
|
138
|
+
const { detail, ...context } = parsedError(await res.text());
|
|
139
|
+
throw new ApiError(res.status, detail, path, context);
|
|
75
140
|
}
|
|
76
141
|
const contentType = res.headers.get('content-type') ?? '';
|
|
77
142
|
return contentType.includes('application/json') ? res.json() : res;
|
|
@@ -95,8 +160,8 @@ export class ApiClient {
|
|
|
95
160
|
body: body,
|
|
96
161
|
});
|
|
97
162
|
if (!res.ok) {
|
|
98
|
-
const detail =
|
|
99
|
-
throw new ApiError(res.status, detail, path);
|
|
163
|
+
const { detail, ...context } = parsedError(await res.text());
|
|
164
|
+
throw new ApiError(res.status, detail, path, context);
|
|
100
165
|
}
|
|
101
166
|
const contentType_ = res.headers.get('content-type') ?? '';
|
|
102
167
|
return contentType_.includes('application/json') ? res.json() : res;
|
|
@@ -110,20 +175,29 @@ export class ApiClient {
|
|
|
110
175
|
body: form,
|
|
111
176
|
});
|
|
112
177
|
if (!res.ok) {
|
|
113
|
-
const detail =
|
|
114
|
-
throw new ApiError(res.status, detail, path);
|
|
178
|
+
const { detail, ...context } = parsedError(await res.text());
|
|
179
|
+
throw new ApiError(res.status, detail, path, context);
|
|
115
180
|
}
|
|
116
181
|
const contentType = res.headers.get('content-type') ?? '';
|
|
117
182
|
return contentType.includes('application/json') ? res.json() : res;
|
|
118
183
|
}
|
|
119
|
-
async getBinary(path) {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
184
|
+
async getBinary(path, options = {}) {
|
|
185
|
+
let res;
|
|
186
|
+
try {
|
|
187
|
+
res = await fetch(`${this.baseUrl}${path}`, {
|
|
188
|
+
method: 'GET',
|
|
189
|
+
headers: { Authorization: `Bearer ${this.token}`, ...options.headers },
|
|
190
|
+
signal: options.signal,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
if (options.signal?.aborted)
|
|
195
|
+
throw options.signal.reason ?? error;
|
|
196
|
+
throw new ApiTransportError(path, error);
|
|
197
|
+
}
|
|
124
198
|
if (!res.ok) {
|
|
125
|
-
const detail = await res.text().catch(() => '');
|
|
126
|
-
throw new ApiError(res.status, detail || res.statusText, path);
|
|
199
|
+
const { detail, ...context } = parsedError(await res.text().catch(() => ''));
|
|
200
|
+
throw new ApiError(res.status, detail || res.statusText, path, context);
|
|
127
201
|
}
|
|
128
202
|
return Buffer.from(await res.arrayBuffer());
|
|
129
203
|
}
|
|
@@ -133,8 +207,8 @@ export class ApiClient {
|
|
|
133
207
|
headers: { Authorization: `Bearer ${this.token}`, Accept: 'text/event-stream' },
|
|
134
208
|
});
|
|
135
209
|
if (!res.ok) {
|
|
136
|
-
const detail =
|
|
137
|
-
throw new ApiError(res.status, detail || res.statusText, path);
|
|
210
|
+
const { detail, ...context } = parsedError(await res.text().catch(() => ''));
|
|
211
|
+
throw new ApiError(res.status, detail || res.statusText, path, context);
|
|
138
212
|
}
|
|
139
213
|
const decoder = new TextDecoder();
|
|
140
214
|
for await (const chunk of res.body) {
|