@crosshands/cli 0.1.4
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 +15 -0
- package/dist/bin.d.ts +3 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +51 -0
- package/dist/bin.js.map +1 -0
- package/dist/broker-host.d.ts +23 -0
- package/dist/broker-host.d.ts.map +1 -0
- package/dist/broker-host.js +87 -0
- package/dist/broker-host.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +511 -0
- package/dist/index.js.map +1 -0
- package/dist/local-client.d.ts +17 -0
- package/dist/local-client.d.ts.map +1 -0
- package/dist/local-client.js +131 -0
- package/dist/local-client.js.map +1 -0
- package/package.json +47 -0
- package/src/bin.ts +56 -0
- package/src/broker-host.ts +132 -0
- package/src/index.ts +558 -0
- package/src/local-client.ts +171 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { constants } from 'node:fs'
|
|
3
|
+
import { chmod, lstat, open } from 'node:fs/promises'
|
|
4
|
+
import { dirname, resolve } from 'node:path'
|
|
5
|
+
|
|
6
|
+
import type { ComputerOperationName } from '@crosshands/contract'
|
|
7
|
+
|
|
8
|
+
export type CliBrokerClient = {
|
|
9
|
+
request(operation: ComputerOperationName, input: unknown): Promise<unknown>
|
|
10
|
+
close(): Promise<void>
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type CliIo = {
|
|
14
|
+
stdin(): Promise<string>
|
|
15
|
+
stdout(value: string): void
|
|
16
|
+
stderr(value: string): void
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type Flags = Map<string, string | true>
|
|
20
|
+
|
|
21
|
+
const BOOLEAN_FLAGS = new Set([
|
|
22
|
+
'json',
|
|
23
|
+
'no-screenshot',
|
|
24
|
+
'restore-window',
|
|
25
|
+
'text-stdin',
|
|
26
|
+
'value-stdin'
|
|
27
|
+
])
|
|
28
|
+
|
|
29
|
+
const COMMANDS: Record<string, ComputerOperationName | 'doctor'> = {
|
|
30
|
+
capabilities: 'capabilities',
|
|
31
|
+
permissions: 'permissions',
|
|
32
|
+
'list-apps': 'listApps',
|
|
33
|
+
'list-windows': 'listWindows',
|
|
34
|
+
'get-app-state': 'getAppState',
|
|
35
|
+
click: 'click',
|
|
36
|
+
'perform-secondary-action': 'performSecondaryAction',
|
|
37
|
+
scroll: 'scroll',
|
|
38
|
+
drag: 'drag',
|
|
39
|
+
'type-text': 'typeText',
|
|
40
|
+
'press-key': 'pressKey',
|
|
41
|
+
hotkey: 'hotkey',
|
|
42
|
+
'paste-text': 'pasteText',
|
|
43
|
+
'set-value': 'setValue',
|
|
44
|
+
doctor: 'doctor'
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const ALLOWED: Record<string, readonly string[]> = {
|
|
48
|
+
capabilities: ['json'],
|
|
49
|
+
permissions: ['json', 'id'],
|
|
50
|
+
'list-apps': ['json'],
|
|
51
|
+
'list-windows': ['json', 'app'],
|
|
52
|
+
'get-app-state': [
|
|
53
|
+
'json',
|
|
54
|
+
'app',
|
|
55
|
+
'window-id',
|
|
56
|
+
'window-index',
|
|
57
|
+
'no-screenshot',
|
|
58
|
+
'restore-window',
|
|
59
|
+
'screenshot-output'
|
|
60
|
+
],
|
|
61
|
+
click: [
|
|
62
|
+
'json',
|
|
63
|
+
'app',
|
|
64
|
+
'context',
|
|
65
|
+
'element-index',
|
|
66
|
+
'x',
|
|
67
|
+
'y',
|
|
68
|
+
'click-count',
|
|
69
|
+
'mouse-button',
|
|
70
|
+
'modifiers',
|
|
71
|
+
'no-screenshot',
|
|
72
|
+
'restore-window',
|
|
73
|
+
'screenshot-output'
|
|
74
|
+
],
|
|
75
|
+
'perform-secondary-action': [
|
|
76
|
+
'json',
|
|
77
|
+
'app',
|
|
78
|
+
'context',
|
|
79
|
+
'element-index',
|
|
80
|
+
'action',
|
|
81
|
+
'no-screenshot',
|
|
82
|
+
'restore-window',
|
|
83
|
+
'screenshot-output'
|
|
84
|
+
],
|
|
85
|
+
scroll: [
|
|
86
|
+
'json',
|
|
87
|
+
'app',
|
|
88
|
+
'context',
|
|
89
|
+
'element-index',
|
|
90
|
+
'x',
|
|
91
|
+
'y',
|
|
92
|
+
'direction',
|
|
93
|
+
'pages',
|
|
94
|
+
'no-screenshot',
|
|
95
|
+
'restore-window',
|
|
96
|
+
'screenshot-output'
|
|
97
|
+
],
|
|
98
|
+
drag: [
|
|
99
|
+
'json',
|
|
100
|
+
'app',
|
|
101
|
+
'context',
|
|
102
|
+
'from-element-index',
|
|
103
|
+
'to-element-index',
|
|
104
|
+
'from-x',
|
|
105
|
+
'from-y',
|
|
106
|
+
'to-x',
|
|
107
|
+
'to-y',
|
|
108
|
+
'duration-ms',
|
|
109
|
+
'no-screenshot',
|
|
110
|
+
'restore-window',
|
|
111
|
+
'screenshot-output'
|
|
112
|
+
],
|
|
113
|
+
'type-text': [
|
|
114
|
+
'json',
|
|
115
|
+
'app',
|
|
116
|
+
'context',
|
|
117
|
+
'text',
|
|
118
|
+
'text-stdin',
|
|
119
|
+
'no-screenshot',
|
|
120
|
+
'restore-window',
|
|
121
|
+
'screenshot-output'
|
|
122
|
+
],
|
|
123
|
+
'press-key': [
|
|
124
|
+
'json',
|
|
125
|
+
'app',
|
|
126
|
+
'context',
|
|
127
|
+
'key',
|
|
128
|
+
'no-screenshot',
|
|
129
|
+
'restore-window',
|
|
130
|
+
'screenshot-output'
|
|
131
|
+
],
|
|
132
|
+
hotkey: ['json', 'app', 'context', 'key', 'no-screenshot', 'restore-window', 'screenshot-output'],
|
|
133
|
+
'paste-text': [
|
|
134
|
+
'json',
|
|
135
|
+
'app',
|
|
136
|
+
'context',
|
|
137
|
+
'text',
|
|
138
|
+
'no-screenshot',
|
|
139
|
+
'restore-window',
|
|
140
|
+
'screenshot-output'
|
|
141
|
+
],
|
|
142
|
+
'set-value': [
|
|
143
|
+
'json',
|
|
144
|
+
'app',
|
|
145
|
+
'context',
|
|
146
|
+
'element-index',
|
|
147
|
+
'value',
|
|
148
|
+
'value-stdin',
|
|
149
|
+
'no-screenshot',
|
|
150
|
+
'restore-window',
|
|
151
|
+
'screenshot-output'
|
|
152
|
+
],
|
|
153
|
+
doctor: ['json']
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const EXIT_CODES: Record<string, number> = {
|
|
157
|
+
invalid_argument: 2,
|
|
158
|
+
provider_unavailable: 3,
|
|
159
|
+
permission_denied: 4,
|
|
160
|
+
stale_target: 5,
|
|
161
|
+
interaction_context_invalid: 5,
|
|
162
|
+
interaction_context_expired: 5,
|
|
163
|
+
app_blocked: 6,
|
|
164
|
+
unsupported_capability: 7,
|
|
165
|
+
action_not_supported: 7,
|
|
166
|
+
timeout: 8,
|
|
167
|
+
version_incompatible: 9,
|
|
168
|
+
session_unavailable: 10
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
class CliError extends Error {
|
|
172
|
+
constructor(
|
|
173
|
+
readonly code: string,
|
|
174
|
+
message: string,
|
|
175
|
+
readonly remediation = 'correct_request'
|
|
176
|
+
) {
|
|
177
|
+
super(message)
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function parseFlags(args: string[], command: string): Flags {
|
|
182
|
+
const flags: Flags = new Map()
|
|
183
|
+
const allowed = new Set(ALLOWED[command] ?? [])
|
|
184
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
185
|
+
const raw = args[index]
|
|
186
|
+
if (raw === undefined || !raw.startsWith('--'))
|
|
187
|
+
throw new CliError('invalid_argument', `Unexpected argument: ${raw ?? ''}`)
|
|
188
|
+
const name = raw.slice(2)
|
|
189
|
+
if (name === 'worktree' || name === 'session') {
|
|
190
|
+
throw new CliError(
|
|
191
|
+
'invalid_argument',
|
|
192
|
+
`--${name} was an Orca routing flag and is not used by standalone CrossHands; use --context from get-app-state instead`,
|
|
193
|
+
'remove_orca_routing_flag'
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
if (!allowed.has(name)) throw new CliError('invalid_argument', `Unknown flag --${name}`)
|
|
197
|
+
if (flags.has(name)) throw new CliError('invalid_argument', `Duplicate flag --${name}`)
|
|
198
|
+
if (BOOLEAN_FLAGS.has(name)) {
|
|
199
|
+
flags.set(name, true)
|
|
200
|
+
continue
|
|
201
|
+
}
|
|
202
|
+
const value = args[index + 1]
|
|
203
|
+
if (value === undefined || value.startsWith('--'))
|
|
204
|
+
throw new CliError('invalid_argument', `Missing value for --${name}`)
|
|
205
|
+
flags.set(name, value)
|
|
206
|
+
index += 1
|
|
207
|
+
}
|
|
208
|
+
return flags
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function stringFlag(flags: Flags, name: string, required = false): string | undefined {
|
|
212
|
+
const value = flags.get(name)
|
|
213
|
+
if (typeof value === 'string') return value
|
|
214
|
+
if (required) throw new CliError('invalid_argument', `Missing required --${name}`)
|
|
215
|
+
return undefined
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function numberFlag(
|
|
219
|
+
flags: Flags,
|
|
220
|
+
name: string,
|
|
221
|
+
options: { integer?: boolean; min?: number } = {}
|
|
222
|
+
): number | undefined {
|
|
223
|
+
const raw = stringFlag(flags, name)
|
|
224
|
+
if (raw === undefined) return undefined
|
|
225
|
+
const value = Number(raw)
|
|
226
|
+
if (
|
|
227
|
+
!Number.isFinite(value) ||
|
|
228
|
+
(options.integer === true && !Number.isInteger(value)) ||
|
|
229
|
+
(options.min !== undefined && value < options.min)
|
|
230
|
+
) {
|
|
231
|
+
throw new CliError('invalid_argument', `Invalid --${name}`)
|
|
232
|
+
}
|
|
233
|
+
return value
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function observationFlags(flags: Flags): Record<string, unknown> {
|
|
237
|
+
return {
|
|
238
|
+
...(flags.has('no-screenshot') ? { captureScreenshot: false } : {}),
|
|
239
|
+
...(flags.has('restore-window') ? { restoreWindow: true } : {})
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function windowSelector(flags: Flags): unknown {
|
|
244
|
+
const id = stringFlag(flags, 'window-id')
|
|
245
|
+
const index = numberFlag(flags, 'window-index', { integer: true, min: 0 })
|
|
246
|
+
if (id !== undefined && index !== undefined)
|
|
247
|
+
throw new CliError('invalid_argument', 'Choose either --window-id or --window-index')
|
|
248
|
+
return id === undefined ? (index === undefined ? undefined : { index }) : { id }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function contextToken(flags: Flags): string {
|
|
252
|
+
return stringFlag(flags, 'context', true)!
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function elementTarget(flags: Flags, name = 'element-index'): unknown {
|
|
256
|
+
const index = numberFlag(flags, name, { integer: true, min: 0 })
|
|
257
|
+
if (index === undefined) throw new CliError('invalid_argument', `Missing required --${name}`)
|
|
258
|
+
return { kind: 'element', elementIndex: index }
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function chordTokens(raw: string): string[] {
|
|
262
|
+
return raw.split('+')
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function parseClickModifiers(flags: Flags): string[] | undefined {
|
|
266
|
+
const raw = stringFlag(flags, 'modifiers')
|
|
267
|
+
if (raw === undefined) return undefined
|
|
268
|
+
const modifiers = chordTokens(raw).filter((token) => token.length > 0)
|
|
269
|
+
if (modifiers.length === 0 || modifiers.length > 4)
|
|
270
|
+
throw new CliError('invalid_argument', 'Invalid --modifiers')
|
|
271
|
+
return modifiers
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function pointOrElement(flags: Flags): unknown {
|
|
275
|
+
const element = numberFlag(flags, 'element-index', { integer: true, min: 0 })
|
|
276
|
+
const x = numberFlag(flags, 'x')
|
|
277
|
+
const y = numberFlag(flags, 'y')
|
|
278
|
+
const hasCoordinates = x !== undefined || y !== undefined
|
|
279
|
+
if (element !== undefined && hasCoordinates)
|
|
280
|
+
throw new CliError('invalid_argument', 'Choose an element index or coordinates, not both')
|
|
281
|
+
if (element !== undefined) return { kind: 'element', elementIndex: element }
|
|
282
|
+
if (x === undefined || y === undefined)
|
|
283
|
+
throw new CliError('invalid_argument', 'Coordinates require both --x and --y')
|
|
284
|
+
return { kind: 'coordinate', x, y }
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function protectedText(flags: Flags, name: 'text' | 'value', io: CliIo): Promise<string> {
|
|
288
|
+
const literal = stringFlag(flags, name)
|
|
289
|
+
const stdin = flags.has(`${name}-stdin`)
|
|
290
|
+
if ((literal === undefined) === !stdin)
|
|
291
|
+
throw new CliError('invalid_argument', `Choose exactly one of --${name} or --${name}-stdin`)
|
|
292
|
+
return stdin ? await io.stdin() : literal!
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function operationInput(command: string, flags: Flags, io: CliIo): Promise<unknown> {
|
|
296
|
+
const common = () => {
|
|
297
|
+
const app = stringFlag(flags, 'app')
|
|
298
|
+
return {
|
|
299
|
+
contextToken: contextToken(flags),
|
|
300
|
+
...(app === undefined ? {} : { app }),
|
|
301
|
+
...observationFlags(flags)
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
switch (command) {
|
|
305
|
+
case 'capabilities':
|
|
306
|
+
case 'list-apps':
|
|
307
|
+
return {}
|
|
308
|
+
case 'permissions': {
|
|
309
|
+
const id = stringFlag(flags, 'id')
|
|
310
|
+
if (id !== undefined && id !== 'accessibility' && id !== 'screenshots')
|
|
311
|
+
throw new CliError('invalid_argument', '--id must be accessibility or screenshots')
|
|
312
|
+
return id === undefined ? {} : { id }
|
|
313
|
+
}
|
|
314
|
+
case 'list-windows':
|
|
315
|
+
return { app: stringFlag(flags, 'app', true) }
|
|
316
|
+
case 'get-app-state': {
|
|
317
|
+
const window = windowSelector(flags)
|
|
318
|
+
return {
|
|
319
|
+
app: stringFlag(flags, 'app', true),
|
|
320
|
+
...(window === undefined ? {} : { window }),
|
|
321
|
+
...observationFlags(flags)
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
case 'click': {
|
|
325
|
+
const clickCount = numberFlag(flags, 'click-count', { integer: true, min: 1 })
|
|
326
|
+
const button = stringFlag(flags, 'mouse-button')
|
|
327
|
+
if (button !== undefined && !['left', 'right', 'middle'].includes(button))
|
|
328
|
+
throw new CliError('invalid_argument', 'Invalid --mouse-button')
|
|
329
|
+
const modifiers = parseClickModifiers(flags)
|
|
330
|
+
return {
|
|
331
|
+
...common(),
|
|
332
|
+
target: pointOrElement(flags),
|
|
333
|
+
...(clickCount === undefined ? {} : { clickCount }),
|
|
334
|
+
...(button === undefined ? {} : { button }),
|
|
335
|
+
...(modifiers === undefined ? {} : { modifiers })
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
case 'perform-secondary-action':
|
|
339
|
+
return {
|
|
340
|
+
...common(),
|
|
341
|
+
target: elementTarget(flags),
|
|
342
|
+
action: stringFlag(flags, 'action', true)
|
|
343
|
+
}
|
|
344
|
+
case 'scroll': {
|
|
345
|
+
const direction = stringFlag(flags, 'direction', true)!
|
|
346
|
+
if (!['up', 'down', 'left', 'right'].includes(direction))
|
|
347
|
+
throw new CliError('invalid_argument', 'Invalid --direction')
|
|
348
|
+
const pages = numberFlag(flags, 'pages', { integer: true, min: 1 })
|
|
349
|
+
return {
|
|
350
|
+
...common(),
|
|
351
|
+
target: pointOrElement(flags),
|
|
352
|
+
direction,
|
|
353
|
+
...(pages === undefined ? {} : { pages })
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
case 'drag': {
|
|
357
|
+
const fromElement = numberFlag(flags, 'from-element-index', { integer: true, min: 0 })
|
|
358
|
+
const toElement = numberFlag(flags, 'to-element-index', { integer: true, min: 0 })
|
|
359
|
+
const coordinates = ['from-x', 'from-y', 'to-x', 'to-y'].map((name) =>
|
|
360
|
+
numberFlag(flags, name)
|
|
361
|
+
)
|
|
362
|
+
const endpoint = (
|
|
363
|
+
label: string,
|
|
364
|
+
element: number | undefined,
|
|
365
|
+
x: number | undefined,
|
|
366
|
+
y: number | undefined
|
|
367
|
+
): unknown => {
|
|
368
|
+
if (element !== undefined && (x !== undefined || y !== undefined))
|
|
369
|
+
throw new CliError('invalid_argument', `Choose one selector for drag ${label}`)
|
|
370
|
+
if (element !== undefined) return { kind: 'element', elementIndex: element }
|
|
371
|
+
if (x !== undefined && y !== undefined) return { kind: 'coordinate', x, y }
|
|
372
|
+
throw new CliError('invalid_argument', `Drag ${label} requires an element or coordinates`)
|
|
373
|
+
}
|
|
374
|
+
const from = endpoint('start', fromElement, coordinates[0], coordinates[1])
|
|
375
|
+
const to = endpoint('end', toElement, coordinates[2], coordinates[3])
|
|
376
|
+
const durationMs = numberFlag(flags, 'duration-ms', { integer: true, min: 50 })
|
|
377
|
+
return {
|
|
378
|
+
...common(),
|
|
379
|
+
from,
|
|
380
|
+
to,
|
|
381
|
+
...(durationMs === undefined ? {} : { durationMs })
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
case 'type-text':
|
|
385
|
+
case 'paste-text':
|
|
386
|
+
return {
|
|
387
|
+
...common(),
|
|
388
|
+
target: { kind: 'context-window' },
|
|
389
|
+
text: await protectedText(flags, 'text', io)
|
|
390
|
+
}
|
|
391
|
+
case 'press-key':
|
|
392
|
+
return {
|
|
393
|
+
...common(),
|
|
394
|
+
target: { kind: 'context-window' },
|
|
395
|
+
key: stringFlag(flags, 'key', true)
|
|
396
|
+
}
|
|
397
|
+
case 'hotkey': {
|
|
398
|
+
const keys = chordTokens(stringFlag(flags, 'key', true)!)
|
|
399
|
+
if (keys.length < 2 || keys.some((key) => key.length === 0))
|
|
400
|
+
throw new CliError('invalid_argument', 'Hotkeys require a modifier and key')
|
|
401
|
+
return { ...common(), target: { kind: 'context-window' }, keys }
|
|
402
|
+
}
|
|
403
|
+
case 'set-value':
|
|
404
|
+
return {
|
|
405
|
+
...common(),
|
|
406
|
+
target: elementTarget(flags),
|
|
407
|
+
value: await protectedText(flags, 'value', io)
|
|
408
|
+
}
|
|
409
|
+
default:
|
|
410
|
+
throw new CliError('invalid_argument', `Unknown computer command: ${command}`)
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function readiness(capabilities: unknown): string {
|
|
415
|
+
if (capabilities === null || typeof capabilities !== 'object') return 'unavailable'
|
|
416
|
+
const record = capabilities as Record<string, unknown>
|
|
417
|
+
const permissions = record.permissions
|
|
418
|
+
if (permissions !== null && typeof permissions === 'object') {
|
|
419
|
+
const values = Object.values(permissions)
|
|
420
|
+
if (values.includes('denied') || values.includes('unknown')) return 'operator_action_required'
|
|
421
|
+
}
|
|
422
|
+
const operations = record.operations
|
|
423
|
+
if (operations !== null && typeof operations === 'object') {
|
|
424
|
+
const values = Object.values(operations)
|
|
425
|
+
if (values.includes(false)) return 'capability_reduced'
|
|
426
|
+
}
|
|
427
|
+
return 'ready'
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
async function runDoctor(client: CliBrokerClient): Promise<unknown> {
|
|
431
|
+
const capabilities = await client.request('capabilities', {})
|
|
432
|
+
const permissions = await client.request('permissions', {})
|
|
433
|
+
const capabilityResult =
|
|
434
|
+
capabilities !== null && typeof capabilities === 'object' && 'result' in capabilities
|
|
435
|
+
? (capabilities as Record<string, unknown>).result
|
|
436
|
+
: capabilities
|
|
437
|
+
return { readiness: readiness(capabilityResult), checks: { capabilities, permissions } }
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function publicBrokerResult(value: unknown): unknown {
|
|
441
|
+
if (value !== null && typeof value === 'object' && 'requestId' in value && 'result' in value)
|
|
442
|
+
return (value as Record<string, unknown>).result
|
|
443
|
+
return value
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function serializedError(cause: unknown): Record<string, unknown> {
|
|
447
|
+
if (cause !== null && typeof cause === 'object') {
|
|
448
|
+
const record = cause as Record<string, unknown>
|
|
449
|
+
if (typeof record.toJSON === 'function')
|
|
450
|
+
return (record.toJSON as () => Record<string, unknown>)()
|
|
451
|
+
return {
|
|
452
|
+
code: typeof record.code === 'string' ? record.code : 'provider_unavailable',
|
|
453
|
+
message:
|
|
454
|
+
cause instanceof Error
|
|
455
|
+
? cause.message
|
|
456
|
+
: typeof record.message === 'string'
|
|
457
|
+
? record.message
|
|
458
|
+
: 'Request failed',
|
|
459
|
+
retry: typeof record.retry === 'boolean' ? record.retry : false,
|
|
460
|
+
remediation: typeof record.remediation === 'string' ? record.remediation : 'run_doctor'
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
return {
|
|
464
|
+
code: 'provider_unavailable',
|
|
465
|
+
message: 'Request failed',
|
|
466
|
+
retry: false,
|
|
467
|
+
remediation: 'run_doctor'
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function findScreenshot(value: unknown): Record<string, unknown> | undefined {
|
|
472
|
+
if (value === null || typeof value !== 'object') return undefined
|
|
473
|
+
const record = value as Record<string, unknown>
|
|
474
|
+
if (record.screenshot !== null && typeof record.screenshot === 'object') {
|
|
475
|
+
const screenshot = record.screenshot as Record<string, unknown>
|
|
476
|
+
if (typeof screenshot.data === 'string') return screenshot
|
|
477
|
+
}
|
|
478
|
+
for (const nested of Object.values(record)) {
|
|
479
|
+
const found = findScreenshot(nested)
|
|
480
|
+
if (found !== undefined) return found
|
|
481
|
+
}
|
|
482
|
+
return undefined
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
async function exportScreenshot(value: unknown, destination: string): Promise<void> {
|
|
486
|
+
const screenshot = findScreenshot(value)
|
|
487
|
+
if (screenshot === undefined)
|
|
488
|
+
throw new CliError('invalid_argument', 'Result has no screenshot data to export')
|
|
489
|
+
const absolute = resolve(destination)
|
|
490
|
+
const parent = dirname(absolute)
|
|
491
|
+
const parentInfo = await lstat(parent).catch(() => undefined)
|
|
492
|
+
if (parentInfo === undefined || !parentInfo.isDirectory() || parentInfo.isSymbolicLink())
|
|
493
|
+
throw new CliError('invalid_argument', 'Screenshot output parent must be a real directory')
|
|
494
|
+
const existing = await lstat(absolute).catch((cause: NodeJS.ErrnoException) => {
|
|
495
|
+
if (cause.code === 'ENOENT') return undefined
|
|
496
|
+
throw cause
|
|
497
|
+
})
|
|
498
|
+
if (existing !== undefined)
|
|
499
|
+
throw new CliError(
|
|
500
|
+
'invalid_argument',
|
|
501
|
+
'Screenshot output already exists; overwrite is forbidden'
|
|
502
|
+
)
|
|
503
|
+
const bytes = Buffer.from(screenshot.data as string, 'base64')
|
|
504
|
+
const handle = await open(
|
|
505
|
+
absolute,
|
|
506
|
+
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
|
|
507
|
+
0o600
|
|
508
|
+
)
|
|
509
|
+
try {
|
|
510
|
+
await handle.writeFile(bytes)
|
|
511
|
+
await handle.sync()
|
|
512
|
+
} finally {
|
|
513
|
+
await handle.close()
|
|
514
|
+
}
|
|
515
|
+
await chmod(absolute, 0o600)
|
|
516
|
+
delete screenshot.data
|
|
517
|
+
Object.assign(screenshot, {
|
|
518
|
+
path: absolute,
|
|
519
|
+
bytes: bytes.byteLength,
|
|
520
|
+
sha256: createHash('sha256').update(bytes).digest('hex'),
|
|
521
|
+
dataOmitted: true
|
|
522
|
+
})
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
export async function runCli(argv: string[], io: CliIo, client: CliBrokerClient): Promise<number> {
|
|
526
|
+
let json = false
|
|
527
|
+
try {
|
|
528
|
+
if (argv[0] !== 'computer')
|
|
529
|
+
throw new CliError('invalid_argument', 'Usage: crosshands computer <command> --json')
|
|
530
|
+
const command = argv[1]
|
|
531
|
+
if (command === undefined || COMMANDS[command] === undefined)
|
|
532
|
+
throw new CliError('invalid_argument', `Unknown computer command: ${command ?? ''}`)
|
|
533
|
+
const flags = parseFlags(argv.slice(2), command)
|
|
534
|
+
json = flags.has('json')
|
|
535
|
+
const operation = COMMANDS[command]!
|
|
536
|
+
const brokerResult =
|
|
537
|
+
operation === 'doctor'
|
|
538
|
+
? await runDoctor(client)
|
|
539
|
+
: publicBrokerResult(
|
|
540
|
+
await client.request(operation, await operationInput(command, flags, io))
|
|
541
|
+
)
|
|
542
|
+
const result = structuredClone(brokerResult)
|
|
543
|
+
const screenshotOutput = stringFlag(flags, 'screenshot-output')
|
|
544
|
+
if (screenshotOutput !== undefined) await exportScreenshot(result, screenshotOutput)
|
|
545
|
+
io.stdout(`${JSON.stringify(result)}\n`)
|
|
546
|
+
return 0
|
|
547
|
+
} catch (cause) {
|
|
548
|
+
const error = serializedError(cause)
|
|
549
|
+
io.stdout(`${JSON.stringify({ error })}\n`)
|
|
550
|
+
if (!json) io.stderr(`${String(error.message)}\n`)
|
|
551
|
+
return EXIT_CODES[String(error.code)] ?? 1
|
|
552
|
+
} finally {
|
|
553
|
+
await client.close().catch(() => undefined)
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
export { createProductionBrokerClient, localClientPaths } from './local-client.js'
|
|
558
|
+
export type { LocalClientPaths, ProductionClientOptions } from './local-client.js'
|