@newly/cli 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.
@@ -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
- constructor(status, detail, path) {
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
- function parsedErrorDetail(body) {
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 detail = parsed.detail ?? parsed.error ?? parsed.message;
17
- if (typeof detail === 'string')
18
- return detail;
19
- if (detail !== undefined)
20
- return JSON.stringify(detail);
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 'the API edge returned an HTML error page; retry the request or use the streaming/direct-transfer command';
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
- const res = await fetch(`${this.baseUrl}${path}`, {
38
- method,
39
- headers: {
40
- Authorization: `Bearer ${this.token}`,
41
- ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
42
- },
43
- body: body !== undefined ? JSON.stringify(body) : undefined,
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 = parsedErrorDetail(await res.text());
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 = parsedErrorDetail(await res.text());
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 = parsedErrorDetail(await res.text());
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 = parsedErrorDetail(await res.text());
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
- const res = await fetch(`${this.baseUrl}${path}`, {
121
- method: 'GET',
122
- headers: { Authorization: `Bearer ${this.token}` },
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 = parsedErrorDetail(await res.text().catch(() => ''));
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) {
@@ -3,9 +3,91 @@ import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFil
3
3
  import { basename, dirname, join, resolve } from 'node:path';
4
4
  import { homedir } from 'node:os';
5
5
  import { fileURLToPath, pathToFileURL } from 'node:url';
6
- import { createHash } from 'node:crypto';
7
- import { ApiClient, friendlyApiError } from '../api.js';
6
+ import { createHash, randomUUID } from 'node:crypto';
7
+ import { ApiClient, ApiError, ApiTransportError, friendlyApiError } from '../api.js';
8
8
  import { requireCredentials } from '../config.js';
9
+ export const SIMULATOR_KEYS = [
10
+ 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm',
11
+ 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z',
12
+ '0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
13
+ 'enter', 'escape', 'backspace', 'tab', 'space', 'minus', 'equal',
14
+ 'bracketLeft', 'bracketRight', 'backslash', 'semicolon', 'quote', 'backquote',
15
+ 'comma', 'period', 'slash', 'capsLock',
16
+ 'f1', 'f2', 'f3', 'f4', 'f5', 'f6', 'f7', 'f8', 'f9', 'f10', 'f11', 'f12',
17
+ 'printScreen', 'scrollLock', 'pause', 'insert', 'home', 'pageUp', 'delete', 'end',
18
+ 'pageDown', 'arrowRight', 'arrowLeft', 'arrowDown', 'arrowUp', 'numLock',
19
+ 'numpadDivide', 'numpadMultiply', 'numpadSubtract', 'numpadAdd', 'numpadEnter',
20
+ 'numpad0', 'numpad1', 'numpad2', 'numpad3', 'numpad4', 'numpad5', 'numpad6',
21
+ 'numpad7', 'numpad8', 'numpad9', 'numpadDecimal',
22
+ ];
23
+ export const SIMULATOR_BUTTONS = [
24
+ 'home', 'back', 'appSwitcher', 'recents', 'power', 'volumeUp', 'volumeDown',
25
+ 'menu', 'sideButton', 'actionButton', 'digitalCrown',
26
+ ];
27
+ export const SIMULATOR_RENDER_DEBUG_OPTIONS = [
28
+ 'colorBlendedLayers',
29
+ 'colorCopiedImages',
30
+ 'colorMisalignedImages',
31
+ 'colorOffscreenRendered',
32
+ 'slowAnimations',
33
+ ];
34
+ export const SIMULATOR_INTERACTION_ERROR_CODES = [
35
+ 'invalid_interaction',
36
+ 'active_session_not_found',
37
+ 'simulator_capacity_unavailable',
38
+ 'unsupported_action',
39
+ 'duplicate_request',
40
+ 'interaction_cancelled',
41
+ 'interaction_timeout',
42
+ 'interaction_transport_error',
43
+ 'interaction_invalid_response',
44
+ 'interaction_failed',
45
+ ];
46
+ export const SIMULATOR_SCREENSHOT_ERROR_CODES = [
47
+ 'invalid_screenshot_request',
48
+ 'active_session_not_found',
49
+ 'simulator_capacity_unavailable',
50
+ 'screenshot_cancelled',
51
+ 'screenshot_timeout',
52
+ 'screenshot_transport_error',
53
+ 'screenshot_invalid_response',
54
+ 'screenshot_failed',
55
+ ];
56
+ const simulatorInteractionErrorCodeSet = new Set(SIMULATOR_INTERACTION_ERROR_CODES);
57
+ const simulatorScreenshotErrorCodeSet = new Set(SIMULATOR_SCREENSHOT_ERROR_CODES);
58
+ function normalizedInteractionErrorCode(value) {
59
+ if (typeof value === 'string' && simulatorInteractionErrorCodeSet.has(value)) {
60
+ return value;
61
+ }
62
+ switch (value) {
63
+ case 'timeout':
64
+ case 'timed_out':
65
+ case 'interaction_transport_timeout':
66
+ return 'interaction_timeout';
67
+ case 'cancelled':
68
+ return 'interaction_cancelled';
69
+ case 'invalid_request':
70
+ return 'invalid_interaction';
71
+ case 'simulator_session_ended':
72
+ return 'active_session_not_found';
73
+ case 'unsupported_on_platform':
74
+ return 'unsupported_action';
75
+ case 'invalid_host_response':
76
+ case 'invalid_interaction_result':
77
+ case 'interaction_platform_mismatch':
78
+ return 'interaction_invalid_response';
79
+ default:
80
+ return 'interaction_failed';
81
+ }
82
+ }
83
+ function normalizedScreenshotErrorCode(value) {
84
+ return typeof value === 'string' && simulatorScreenshotErrorCodeSet.has(value)
85
+ ? value
86
+ : 'screenshot_failed';
87
+ }
88
+ const DEFAULT_INTERACTION_TIMEOUT_MS = 15_000;
89
+ const INTERACTION_RESPONSE_MARGIN_MS = 250;
90
+ const REQUEST_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
9
91
  export const RUN_CLOUD_SKILL_NAMES = [
10
92
  'run-cloud',
11
93
  'run-cloud-ios-simulator',
@@ -109,6 +191,341 @@ function parseTags(labels) {
109
191
  }
110
192
  return out;
111
193
  }
194
+ function integerInRange(raw, name, min, max, fallback) {
195
+ if (raw === undefined && fallback !== undefined)
196
+ return fallback;
197
+ const value = Number(raw);
198
+ if (!Number.isInteger(value) || value < min || value > max) {
199
+ throw new Error(`${name} must be an integer from ${min} to ${max}`);
200
+ }
201
+ return value;
202
+ }
203
+ function finiteInRange(raw, name, min, max) {
204
+ const value = Number(raw);
205
+ if (!Number.isFinite(value) || value < min || value > max) {
206
+ throw new Error(`${name} must be a number from ${min} to ${max}`);
207
+ }
208
+ return value;
209
+ }
210
+ function normalizedPoint(x, y, name) {
211
+ return {
212
+ x: finiteInRange(x, `${name} x`, 0, 1),
213
+ y: finiteInRange(y, `${name} y`, 0, 1),
214
+ };
215
+ }
216
+ function simulatorKey(raw) {
217
+ if (SIMULATOR_KEYS.includes(raw))
218
+ return raw;
219
+ throw new Error(`Unknown simulator key: ${raw}. Run with --help to see supported semantic keys.`);
220
+ }
221
+ function simulatorButton(raw) {
222
+ if (SIMULATOR_BUTTONS.includes(raw))
223
+ return raw;
224
+ throw new Error(`Unknown simulator button: ${raw}. Choose one of: ${SIMULATOR_BUTTONS.join(', ')}.`);
225
+ }
226
+ function simulatorOrientation(raw) {
227
+ if (raw === 'portrait' || raw === 'portrait_upside_down' || raw === 'landscape_left' || raw === 'landscape_right') {
228
+ return raw;
229
+ }
230
+ throw new Error(`Unknown orientation: ${raw}. Choose portrait, portrait_upside_down, landscape_left, or landscape_right.`);
231
+ }
232
+ function renderDebugOption(raw) {
233
+ if (SIMULATOR_RENDER_DEBUG_OPTIONS.includes(raw)) {
234
+ return raw;
235
+ }
236
+ throw new Error(`Unknown render debug option: ${raw}. Choose one of: ${SIMULATOR_RENDER_DEBUG_OPTIONS.join(', ')}.`);
237
+ }
238
+ function booleanArgument(raw, name) {
239
+ if (raw === 'true')
240
+ return true;
241
+ if (raw === 'false')
242
+ return false;
243
+ throw new Error(`${name} must be true or false`);
244
+ }
245
+ function isUsAsciiText(text) {
246
+ for (let index = 0; index < text.length; index += 1) {
247
+ const code = text.charCodeAt(index);
248
+ if (code !== 9 && code !== 10 && (code < 32 || code > 126))
249
+ return false;
250
+ }
251
+ return true;
252
+ }
253
+ function assertInteraction(interaction, timeoutMs) {
254
+ switch (interaction.action) {
255
+ case 'tap':
256
+ return;
257
+ case 'swipe':
258
+ if (interaction.durationMs !== undefined) {
259
+ integerInRange(String(interaction.durationMs), '--duration', 50, 30_000);
260
+ }
261
+ return;
262
+ case 'gesture': {
263
+ if (interaction.steps.length < 2 || interaction.steps.length > 1_000) {
264
+ throw new Error('--steps must contain from 2 to 1000 gesture steps');
265
+ }
266
+ let pointCount;
267
+ let totalDelayMs = 0;
268
+ for (const [index, step] of interaction.steps.entries()) {
269
+ if ((index === 0) !== (step.phase === 'begin')) {
270
+ throw new Error('Gesture steps must start with begin and contain no later begin phase');
271
+ }
272
+ if ((index === interaction.steps.length - 1) !== (step.phase === 'end')) {
273
+ throw new Error('Gesture steps must end with end and contain no earlier end phase');
274
+ }
275
+ if (step.points.length !== 1 && step.points.length !== 2) {
276
+ throw new Error('Each gesture step must contain one or two points');
277
+ }
278
+ if (pointCount === undefined)
279
+ pointCount = step.points.length;
280
+ else if (pointCount !== step.points.length) {
281
+ throw new Error('Every gesture step must use the same number of points');
282
+ }
283
+ for (const point of step.points) {
284
+ finiteInRange(String(point.x), 'gesture x', 0, 1);
285
+ finiteInRange(String(point.y), 'gesture y', 0, 1);
286
+ }
287
+ if (step.delayMs !== undefined) {
288
+ if (index === interaction.steps.length - 1 && step.delayMs !== 0) {
289
+ throw new Error('Final gesture end step delayMs must be 0 because there is no next step');
290
+ }
291
+ totalDelayMs += integerInRange(String(step.delayMs), 'gesture delayMs', 0, 5_000);
292
+ }
293
+ }
294
+ if (totalDelayMs > timeoutMs)
295
+ throw new Error('Gesture delays cannot exceed --timeout');
296
+ return;
297
+ }
298
+ case 'typeText':
299
+ if (interaction.text.length < 1 || interaction.text.length > 10_000) {
300
+ throw new Error('text must contain from 1 to 10000 characters');
301
+ }
302
+ if (!isUsAsciiText(interaction.text)) {
303
+ throw new Error('text must contain only US ASCII characters, tabs, and line feeds');
304
+ }
305
+ return;
306
+ case 'pressKey':
307
+ case 'pressButton':
308
+ if (interaction.durationMs !== undefined) {
309
+ integerInRange(String(interaction.durationMs), '--duration', 20, 30_000);
310
+ }
311
+ return;
312
+ case 'scroll':
313
+ if (interaction.deltaX === 0 && interaction.deltaY === 0) {
314
+ throw new Error('At least one scroll delta must be nonzero');
315
+ }
316
+ return;
317
+ case 'rotateDigitalCrown':
318
+ if (interaction.delta === 0)
319
+ throw new Error('digital crown delta must be nonzero');
320
+ return;
321
+ default:
322
+ return;
323
+ }
324
+ }
325
+ export function parseSimulatorGestureSteps(raw, timeoutMs = DEFAULT_INTERACTION_TIMEOUT_MS) {
326
+ let value;
327
+ try {
328
+ value = JSON.parse(raw);
329
+ }
330
+ catch {
331
+ throw new Error('--steps must be a valid JSON array');
332
+ }
333
+ if (!Array.isArray(value))
334
+ throw new Error('--steps must be a JSON array');
335
+ const steps = value.map((item, index) => {
336
+ if (!item || typeof item !== 'object' || Array.isArray(item)) {
337
+ throw new Error(`Gesture step ${index} must be an object`);
338
+ }
339
+ const record = item;
340
+ if (record.phase !== 'begin' && record.phase !== 'move' && record.phase !== 'end') {
341
+ throw new Error(`Gesture step ${index} phase must be begin, move, or end`);
342
+ }
343
+ if (!Array.isArray(record.points) || (record.points.length !== 1 && record.points.length !== 2)) {
344
+ throw new Error(`Gesture step ${index} must contain one or two points`);
345
+ }
346
+ const points = record.points.map((point, pointIndex) => {
347
+ if (!point || typeof point !== 'object' || Array.isArray(point)) {
348
+ throw new Error(`Gesture step ${index} point ${pointIndex} must contain x and y`);
349
+ }
350
+ const candidate = point;
351
+ return normalizedPoint(String(candidate.x), String(candidate.y), `gesture step ${index} point ${pointIndex}`);
352
+ });
353
+ const tuple = points.length === 1
354
+ ? [points[0]]
355
+ : [points[0], points[1]];
356
+ const delayMs = record.delayMs === undefined
357
+ ? undefined
358
+ : integerInRange(String(record.delayMs), `gesture step ${index} delayMs`, 0, 5_000);
359
+ return {
360
+ phase: record.phase,
361
+ points: tuple,
362
+ ...(delayMs === undefined ? {} : { delayMs }),
363
+ };
364
+ });
365
+ assertInteraction({ action: 'gesture', steps }, timeoutMs);
366
+ return steps;
367
+ }
368
+ function interactionTimeout(opts) {
369
+ return integerInRange(opts.timeout, '--timeout', 100, 60_000, DEFAULT_INTERACTION_TIMEOUT_MS);
370
+ }
371
+ function interactionRequestId(opts) {
372
+ const requestId = opts.requestId?.trim() || `cli_${randomUUID()}`;
373
+ if (!REQUEST_ID_PATTERN.test(requestId)) {
374
+ throw new Error('--request-id must match /^[A-Za-z0-9._:-]{1,128}$/');
375
+ }
376
+ return requestId;
377
+ }
378
+ class CliInteractionError extends Error {
379
+ code;
380
+ requestId;
381
+ actionName;
382
+ sessionId;
383
+ platform;
384
+ constructor(message, code, requestId, actionName, sessionId, platform) {
385
+ super(message);
386
+ this.code = code;
387
+ this.requestId = requestId;
388
+ this.actionName = actionName;
389
+ this.sessionId = sessionId;
390
+ this.platform = platform;
391
+ this.name = 'CliInteractionError';
392
+ }
393
+ }
394
+ async function withInteractionCancellation(timeoutMs, requestId, actionName, sessionId, platform, operation, run) {
395
+ const controller = new AbortController();
396
+ const onSigint = () => controller.abort(new CliInteractionError(`Simulator ${operation === 'screenshot' ? 'screenshot' : 'interaction'} cancelled by SIGINT`, operation === 'screenshot' ? 'screenshot_cancelled' : 'interaction_cancelled', requestId, actionName, sessionId, platform));
397
+ process.once('SIGINT', onSigint);
398
+ const timer = setTimeout(() => controller.abort(new CliInteractionError(`Simulator ${actionName} did not complete within ${timeoutMs} ms`, operation === 'screenshot' ? 'screenshot_timeout' : 'interaction_timeout', requestId, actionName, sessionId, platform)), timeoutMs + INTERACTION_RESPONSE_MARGIN_MS);
399
+ try {
400
+ return await run(controller.signal);
401
+ }
402
+ catch (error) {
403
+ if (controller.signal.aborted && controller.signal.reason instanceof CliInteractionError) {
404
+ throw controller.signal.reason;
405
+ }
406
+ throw error;
407
+ }
408
+ finally {
409
+ clearTimeout(timer);
410
+ process.removeListener('SIGINT', onSigint);
411
+ }
412
+ }
413
+ function addInteractionOptions(command) {
414
+ return command
415
+ .option('--timeout <milliseconds>', 'acknowledgement timeout in milliseconds (100-60000)', String(DEFAULT_INTERACTION_TIMEOUT_MS))
416
+ .option('--request-id <id>', 'caller-supplied correlation id')
417
+ .option('--json', 'output a stable JSON result or error envelope', false);
418
+ }
419
+ async function runSimulatorInteraction(platform, id, interaction, opts) {
420
+ const timeoutMs = interactionTimeout(opts);
421
+ const requestId = interactionRequestId(opts);
422
+ assertInteraction(interaction, timeoutMs);
423
+ let result;
424
+ try {
425
+ result = await withInteractionCancellation(timeoutMs, requestId, interaction.action, id, platform, 'interaction', async (signal) => await client().post(`/run-cloud/${platform}/${encodeURIComponent(id)}/interactions`, { ...interaction, requestId, timeoutMs }, { signal }));
426
+ }
427
+ catch (error) {
428
+ if (error instanceof ApiError) {
429
+ Object.assign(error, {
430
+ code: normalizedInteractionErrorCode(error.code),
431
+ requestId: error.requestId ?? requestId,
432
+ sessionId: error.sessionId ?? id,
433
+ platform: error.platform ?? platform,
434
+ action: error.action ?? interaction.action,
435
+ });
436
+ }
437
+ if (error instanceof ApiTransportError) {
438
+ throw new CliInteractionError(`Simulator ${interaction.action} could not reach the run.cloud API`, 'interaction_transport_error', requestId, interaction.action, id, platform);
439
+ }
440
+ throw error;
441
+ }
442
+ print(result, opts);
443
+ }
444
+ async function captureSimulatorScreenshot(platform, id, opts) {
445
+ const timeoutMs = interactionTimeout(opts);
446
+ const requestId = interactionRequestId(opts);
447
+ let bytes;
448
+ try {
449
+ bytes = await withInteractionCancellation(timeoutMs, requestId, 'screenshot', id, platform, 'screenshot', async (signal) => await client().getBinary(`/run-cloud/${platform}/${encodeURIComponent(id)}/screenshot`, { signal, headers: { 'X-Run-Cloud-Request-ID': requestId } }));
450
+ }
451
+ catch (error) {
452
+ if (error instanceof ApiError) {
453
+ Object.assign(error, {
454
+ code: normalizedScreenshotErrorCode(error.code),
455
+ requestId: error.requestId ?? requestId,
456
+ sessionId: error.sessionId ?? id,
457
+ platform: error.platform ?? platform,
458
+ action: error.action ?? 'screenshot',
459
+ });
460
+ }
461
+ if (error instanceof ApiTransportError) {
462
+ throw new CliInteractionError('Simulator screenshot could not reach the run.cloud API', 'screenshot_transport_error', requestId, 'screenshot', id, platform);
463
+ }
464
+ throw error;
465
+ }
466
+ if (!bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) {
467
+ throw new CliInteractionError('Simulator screenshot response was not a valid PNG image', 'screenshot_invalid_response', requestId, 'screenshot', id, platform);
468
+ }
469
+ const output = resolve(opts.output);
470
+ mkdirSync(dirname(output), { recursive: true });
471
+ writeFileSync(output, bytes);
472
+ const result = {
473
+ ok: true,
474
+ requestId,
475
+ sessionId: id,
476
+ platform,
477
+ action: 'screenshot',
478
+ status: 'completed',
479
+ output,
480
+ byteSize: bytes.byteLength,
481
+ sha256: createHash('sha256').update(bytes).digest('hex'),
482
+ };
483
+ if (opts.json)
484
+ console.log(JSON.stringify(result, null, 2));
485
+ else
486
+ console.log(`Saved ${platform} simulator screenshot to ${output} (${bytes.byteLength} bytes)`);
487
+ }
488
+ function simulatorRecordingBase(platform, sessionId) {
489
+ return `/run-cloud/${platform}/${encodeURIComponent(sessionId)}/recordings`;
490
+ }
491
+ function recordingIdempotencyKey(value) {
492
+ if (value === undefined)
493
+ return undefined;
494
+ const key = value.trim();
495
+ const hasControlCharacter = [...key].some((character) => {
496
+ const code = character.charCodeAt(0);
497
+ return code <= 31 || code === 127;
498
+ });
499
+ if (!key || key.length > 200 || hasControlCharacter) {
500
+ throw new Error('--idempotency-key must contain 1-200 printable characters');
501
+ }
502
+ return key;
503
+ }
504
+ async function downloadSimulatorRecording(platform, sessionId, recordingId, opts) {
505
+ const bytes = await client().getBinary(`${simulatorRecordingBase(platform, sessionId)}/${encodeURIComponent(recordingId)}/download`);
506
+ if (bytes.length < 8 || bytes.subarray(4, 8).toString('ascii') !== 'ftyp') {
507
+ throw new Error('Simulator recording response was not a valid MP4 file');
508
+ }
509
+ const output = resolve(opts.output);
510
+ mkdirSync(dirname(output), { recursive: true });
511
+ writeFileSync(output, bytes);
512
+ const result = {
513
+ ok: true,
514
+ sessionId,
515
+ recordingId,
516
+ platform,
517
+ action: 'recording.download',
518
+ status: 'completed',
519
+ output,
520
+ contentType: 'video/mp4',
521
+ byteSize: bytes.byteLength,
522
+ sha256: createHash('sha256').update(bytes).digest('hex'),
523
+ };
524
+ if (opts.json)
525
+ console.log(JSON.stringify(result, null, 2));
526
+ else
527
+ console.log(`Saved ${platform} simulator recording to ${output} (${bytes.byteLength} bytes)`);
528
+ }
112
529
  function fileBlob(path) {
113
530
  const resolved = resolve(path);
114
531
  if (!existsSync(resolved))
@@ -319,7 +736,9 @@ async function createSimulatorSession(platform, opts) {
319
736
  function registerSimulatorCommands(program, platform) {
320
737
  const label = platform === 'android' ? 'Android emulator' : 'iOS simulator';
321
738
  const article = platform === 'android' ? 'an' : 'an';
322
- const simulator = program.command(platform).description(`Create, list, inspect, and delete ${label} sessions`);
739
+ const simulator = program
740
+ .command(platform)
741
+ .description(`Create, inspect, interact with, capture, and release ${label} sessions`);
323
742
  simulator
324
743
  .command('create')
325
744
  .description(`Create a remote ${label} session`)
@@ -372,6 +791,188 @@ function registerSimulatorCommands(program, platform) {
372
791
  .action((url, opts) => action(async () => {
373
792
  print(await client().post(`/run-cloud/${platform}/${encodeURIComponent(opts.id)}/open-url`, { url }), opts);
374
793
  }));
794
+ addInteractionOptions(simulator
795
+ .command('tap')
796
+ .description(`Tap normalized display coordinates in ${article} ${label} session`)
797
+ .argument('<id>', 'session id')
798
+ .argument('<x>', 'normalized x coordinate from 0 (left) to 1 (right)')
799
+ .argument('<y>', 'normalized y coordinate from 0 (top) to 1 (bottom)')).action((id, x, y, opts) => action(() => runSimulatorInteraction(platform, id, { action: 'tap', ...normalizedPoint(x, y, 'tap') }, opts), opts));
800
+ addInteractionOptions(simulator
801
+ .command('swipe')
802
+ .description(`Swipe between normalized display coordinates in ${article} ${label} session`)
803
+ .argument('<id>', 'session id')
804
+ .argument('<from-x>', 'start x from 0 to 1')
805
+ .argument('<from-y>', 'start y from 0 to 1')
806
+ .argument('<to-x>', 'end x from 0 to 1')
807
+ .argument('<to-y>', 'end y from 0 to 1')
808
+ .option('--duration <milliseconds>', 'swipe duration in milliseconds (50-30000)')).action((id, fromX, fromY, toX, toY, opts) => action(() => runSimulatorInteraction(platform, id, {
809
+ action: 'swipe',
810
+ from: normalizedPoint(fromX, fromY, 'swipe start'),
811
+ to: normalizedPoint(toX, toY, 'swipe end'),
812
+ ...(opts.duration === undefined
813
+ ? {}
814
+ : { durationMs: integerInRange(opts.duration, '--duration', 50, 30_000) }),
815
+ }, opts), opts));
816
+ addInteractionOptions(simulator
817
+ .command('gesture')
818
+ .description(`Run an ordered one- or two-finger gesture in ${article} ${label} session`)
819
+ .argument('<id>', 'session id')
820
+ .requiredOption('--steps <json>', 'JSON steps: [{"phase":"begin|move|end","points":[{"x":0.5,"y":0.5}],"delayMs":0}]')).action((id, opts) => action(() => runSimulatorInteraction(platform, id, {
821
+ action: 'gesture',
822
+ steps: parseSimulatorGestureSteps(opts.steps, interactionTimeout(opts)),
823
+ }, opts), opts));
824
+ addInteractionOptions(simulator
825
+ .command('type-text')
826
+ .description(`Type US ASCII text into ${article} ${label} session`)
827
+ .argument('<id>', 'session id')
828
+ .argument('<text>', '1-10000 US ASCII characters; quote text containing spaces')).action((id, text, opts) => action(() => runSimulatorInteraction(platform, id, { action: 'typeText', text }, opts), opts));
829
+ addInteractionOptions(simulator
830
+ .command('press-key')
831
+ .description(`Press a semantic keyboard key in ${article} ${label} session`)
832
+ .argument('<id>', 'session id')
833
+ .argument('<key>', 'semantic key, for example a, enter, arrowUp, or f1')
834
+ .option('--duration <milliseconds>', 'key hold duration in milliseconds (20-30000)')
835
+ .option('--shift', 'hold the left Shift modifier', false)
836
+ .option('--control', 'hold the left Control modifier', false)
837
+ .option('--alt', 'hold the left Alt modifier', false)
838
+ .option('--meta', 'hold the left Meta/Command modifier', false)
839
+ .addHelpText('after', `\nSupported keys:\n ${SIMULATOR_KEYS.join(', ')}\n`)).action((id, rawKey, opts) => action(() => {
840
+ const modifiers = ['shift', 'control', 'alt', 'meta']
841
+ .filter((modifier) => opts[modifier]);
842
+ return runSimulatorInteraction(platform, id, {
843
+ action: 'pressKey',
844
+ key: simulatorKey(rawKey),
845
+ ...(modifiers.length ? { modifiers } : {}),
846
+ ...(opts.duration === undefined
847
+ ? {}
848
+ : { durationMs: integerInRange(opts.duration, '--duration', 20, 30_000) }),
849
+ }, opts);
850
+ }, opts));
851
+ addInteractionOptions(simulator
852
+ .command('press-button')
853
+ .description(`Press a simulator hardware or system button in ${article} ${label} session`)
854
+ .argument('<id>', 'session id')
855
+ .argument('<button>', `one of: ${SIMULATOR_BUTTONS.join(', ')}`)
856
+ .option('--duration <milliseconds>', 'button hold duration in milliseconds (20-30000)')).action((id, rawButton, opts) => action(() => runSimulatorInteraction(platform, id, {
857
+ action: 'pressButton',
858
+ button: simulatorButton(rawButton),
859
+ ...(opts.duration === undefined
860
+ ? {}
861
+ : { durationMs: integerInRange(opts.duration, '--duration', 20, 30_000) }),
862
+ }, opts), opts));
863
+ addInteractionOptions(simulator
864
+ .command('rotate')
865
+ .description(`Set the absolute display orientation for ${article} ${label} session`)
866
+ .argument('<id>', 'session id')
867
+ .argument('<orientation>', 'portrait, portrait_upside_down, landscape_left, or landscape_right')).action((id, rawOrientation, opts) => action(() => runSimulatorInteraction(platform, id, {
868
+ action: 'rotate',
869
+ orientation: simulatorOrientation(rawOrientation),
870
+ }, opts), opts));
871
+ addInteractionOptions(simulator
872
+ .command('reload')
873
+ .description(`Reload the foreground app in ${article} ${label} session`)
874
+ .argument('<id>', 'session id')).action((id, opts) => action(() => runSimulatorInteraction(platform, id, { action: 'reload' }, opts), opts));
875
+ addInteractionOptions(simulator
876
+ .command('scroll')
877
+ .description(`Send normalized scroll deltas to ${article} ${label} session`)
878
+ .argument('<id>', 'session id')
879
+ .argument('<delta-x>', 'horizontal delta from -1 to 1')
880
+ .argument('<delta-y>', 'vertical delta from -1 to 1')
881
+ .option('--x <coordinate>', 'normalized anchor x; requires --y')
882
+ .option('--y <coordinate>', 'normalized anchor y; requires --x')).action((id, deltaX, deltaY, opts) => action(() => {
883
+ if ((opts.x === undefined) !== (opts.y === undefined))
884
+ throw new Error('--x and --y must be provided together');
885
+ const anchor = opts.x !== undefined && opts.y !== undefined
886
+ ? normalizedPoint(opts.x, opts.y, 'scroll anchor')
887
+ : undefined;
888
+ return runSimulatorInteraction(platform, id, {
889
+ action: 'scroll',
890
+ deltaX: finiteInRange(deltaX, 'delta-x', -1, 1),
891
+ deltaY: finiteInRange(deltaY, 'delta-y', -1, 1),
892
+ ...(anchor ?? {}),
893
+ }, opts);
894
+ }, opts));
895
+ addInteractionOptions(simulator
896
+ .command('toggle-software-keyboard')
897
+ .description(`Toggle the software keyboard in ${article} ${label} session`)
898
+ .argument('<id>', 'session id')).action((id, opts) => action(() => runSimulatorInteraction(platform, id, { action: 'toggleSoftwareKeyboard' }, opts), opts));
899
+ addInteractionOptions(simulator
900
+ .command('simulate-memory-warning')
901
+ .description(`Send a memory-warning event to ${article} ${label} session`)
902
+ .argument('<id>', 'session id')).action((id, opts) => action(() => runSimulatorInteraction(platform, id, { action: 'simulateMemoryWarning' }, opts), opts));
903
+ addInteractionOptions(simulator
904
+ .command('rotate-digital-crown')
905
+ .description(`Request Digital Crown input for ${article} ${label} session (unsupported on current mobile sessions)`)
906
+ .argument('<id>', 'session id')
907
+ .argument('<delta>', 'nonzero crown delta from -10000 to 10000')).action((id, delta, opts) => action(() => runSimulatorInteraction(platform, id, {
908
+ action: 'rotateDigitalCrown',
909
+ delta: finiteInRange(delta, 'delta', -10_000, 10_000),
910
+ }, opts), opts));
911
+ addInteractionOptions(simulator
912
+ .command('set-render-debug')
913
+ .description(`Enable or disable a simulator render diagnostic in ${article} ${label} session`)
914
+ .argument('<id>', 'session id')
915
+ .argument('<option>', `one of: ${SIMULATOR_RENDER_DEBUG_OPTIONS.join(', ')}`)
916
+ .argument('<enabled>', 'true or false')).action((id, rawOption, rawEnabled, opts) => action(() => runSimulatorInteraction(platform, id, {
917
+ action: 'setRenderDebug',
918
+ option: renderDebugOption(rawOption),
919
+ enabled: booleanArgument(rawEnabled, 'enabled'),
920
+ }, opts), opts));
921
+ addInteractionOptions(simulator
922
+ .command('screenshot')
923
+ .description(`Capture the current display from ${article} ${label} session`)
924
+ .argument('<id>', 'session id')
925
+ .requiredOption('-o, --output <path>', 'PNG output path')).action((id, opts) => action(() => captureSimulatorScreenshot(platform, id, opts), opts));
926
+ const recording = simulator
927
+ .command('recording')
928
+ .alias('recordings')
929
+ .description(`Record the display of ${article} ${label} session`);
930
+ recording
931
+ .command('start')
932
+ .description(`Start an MP4 screen recording for ${article} active ${label} session`)
933
+ .argument('<id>', 'session id')
934
+ .option('--idempotency-key <key>', 'retry-safe key; reuse it to receive the same recording')
935
+ .option('--json', 'output JSON', false)
936
+ .action((id, opts) => action(async () => {
937
+ const key = recordingIdempotencyKey(opts.idempotencyKey);
938
+ const headers = key ? { 'Idempotency-Key': key } : {};
939
+ print(await client().postWithHeaders(simulatorRecordingBase(platform, id), {}, headers), opts);
940
+ }, opts));
941
+ recording
942
+ .command('list')
943
+ .description(`List retained screen recordings for ${article} ${label} session`)
944
+ .argument('<id>', 'session id')
945
+ .option('--json', 'output JSON', false)
946
+ .action((id, opts) => action(async () => {
947
+ print(await client().get(simulatorRecordingBase(platform, id)), opts);
948
+ }, opts));
949
+ recording
950
+ .command('status')
951
+ .alias('get')
952
+ .description(`Show lifecycle state, failures, events, and retrieval metadata for a recording`)
953
+ .argument('<id>', 'session id')
954
+ .argument('<recording-id>', 'recording id')
955
+ .option('--json', 'output JSON', false)
956
+ .action((id, recordingId, opts) => action(async () => {
957
+ print(await client().get(`${simulatorRecordingBase(platform, id)}/${encodeURIComponent(recordingId)}`), opts);
958
+ }, opts));
959
+ recording
960
+ .command('stop')
961
+ .description(`Stop and finalize a screen recording as an MP4`)
962
+ .argument('<id>', 'session id')
963
+ .argument('<recording-id>', 'recording id')
964
+ .option('--json', 'output JSON', false)
965
+ .action((id, recordingId, opts) => action(async () => {
966
+ print(await client().post(`${simulatorRecordingBase(platform, id)}/${encodeURIComponent(recordingId)}/stop`), opts);
967
+ }, opts));
968
+ recording
969
+ .command('download')
970
+ .description(`Download a ready screen recording through the authenticated API`)
971
+ .argument('<id>', 'session id')
972
+ .argument('<recording-id>', 'recording id')
973
+ .requiredOption('-o, --output <path>', 'MP4 output path')
974
+ .option('--json', 'output file metadata as JSON', false)
975
+ .action((id, recordingId, opts) => action(() => downloadSimulatorRecording(platform, id, recordingId, opts), opts));
375
976
  simulator
376
977
  .command('logs')
377
978
  .description(`Read or follow logs from ${article} active ${label} session`)
@@ -414,16 +1015,89 @@ function registerSimulatorCommands(program, platform) {
414
1015
  }));
415
1016
  return simulator;
416
1017
  }
417
- async function action(fn) {
1018
+ function jsonErrorEnvelope(error) {
1019
+ if (error instanceof ApiError) {
1020
+ return {
1021
+ ok: false,
1022
+ ...(error.requestId ? { requestId: error.requestId } : {}),
1023
+ ...(error.sessionId ? { sessionId: error.sessionId } : {}),
1024
+ ...(error.platform ? { platform: error.platform } : {}),
1025
+ ...(error.action ? { action: error.action } : {}),
1026
+ ...(error.interactionStatus ? { status: error.interactionStatus } : {}),
1027
+ ...(error.acceptedAt ? { acceptedAt: error.acceptedAt } : {}),
1028
+ ...(error.completedAt ? { completedAt: error.completedAt } : {}),
1029
+ ...(error.durationMs === undefined ? {} : { durationMs: error.durationMs }),
1030
+ error: {
1031
+ code: error.code ?? `http_${error.status}`,
1032
+ message: error.detail || friendlyApiError(error),
1033
+ retryable: error.retryable ?? (error.status === 408 || error.status === 429 || error.status >= 500),
1034
+ httpStatus: error.status,
1035
+ ...(error.details === undefined ? {} : { details: error.details }),
1036
+ },
1037
+ };
1038
+ }
1039
+ if (error instanceof CliInteractionError) {
1040
+ const status = error.code.endsWith('_timeout')
1041
+ ? 'timed_out'
1042
+ : error.code.endsWith('_cancelled')
1043
+ ? 'cancelled'
1044
+ : 'failed';
1045
+ return {
1046
+ ok: false,
1047
+ requestId: error.requestId,
1048
+ sessionId: error.sessionId,
1049
+ platform: error.platform,
1050
+ action: error.actionName,
1051
+ status,
1052
+ error: {
1053
+ code: error.code,
1054
+ message: error.message,
1055
+ retryable: !error.code.endsWith('_cancelled'),
1056
+ },
1057
+ };
1058
+ }
1059
+ return {
1060
+ ok: false,
1061
+ error: {
1062
+ code: 'invalid_request',
1063
+ message: error instanceof Error ? error.message : String(error),
1064
+ retryable: false,
1065
+ },
1066
+ };
1067
+ }
1068
+ async function action(fn, opts = {}) {
418
1069
  try {
419
1070
  await fn();
420
1071
  }
421
1072
  catch (err) {
422
- console.error(friendlyApiError(err));
423
- process.exitCode = 1;
1073
+ console.error(opts.json ? JSON.stringify(jsonErrorEnvelope(err), null, 2) : friendlyApiError(err));
1074
+ process.exitCode = err instanceof CliInteractionError && err.code.endsWith('_cancelled') ? 130 : 1;
424
1075
  }
425
1076
  }
1077
+ function configureMachineReadableParseErrors(program) {
1078
+ const output = program.configureOutput();
1079
+ program.configureOutput({
1080
+ ...output,
1081
+ outputError: (message, write) => {
1082
+ const rawArgs = program.rawArgs ?? [];
1083
+ const argumentsSeen = [...process.argv, ...rawArgs, ...program.args];
1084
+ if (!argumentsSeen.includes('--json')) {
1085
+ (output.outputError ?? ((value, destination) => destination(value)))(message, write);
1086
+ return;
1087
+ }
1088
+ write(`${JSON.stringify({
1089
+ ok: false,
1090
+ error: {
1091
+ code: 'invalid_request',
1092
+ message: message.replace(/^error:\s*/i, '').trim(),
1093
+ retryable: false,
1094
+ },
1095
+ })}\n`);
1096
+ },
1097
+ });
1098
+ }
426
1099
  export function registerRunCloud(program) {
1100
+ configureMachineReadableParseErrors(program);
427
1101
  const sample = program
428
1102
  .command('sample')
429
1103
  .alias('samples')
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const CLI_VERSION = '0.1.108';
1
+ export const CLI_VERSION = '0.1.110';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@newly/cli",
3
- "version": "0.1.108",
3
+ "version": "0.1.110",
4
4
  "description": "Develop, provision, and deploy Newly backends from your terminal",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",