@blastlauncher/cli 1.0.5 → 3.0.0-alpha.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/README.md +4 -47
- package/dist/browser.d.ts +11 -0
- package/dist/browser.js +49 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +855 -0
- package/dist/headless.d.ts +26 -0
- package/dist/headless.js +162 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/package.json +23 -24
- package/CHANGELOG.md +0 -31
- package/build.js +0 -15
- package/dist/cli-linux +0 -4
- package/dist/cli-macos +0 -4
- package/dist/cli-win.exe +0 -0
- package/dist/index.cjs +0 -368
- package/index.js +0 -265
package/dist/cli.js
ADDED
|
@@ -0,0 +1,855 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The `blast` debug CLI: bundles and runs one extension command headlessly and
|
|
3
|
+
// prints every published scene — as a readable text tree, or as protocol-shaped
|
|
4
|
+
// NDJSON with `--json`. A `--script` drives the command (action / search /
|
|
5
|
+
// dropdown / back / confirm / dismiss / wait / expect / quit), which is how e2e tests interact
|
|
6
|
+
// with extensions. See "Debugging extensions" in docs/architecture.md.
|
|
7
|
+
import { parseBrowserTabs } from "./browser.js";
|
|
8
|
+
import { rmSync } from "node:fs";
|
|
9
|
+
import { readFile, rm } from "node:fs/promises";
|
|
10
|
+
import { dirname, join } from "node:path";
|
|
11
|
+
import { parseArgs } from "node:util";
|
|
12
|
+
import { bundleCommand, MissingDependencyError, checkExtensionDependencies, DependenciesRequiredError, installExtensionDependencies, createSessionBroker, defaultDataDir, bundleModelProvider, startModelProvider, startCommand, } from "@blastlauncher/engine";
|
|
13
|
+
import { createHeadlessHandlers, parseApplications } from "./headless.js";
|
|
14
|
+
const EXIT_OK = 0;
|
|
15
|
+
const EXIT_FAILURE = 1;
|
|
16
|
+
const EXIT_USAGE = 2;
|
|
17
|
+
const EXIT_CRASH = 3;
|
|
18
|
+
const USAGE = `Run a Raycast extension command headlessly.
|
|
19
|
+
|
|
20
|
+
Usage: blast run <extensionDir> <command> [options]
|
|
21
|
+
blast deps <extensionDir> [--data-dir <dir>]
|
|
22
|
+
blast models <extensionDir>
|
|
23
|
+
blast complete <extensionDir> <modelId> <prompt>
|
|
24
|
+
|
|
25
|
+
Options:
|
|
26
|
+
--argument <name=value>
|
|
27
|
+
Set a manifest argument for this run (repeatable). An
|
|
28
|
+
argument the manifest marks "required" must be given,
|
|
29
|
+
and an unknown name is a usage error.
|
|
30
|
+
--fallback-text <text> Search text for a fallback command.
|
|
31
|
+
--grant <capability> Grant a capability to the command (repeatable). A
|
|
32
|
+
granted "clipboard.copy" records and prints the copied
|
|
33
|
+
text instead of touching an OS clipboard. LocalStorage
|
|
34
|
+
is always granted to the command's own extension; use
|
|
35
|
+
--data-dir to place its data.
|
|
36
|
+
--applications <file.json>
|
|
37
|
+
Answer getApplications with the JSON array of
|
|
38
|
+
applications in <file>; the file must hold objects with
|
|
39
|
+
"name" and "path". Defaults to an empty list.
|
|
40
|
+
--ai-decide <file.json> Supply AI.experimental_decide answers; denied otherwise.
|
|
41
|
+
--ai-answer <text> Answer AI.ask with this text; denied otherwise.
|
|
42
|
+
--browser-tabs <file.json>
|
|
43
|
+
Supply tabs (id, url, active; optional title, favicon, content).
|
|
44
|
+
--oauth-code <code> Answer OAuth authorization with this code; denied otherwise.
|
|
45
|
+
--clipboard <text> Answer Clipboard.read/readText with <text> at offset 0
|
|
46
|
+
and an empty text at older offsets. Defaults to an
|
|
47
|
+
empty text. No real clipboard is touched.
|
|
48
|
+
--selected-text <text> Answer getSelectedText; rejects when omitted or empty.
|
|
49
|
+
--selected-files <path> Answer getSelectedFinderItems (repeatable; default: []).
|
|
50
|
+
--frontmost <bundleId> Answer getFrontmostApplication from --applications,
|
|
51
|
+
or with that identifier and an empty path (default: Headless).
|
|
52
|
+
--data-dir <dir> Root directory for storage and dependencies (default:
|
|
53
|
+
"$XDG_DATA_HOME/blast"); the command's values are kept
|
|
54
|
+
in <dir>/storage/<extension>.json.
|
|
55
|
+
--script <file|-> Drive the command with a script of the commands below;
|
|
56
|
+
"-" reads the script from stdin. Without a script, the
|
|
57
|
+
first scene is printed and the run ends.
|
|
58
|
+
--json Emit protocol-shaped NDJSON instead of text trees.
|
|
59
|
+
--bundle-timeout <ms> Bundling limit (default: 60000; 0 disables).
|
|
60
|
+
--timeout <ms> Abort the run after <ms> milliseconds (exit code 3).
|
|
61
|
+
--help Show this help.
|
|
62
|
+
|
|
63
|
+
Capabilities a headless run always answers: applications.list
|
|
64
|
+
(--applications), clipboard.read (--clipboard), preferences.open and
|
|
65
|
+
selection.text, selection.files, applications.frontmost and command.metadata
|
|
66
|
+
(printed like any other capability). clipboard.paste and clipboard.clear still need
|
|
67
|
+
--grant; finder.show and trash also require --grant.
|
|
68
|
+
|
|
69
|
+
Script commands (one per line, "#" starts a comment):
|
|
70
|
+
action <title> Dispatch an Action or menu item with that title.
|
|
71
|
+
submenu <title>
|
|
72
|
+
Dispatch the onOpen handler of the ActionPanel.Submenu with
|
|
73
|
+
that title.
|
|
74
|
+
back Pop the current navigation view.
|
|
75
|
+
confirm Confirm the current alert.
|
|
76
|
+
dismiss Dismiss the current alert.
|
|
77
|
+
search <text> Dispatch the List or Grid onSearchTextChange handler.
|
|
78
|
+
more Load the next List/Grid page when hasMore is true.
|
|
79
|
+
dropdown <value>
|
|
80
|
+
Dispatch the List.Dropdown or Grid.Dropdown onChange handler
|
|
81
|
+
with the value.
|
|
82
|
+
wait Wait until the next scene is published.
|
|
83
|
+
expect <text> Fail (exit 1) unless the latest scene tree contains the text.
|
|
84
|
+
field <id> <value> Edit a Form field (checkbox values: true/false; dates: ISO;
|
|
85
|
+
file pickers and tag pickers: comma-separated values; omit
|
|
86
|
+
value to clear).
|
|
87
|
+
submit Run the first Action.SubmitForm.
|
|
88
|
+
quit Shut down and finish with exit code 0.
|
|
89
|
+
|
|
90
|
+
Exit codes: 0 success, 1 script or expectation failure, 2 usage error,
|
|
91
|
+
3 extension crash or protocol error.`;
|
|
92
|
+
// Error kinds mapped to exit codes at the top level: usage errors print the
|
|
93
|
+
// usage text, script failures and crashes print an `# error` line on stderr.
|
|
94
|
+
class UsageError extends Error {
|
|
95
|
+
}
|
|
96
|
+
class ScriptError extends Error {
|
|
97
|
+
}
|
|
98
|
+
class RunError extends Error {
|
|
99
|
+
}
|
|
100
|
+
/** The runtime exited cleanly without producing the scene a step waited for. */
|
|
101
|
+
class CleanExit extends Error {
|
|
102
|
+
}
|
|
103
|
+
function handlerRef(value) {
|
|
104
|
+
return typeof value === "object" && value !== null && "$handler" in value
|
|
105
|
+
? value
|
|
106
|
+
: undefined;
|
|
107
|
+
}
|
|
108
|
+
// One line per node, two-space indented: `Type "title" key=value… [onAction]`.
|
|
109
|
+
// Values are JSON-encoded and props keep their insertion order, so the text is
|
|
110
|
+
// stable enough to snapshot.
|
|
111
|
+
function renderTree(root) {
|
|
112
|
+
const lines = [];
|
|
113
|
+
const walk = (node, depth) => {
|
|
114
|
+
lines.push(`${" ".repeat(depth)}${describe(node)}`);
|
|
115
|
+
for (const child of node.children)
|
|
116
|
+
walk(child, depth + 1);
|
|
117
|
+
};
|
|
118
|
+
walk(root, 0);
|
|
119
|
+
return lines;
|
|
120
|
+
}
|
|
121
|
+
function describe(node) {
|
|
122
|
+
const entries = Object.entries(node.props);
|
|
123
|
+
const title = entries.find(([key, value]) => key === "title" && typeof value === "string");
|
|
124
|
+
const parts = [node.type];
|
|
125
|
+
if (title)
|
|
126
|
+
parts.push(JSON.stringify(title[1]));
|
|
127
|
+
for (const [key, value] of entries) {
|
|
128
|
+
if (title && key === "title")
|
|
129
|
+
continue;
|
|
130
|
+
parts.push(handlerRef(value) ? `[${key}]` : `${key}=${JSON.stringify(value)}`);
|
|
131
|
+
}
|
|
132
|
+
return parts.join(" ");
|
|
133
|
+
}
|
|
134
|
+
// Scenes print as they are published, with the logs that arrived since the
|
|
135
|
+
// previous scene flushed first; in --json mode the same messages are emitted in
|
|
136
|
+
// their protocol shape instead.
|
|
137
|
+
const write = (line) => {
|
|
138
|
+
console.log(line);
|
|
139
|
+
};
|
|
140
|
+
function createOutput(json) {
|
|
141
|
+
let logCursor = 0;
|
|
142
|
+
return {
|
|
143
|
+
flushLogs(session) {
|
|
144
|
+
for (; logCursor < session.logs.length; logCursor++) {
|
|
145
|
+
const log = session.logs[logCursor];
|
|
146
|
+
if (log === undefined)
|
|
147
|
+
break;
|
|
148
|
+
write(json ? JSON.stringify(log) : `# log ${log.message}`);
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
scene(scene) {
|
|
152
|
+
if (json)
|
|
153
|
+
return write(JSON.stringify(scene));
|
|
154
|
+
if (scene.root)
|
|
155
|
+
for (const line of renderTree(scene.root))
|
|
156
|
+
write(line);
|
|
157
|
+
},
|
|
158
|
+
capability(request, result) {
|
|
159
|
+
if (request.name.startsWith("oauth.tokens.")) {
|
|
160
|
+
const redacted = { ...request, params: {} };
|
|
161
|
+
return write(json ? JSON.stringify(redacted) : `# capability ${request.name} [redacted]`);
|
|
162
|
+
}
|
|
163
|
+
if (json)
|
|
164
|
+
return write(JSON.stringify(request));
|
|
165
|
+
if (result.ok) {
|
|
166
|
+
return write(`# capability ${request.name} ${JSON.stringify(request.params)}`);
|
|
167
|
+
}
|
|
168
|
+
return write(`# capability ${request.name} ${result.error.code}`);
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
function reportingBroker(inner, output) {
|
|
173
|
+
return async (request, context) => {
|
|
174
|
+
const result = await inner(request, context);
|
|
175
|
+
output.capability(request, result);
|
|
176
|
+
return result;
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
// Every granted capability records its request (printed by the broker) and
|
|
180
|
+
// succeeds without touching privileged host state.
|
|
181
|
+
function grantHandlers(grants) {
|
|
182
|
+
const handlers = {};
|
|
183
|
+
for (const name of grants) {
|
|
184
|
+
if (!["oauth.authorize", "ai.ask", "ai.decide", "browser.tabs", "browser.content"].includes(name))
|
|
185
|
+
handlers[name] = () => null;
|
|
186
|
+
}
|
|
187
|
+
return handlers;
|
|
188
|
+
}
|
|
189
|
+
function parseScript(source) {
|
|
190
|
+
const steps = [];
|
|
191
|
+
for (const [index, raw] of source.split(/\r?\n/).entries()) {
|
|
192
|
+
const line = raw.trim();
|
|
193
|
+
if (line === "" || line.startsWith("#"))
|
|
194
|
+
continue;
|
|
195
|
+
const separator = line.indexOf(" ");
|
|
196
|
+
const word = (separator === -1 ? line : line.slice(0, separator)).toLowerCase();
|
|
197
|
+
const rest = separator === -1 ? "" : line.slice(separator + 1).trim();
|
|
198
|
+
switch (word) {
|
|
199
|
+
case "action":
|
|
200
|
+
if (rest === "")
|
|
201
|
+
throw new ScriptError(`script line ${index + 1}: action needs a title`);
|
|
202
|
+
steps.push({ op: "action", title: rest });
|
|
203
|
+
break;
|
|
204
|
+
case "submenu":
|
|
205
|
+
if (rest === "")
|
|
206
|
+
throw new ScriptError(`script line ${index + 1}: submenu needs a title`);
|
|
207
|
+
steps.push({ op: "submenu", title: rest });
|
|
208
|
+
break;
|
|
209
|
+
case "search":
|
|
210
|
+
if (rest === "")
|
|
211
|
+
throw new ScriptError(`script line ${index + 1}: search needs text`);
|
|
212
|
+
steps.push({ op: "search", text: rest });
|
|
213
|
+
break;
|
|
214
|
+
case "confirm":
|
|
215
|
+
case "dismiss":
|
|
216
|
+
steps.push({ op: word });
|
|
217
|
+
break;
|
|
218
|
+
case "dropdown":
|
|
219
|
+
if (rest === "")
|
|
220
|
+
throw new ScriptError(`script line ${index + 1}: dropdown needs a value`);
|
|
221
|
+
steps.push({ op: "dropdown", value: rest });
|
|
222
|
+
break;
|
|
223
|
+
case "field": {
|
|
224
|
+
const split = rest.indexOf(" ");
|
|
225
|
+
if (rest === "")
|
|
226
|
+
throw new ScriptError(`script line ${index + 1}: field needs an id and value`);
|
|
227
|
+
steps.push({
|
|
228
|
+
op: "field",
|
|
229
|
+
id: split === -1 ? rest : rest.slice(0, split),
|
|
230
|
+
value: split === -1 ? "" : rest.slice(split + 1),
|
|
231
|
+
});
|
|
232
|
+
break;
|
|
233
|
+
}
|
|
234
|
+
case "more":
|
|
235
|
+
case "submit":
|
|
236
|
+
steps.push({ op: word });
|
|
237
|
+
break;
|
|
238
|
+
case "back":
|
|
239
|
+
steps.push({ op: "back" });
|
|
240
|
+
break;
|
|
241
|
+
case "wait":
|
|
242
|
+
steps.push({ op: "wait" });
|
|
243
|
+
break;
|
|
244
|
+
case "expect":
|
|
245
|
+
if (rest === "")
|
|
246
|
+
throw new ScriptError(`script line ${index + 1}: expect needs text`);
|
|
247
|
+
steps.push({ op: "expect", text: rest });
|
|
248
|
+
break;
|
|
249
|
+
case "quit":
|
|
250
|
+
steps.push({ op: "quit" });
|
|
251
|
+
break;
|
|
252
|
+
default:
|
|
253
|
+
throw new ScriptError(`script line ${index + 1}: unknown command ${JSON.stringify(word)}`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return steps;
|
|
257
|
+
}
|
|
258
|
+
function findNode(root, match) {
|
|
259
|
+
if (match(root))
|
|
260
|
+
return root;
|
|
261
|
+
for (const child of root.children) {
|
|
262
|
+
const found = findNode(child, match);
|
|
263
|
+
if (found)
|
|
264
|
+
return found;
|
|
265
|
+
}
|
|
266
|
+
return undefined;
|
|
267
|
+
}
|
|
268
|
+
function waitForScene(session, after) {
|
|
269
|
+
const latest = session.latestScene;
|
|
270
|
+
if (latest && latest.revision > after)
|
|
271
|
+
return Promise.resolve(latest);
|
|
272
|
+
return new Promise((resolve, reject) => {
|
|
273
|
+
const unsubscribe = session.scenes((scene) => {
|
|
274
|
+
if (scene.revision > after) {
|
|
275
|
+
unsubscribe();
|
|
276
|
+
resolve(scene);
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
void session.exited.then((exit) => {
|
|
280
|
+
unsubscribe();
|
|
281
|
+
if (exit.error)
|
|
282
|
+
reject(new RunError(exit.error.message));
|
|
283
|
+
else if (exit.code === 0 && exit.signal === null) {
|
|
284
|
+
reject(new CleanExit("runtime exited without publishing the awaited scene"));
|
|
285
|
+
}
|
|
286
|
+
else
|
|
287
|
+
reject(new RunError(`runtime exited with code ${exit.code ?? exit.signal}`));
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
function currentScene(session) {
|
|
292
|
+
return session.latestScene ? Promise.resolve(session.latestScene) : waitForScene(session, -1);
|
|
293
|
+
}
|
|
294
|
+
async function runScript(session, steps) {
|
|
295
|
+
// Highest scene revision this script has synchronized with; `wait` resolves
|
|
296
|
+
// only for scenes newer than the one the previous step acted on.
|
|
297
|
+
let observed = session.latestScene?.revision ?? -1;
|
|
298
|
+
for (const step of steps) {
|
|
299
|
+
if (step.op === "quit")
|
|
300
|
+
return;
|
|
301
|
+
switch (step.op) {
|
|
302
|
+
case "confirm":
|
|
303
|
+
case "dismiss": {
|
|
304
|
+
const scene = await currentScene(session);
|
|
305
|
+
observed = Math.max(observed, scene.revision);
|
|
306
|
+
const alert = scene.root
|
|
307
|
+
? findNode(scene.root, (node) => node.type === "Alert")
|
|
308
|
+
: undefined;
|
|
309
|
+
const name = step.op === "confirm" ? "primaryAction" : "dismissAction";
|
|
310
|
+
const node = alert?.children.find((child) => child.props.name === name);
|
|
311
|
+
const handler = node ? handlerRef(node.props.onAction) : undefined;
|
|
312
|
+
if (!node || !handler)
|
|
313
|
+
throw new ScriptError("no alert to answer");
|
|
314
|
+
session.sendEvent(node.id, handler.$handler, []);
|
|
315
|
+
break;
|
|
316
|
+
}
|
|
317
|
+
case "back": {
|
|
318
|
+
const scene = await currentScene(session);
|
|
319
|
+
observed = Math.max(observed, scene.revision);
|
|
320
|
+
const node = scene.root?.type === "Navigation" ? scene.root : undefined;
|
|
321
|
+
const handler = node ? handlerRef(node.props.pop) : undefined;
|
|
322
|
+
if (!node || !handler)
|
|
323
|
+
throw new ScriptError("no navigation view to pop");
|
|
324
|
+
session.sendEvent(node.id, handler.$handler, []);
|
|
325
|
+
break;
|
|
326
|
+
}
|
|
327
|
+
case "action": {
|
|
328
|
+
const scene = await currentScene(session);
|
|
329
|
+
observed = Math.max(observed, scene.revision);
|
|
330
|
+
const node = scene.root
|
|
331
|
+
? findNode(scene.root, (item) => (item.type.startsWith("Action") || item.type === "MenuBarExtra.Item") &&
|
|
332
|
+
item.props.title === step.title)
|
|
333
|
+
: undefined;
|
|
334
|
+
const handler = node ? handlerRef(node.props.onAction) : undefined;
|
|
335
|
+
if (!node || !handler) {
|
|
336
|
+
throw new ScriptError(`no Action with onAction titled ${JSON.stringify(step.title)}`);
|
|
337
|
+
}
|
|
338
|
+
session.sendEvent(node.id, handler.$handler, node.type === "MenuBarExtra.Item" ? [{ type: "left-click" }] : []);
|
|
339
|
+
break;
|
|
340
|
+
}
|
|
341
|
+
case "submenu": {
|
|
342
|
+
const scene = await currentScene(session);
|
|
343
|
+
observed = Math.max(observed, scene.revision);
|
|
344
|
+
const node = scene.root
|
|
345
|
+
? findNode(scene.root, (item) => item.type === "ActionPanel.Submenu" && item.props.title === step.title)
|
|
346
|
+
: undefined;
|
|
347
|
+
const handler = node ? handlerRef(node.props.onOpen) : undefined;
|
|
348
|
+
if (!node || !handler) {
|
|
349
|
+
throw new ScriptError(`no ActionPanel.Submenu with onOpen titled ${JSON.stringify(step.title)}`);
|
|
350
|
+
}
|
|
351
|
+
session.sendEvent(node.id, handler.$handler, []);
|
|
352
|
+
break;
|
|
353
|
+
}
|
|
354
|
+
case "field":
|
|
355
|
+
case "submit": {
|
|
356
|
+
const scene = await currentScene(session);
|
|
357
|
+
observed = Math.max(observed, scene.revision);
|
|
358
|
+
const node = scene.root
|
|
359
|
+
? findNode(scene.root, (item) => step.op === "submit"
|
|
360
|
+
? item.type === "Action.SubmitForm"
|
|
361
|
+
: item.type.startsWith("Form.") && item.props.id === step.id)
|
|
362
|
+
: undefined;
|
|
363
|
+
const handler = node
|
|
364
|
+
? handlerRef(node.props[step.op === "submit" ? "onAction" : "onChange"])
|
|
365
|
+
: undefined;
|
|
366
|
+
if (!node || !handler)
|
|
367
|
+
throw new ScriptError(step.op === "submit"
|
|
368
|
+
? "no Action.SubmitForm to submit"
|
|
369
|
+
: `no editable Form field ${JSON.stringify(step.id)}`);
|
|
370
|
+
let args = [];
|
|
371
|
+
if (step.op === "field") {
|
|
372
|
+
if (node.type === "Form.Checkbox" && !["true", "false"].includes(step.value))
|
|
373
|
+
throw new ScriptError("checkbox field value must be true or false");
|
|
374
|
+
const value = node.type === "Form.FilePicker" || node.type === "Form.TagPicker"
|
|
375
|
+
? step.value === ""
|
|
376
|
+
? []
|
|
377
|
+
: step.value.split(",")
|
|
378
|
+
: node.type === "Form.Checkbox"
|
|
379
|
+
? step.value === "true"
|
|
380
|
+
: step.value;
|
|
381
|
+
args = [value];
|
|
382
|
+
}
|
|
383
|
+
session.sendEvent(node.id, handler.$handler, args);
|
|
384
|
+
break;
|
|
385
|
+
}
|
|
386
|
+
case "more": {
|
|
387
|
+
const scene = await currentScene(session);
|
|
388
|
+
observed = Math.max(observed, scene.revision);
|
|
389
|
+
const view = scene.root
|
|
390
|
+
? findNode(scene.root, (item) => item.type === "List" || item.type === "Grid")
|
|
391
|
+
: undefined;
|
|
392
|
+
const pagination = view?.props.pagination;
|
|
393
|
+
const config = pagination && typeof pagination === "object" && !Array.isArray(pagination)
|
|
394
|
+
? pagination
|
|
395
|
+
: undefined;
|
|
396
|
+
const handler = handlerRef(config && "onLoadMore" in config ? config.onLoadMore : undefined);
|
|
397
|
+
if (!view || !handler || !config || !("hasMore" in config) || config.hasMore !== true)
|
|
398
|
+
throw new ScriptError("no List or Grid with more pages to load");
|
|
399
|
+
session.sendEvent(view.id, handler.$handler, []);
|
|
400
|
+
break;
|
|
401
|
+
}
|
|
402
|
+
case "search": {
|
|
403
|
+
const scene = await currentScene(session);
|
|
404
|
+
observed = Math.max(observed, scene.revision);
|
|
405
|
+
const view = scene.root
|
|
406
|
+
? findNode(scene.root, (item) => item.type === "List" || item.type === "Grid")
|
|
407
|
+
: undefined;
|
|
408
|
+
const handler = view ? handlerRef(view.props.onSearchTextChange) : undefined;
|
|
409
|
+
if (!view || !handler)
|
|
410
|
+
throw new ScriptError("no List or Grid with onSearchTextChange to search");
|
|
411
|
+
session.sendEvent(view.id, handler.$handler, [step.text]);
|
|
412
|
+
break;
|
|
413
|
+
}
|
|
414
|
+
case "dropdown": {
|
|
415
|
+
const scene = await currentScene(session);
|
|
416
|
+
observed = Math.max(observed, scene.revision);
|
|
417
|
+
const dropdown = scene.root
|
|
418
|
+
? findNode(scene.root, (item) => item.type === "List.Dropdown" || item.type === "Grid.Dropdown")
|
|
419
|
+
: undefined;
|
|
420
|
+
const handler = dropdown ? handlerRef(dropdown.props.onChange) : undefined;
|
|
421
|
+
if (!dropdown || !handler)
|
|
422
|
+
throw new ScriptError("no List.Dropdown or Grid.Dropdown with onChange to select");
|
|
423
|
+
session.sendEvent(dropdown.id, handler.$handler, [step.value]);
|
|
424
|
+
break;
|
|
425
|
+
}
|
|
426
|
+
case "wait": {
|
|
427
|
+
observed = (await waitForScene(session, observed)).revision;
|
|
428
|
+
break;
|
|
429
|
+
}
|
|
430
|
+
case "expect": {
|
|
431
|
+
const scene = await currentScene(session);
|
|
432
|
+
const lines = scene.root ? renderTree(scene.root) : [];
|
|
433
|
+
if (!lines.some((line) => line.includes(step.text))) {
|
|
434
|
+
throw new ScriptError(`expected the scene to contain ${JSON.stringify(step.text)}`);
|
|
435
|
+
}
|
|
436
|
+
break;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
/** `--argument name=value` values, checked against the manifest's arguments. */
|
|
442
|
+
function parseArgumentValues(specs, declared, command) {
|
|
443
|
+
const values = {};
|
|
444
|
+
for (const spec of specs) {
|
|
445
|
+
const separator = spec.indexOf("=");
|
|
446
|
+
if (separator < 1) {
|
|
447
|
+
throw new UsageError(`--argument must be name=value, got ${JSON.stringify(spec)}`);
|
|
448
|
+
}
|
|
449
|
+
const name = spec.slice(0, separator);
|
|
450
|
+
if (!declared.some((argument) => argument.name === name)) {
|
|
451
|
+
throw new UsageError(`command ${JSON.stringify(command)} has no argument ${JSON.stringify(name)}`);
|
|
452
|
+
}
|
|
453
|
+
values[name] = spec.slice(separator + 1);
|
|
454
|
+
}
|
|
455
|
+
const missing = declared.filter((argument) => argument.required === true && values[argument.name] === undefined);
|
|
456
|
+
if (missing.length) {
|
|
457
|
+
const names = missing.map((argument) => argument.name).join(", ");
|
|
458
|
+
throw new UsageError(`missing required ${missing.length === 1 ? "argument" : "arguments"} ${names} for ` +
|
|
459
|
+
`${JSON.stringify(command)}: pass --argument <name=value>`);
|
|
460
|
+
}
|
|
461
|
+
return values;
|
|
462
|
+
}
|
|
463
|
+
// Best-effort temp cleanup when the CLI itself is interrupted; the runtime
|
|
464
|
+
// child exits on its own once the parent's stdin pipe closes.
|
|
465
|
+
const SIGNAL_NUMBERS = { SIGHUP: 1, SIGINT: 2, SIGTERM: 15 };
|
|
466
|
+
let cleanupDirectory;
|
|
467
|
+
for (const signal of ["SIGHUP", "SIGINT", "SIGTERM"]) {
|
|
468
|
+
process.on(signal, () => {
|
|
469
|
+
if (cleanupDirectory !== undefined) {
|
|
470
|
+
rmSync(cleanupDirectory, { force: true, recursive: true });
|
|
471
|
+
}
|
|
472
|
+
process.exit(128 + SIGNAL_NUMBERS[signal]);
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
// A reader that stops early (`blast run … | head`) closes stdout: finish
|
|
476
|
+
// quietly instead of crashing on EPIPE.
|
|
477
|
+
process.stdout.on("error", (error) => {
|
|
478
|
+
if (error.code !== "EPIPE")
|
|
479
|
+
throw error;
|
|
480
|
+
if (cleanupDirectory !== undefined)
|
|
481
|
+
rmSync(cleanupDirectory, { force: true, recursive: true });
|
|
482
|
+
process.exit(EXIT_OK);
|
|
483
|
+
});
|
|
484
|
+
async function run(options) {
|
|
485
|
+
const output = createOutput(options.json);
|
|
486
|
+
let deadlineTimer;
|
|
487
|
+
const deadline = new Promise((_, reject) => {
|
|
488
|
+
if (options.timeoutMs !== undefined) {
|
|
489
|
+
deadlineTimer = setTimeout(() => {
|
|
490
|
+
reject(new RunError(`timed out after ${options.timeoutMs}ms`));
|
|
491
|
+
}, options.timeoutMs);
|
|
492
|
+
}
|
|
493
|
+
});
|
|
494
|
+
// A rejection landing between two races must not surface as unhandled.
|
|
495
|
+
deadline.catch(() => undefined);
|
|
496
|
+
let outDir;
|
|
497
|
+
let session;
|
|
498
|
+
let stopScenes;
|
|
499
|
+
try {
|
|
500
|
+
const steps = options.scriptSource === undefined ? undefined : parseScript(options.scriptSource);
|
|
501
|
+
let bundled;
|
|
502
|
+
try {
|
|
503
|
+
const dependencies = await checkExtensionDependencies({
|
|
504
|
+
extensionDir: options.extensionDir,
|
|
505
|
+
dataDir: options.dataDir,
|
|
506
|
+
});
|
|
507
|
+
if (dependencies.unresolved.length && (dependencies.needed || !dependencies.installable))
|
|
508
|
+
throw new DependenciesRequiredError(dependencies);
|
|
509
|
+
try {
|
|
510
|
+
bundled = await Promise.race([
|
|
511
|
+
bundleCommand({
|
|
512
|
+
extensionDir: options.extensionDir,
|
|
513
|
+
command: options.command,
|
|
514
|
+
...(options.bundleTimeoutMs === undefined
|
|
515
|
+
? {}
|
|
516
|
+
: { timeoutMs: options.bundleTimeoutMs }),
|
|
517
|
+
dataDir: options.dataDir,
|
|
518
|
+
}),
|
|
519
|
+
deadline,
|
|
520
|
+
]);
|
|
521
|
+
}
|
|
522
|
+
catch (bundleError) {
|
|
523
|
+
if (!(bundleError instanceof MissingDependencyError))
|
|
524
|
+
throw bundleError;
|
|
525
|
+
const manifest = JSON.parse(await readFile(join(options.extensionDir, "package.json"), "utf8"));
|
|
526
|
+
const status = await checkExtensionDependencies({
|
|
527
|
+
extensionDir: options.extensionDir,
|
|
528
|
+
dataDir: options.dataDir,
|
|
529
|
+
});
|
|
530
|
+
throw new DependenciesRequiredError({
|
|
531
|
+
unresolved: bundleError.packages,
|
|
532
|
+
installable: bundleError.packages.every((name) => Object.hasOwn(manifest.dependencies ?? {}, name)),
|
|
533
|
+
pinned: status.pinned,
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
catch (error) {
|
|
538
|
+
if (error instanceof RunError)
|
|
539
|
+
throw error;
|
|
540
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
541
|
+
// A bad extension or command name is the caller's usage error; any other
|
|
542
|
+
// bundling failure is reported like a crash.
|
|
543
|
+
if (/^(Unknown extension command|Invalid command name|Missing source for )/.test(message)) {
|
|
544
|
+
throw new UsageError(message);
|
|
545
|
+
}
|
|
546
|
+
throw new RunError(message);
|
|
547
|
+
}
|
|
548
|
+
for (const message of bundled.dependencyWarnings ?? []) {
|
|
549
|
+
write(options.json ? JSON.stringify({ type: "log", level: "warn", message }) : `# log ${message}`);
|
|
550
|
+
}
|
|
551
|
+
outDir = dirname(bundled.entry);
|
|
552
|
+
cleanupDirectory = outDir;
|
|
553
|
+
const argumentValues = parseArgumentValues(options.argumentSpecs, bundled.arguments, options.command);
|
|
554
|
+
// Local storage is granted to the session's own extension by default and
|
|
555
|
+
// overrides `--grant storage.*`, so a stale grant cannot disable it. The
|
|
556
|
+
// read-only headless defaults override a bare `--grant` the same way:
|
|
557
|
+
// answering them with null would break the extension.
|
|
558
|
+
const broker = createSessionBroker({ extensionName: bundled.extensionName, dataDir: options.dataDir }, {
|
|
559
|
+
handlers: {
|
|
560
|
+
...grantHandlers(options.grants),
|
|
561
|
+
...createHeadlessHandlers({
|
|
562
|
+
aiAnswer: options.aiAnswer,
|
|
563
|
+
browserTabs: options.browserTabs,
|
|
564
|
+
oauthCode: options.oauthCode,
|
|
565
|
+
applications: options.applications,
|
|
566
|
+
clipboard: options.clipboard,
|
|
567
|
+
selectedText: options.selectedText,
|
|
568
|
+
selectedFiles: options.selectedFiles,
|
|
569
|
+
frontmost: options.frontmost,
|
|
570
|
+
}),
|
|
571
|
+
...(options.aiDecide === undefined ? {} : { "ai.decide": () => options.aiDecide }),
|
|
572
|
+
},
|
|
573
|
+
});
|
|
574
|
+
session = await Promise.race([
|
|
575
|
+
startCommand({
|
|
576
|
+
...bundled,
|
|
577
|
+
argumentValues,
|
|
578
|
+
...(options.fallbackText === undefined ? {} : { fallbackText: options.fallbackText }),
|
|
579
|
+
dataDir: options.dataDir,
|
|
580
|
+
availableAPIs: [
|
|
581
|
+
...(options.aiAnswer === undefined ? [] : ["AI"]),
|
|
582
|
+
...(options.browserTabs === undefined ? [] : ["BrowserExtension"]),
|
|
583
|
+
],
|
|
584
|
+
broker: reportingBroker(broker, output),
|
|
585
|
+
}),
|
|
586
|
+
deadline,
|
|
587
|
+
]).catch((error) => {
|
|
588
|
+
throw new RunError(error instanceof Error ? error.message : String(error));
|
|
589
|
+
});
|
|
590
|
+
const active = session;
|
|
591
|
+
stopScenes = active.scenes((scene) => {
|
|
592
|
+
output.flushLogs(active);
|
|
593
|
+
output.scene(scene);
|
|
594
|
+
});
|
|
595
|
+
await Promise.race([
|
|
596
|
+
steps === undefined ? waitForScene(active, -1) : runScript(active, steps),
|
|
597
|
+
deadline,
|
|
598
|
+
]);
|
|
599
|
+
output.flushLogs(active);
|
|
600
|
+
// A graceful exit waits for host acknowledgements of pending writes (form
|
|
601
|
+
// drafts, storeValue). On a loaded machine that can exceed a few hundred ms,
|
|
602
|
+
// and SIGTERM then loses the data; a hung child is still bounded by
|
|
603
|
+
// `--timeout` through `deadline`.
|
|
604
|
+
const exit = await Promise.race([
|
|
605
|
+
active.shutdown({ timeoutMs: GRACEFUL_SHUTDOWN_MS }),
|
|
606
|
+
deadline,
|
|
607
|
+
]);
|
|
608
|
+
if (exit.error)
|
|
609
|
+
throw new RunError(exit.error.message);
|
|
610
|
+
if (exit.code !== 0 || exit.signal !== null) {
|
|
611
|
+
throw new RunError(`runtime exited with code ${exit.code ?? exit.signal}`);
|
|
612
|
+
}
|
|
613
|
+
return EXIT_OK;
|
|
614
|
+
}
|
|
615
|
+
catch (error) {
|
|
616
|
+
if (error instanceof UsageError)
|
|
617
|
+
throw error;
|
|
618
|
+
if (error instanceof CleanExit)
|
|
619
|
+
return EXIT_OK;
|
|
620
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
621
|
+
console.error(`# error ${message}`);
|
|
622
|
+
return error instanceof ScriptError ? EXIT_FAILURE : EXIT_CRASH;
|
|
623
|
+
}
|
|
624
|
+
finally {
|
|
625
|
+
if (deadlineTimer !== undefined)
|
|
626
|
+
clearTimeout(deadlineTimer);
|
|
627
|
+
stopScenes?.();
|
|
628
|
+
await session?.shutdown({ timeoutMs: 250 });
|
|
629
|
+
if (session)
|
|
630
|
+
output.flushLogs(session);
|
|
631
|
+
if (outDir !== undefined)
|
|
632
|
+
await rm(outDir, { recursive: true, force: true });
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
const GRACEFUL_SHUTDOWN_MS = 5000;
|
|
636
|
+
function parseArguments(argv) {
|
|
637
|
+
try {
|
|
638
|
+
return parseArgs({
|
|
639
|
+
args: argv,
|
|
640
|
+
allowPositionals: true,
|
|
641
|
+
options: {
|
|
642
|
+
argument: { type: "string", multiple: true },
|
|
643
|
+
grant: { type: "string", multiple: true },
|
|
644
|
+
applications: { type: "string" },
|
|
645
|
+
clipboard: { type: "string" },
|
|
646
|
+
"oauth-code": { type: "string" },
|
|
647
|
+
"ai-answer": { type: "string" },
|
|
648
|
+
"ai-decide": { type: "string" },
|
|
649
|
+
"browser-tabs": { type: "string" },
|
|
650
|
+
"selected-text": { type: "string" },
|
|
651
|
+
"selected-files": { type: "string", multiple: true },
|
|
652
|
+
frontmost: { type: "string" },
|
|
653
|
+
"data-dir": { type: "string" },
|
|
654
|
+
"fallback-text": { type: "string" },
|
|
655
|
+
script: { type: "string" },
|
|
656
|
+
json: { type: "boolean" },
|
|
657
|
+
timeout: { type: "string" },
|
|
658
|
+
"bundle-timeout": { type: "string" },
|
|
659
|
+
help: { type: "boolean" },
|
|
660
|
+
},
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
catch (error) {
|
|
664
|
+
throw new UsageError(error instanceof Error ? error.message : String(error));
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
async function readScript(specifier) {
|
|
668
|
+
try {
|
|
669
|
+
return specifier === "-" ? await readStdin() : await readFile(specifier, "utf8");
|
|
670
|
+
}
|
|
671
|
+
catch {
|
|
672
|
+
throw new UsageError(`cannot read script ${JSON.stringify(specifier)}`);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
async function readDecisions(path) {
|
|
676
|
+
try {
|
|
677
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
678
|
+
}
|
|
679
|
+
catch {
|
|
680
|
+
throw new UsageError(`cannot read --ai-decide JSON file ${JSON.stringify(path)}`);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
async function readBrowserTabs(specifier) {
|
|
684
|
+
try {
|
|
685
|
+
return parseBrowserTabs(await readFile(specifier, "utf8"));
|
|
686
|
+
}
|
|
687
|
+
catch (error) {
|
|
688
|
+
throw new UsageError(`--browser-tabs ${JSON.stringify(specifier)}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
async function readApplications(specifier) {
|
|
692
|
+
let text;
|
|
693
|
+
try {
|
|
694
|
+
text = await readFile(specifier, "utf8");
|
|
695
|
+
}
|
|
696
|
+
catch (error) {
|
|
697
|
+
throw new UsageError(`cannot read --applications file ${JSON.stringify(specifier)}`, {
|
|
698
|
+
cause: error,
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
try {
|
|
702
|
+
return parseApplications(text);
|
|
703
|
+
}
|
|
704
|
+
catch (error) {
|
|
705
|
+
throw new UsageError(`--applications ${JSON.stringify(specifier)}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
async function readStdin() {
|
|
709
|
+
const chunks = [];
|
|
710
|
+
for await (const chunk of process.stdin) {
|
|
711
|
+
chunks.push(typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk);
|
|
712
|
+
}
|
|
713
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
714
|
+
}
|
|
715
|
+
async function main(argv) {
|
|
716
|
+
const parsed = parseArguments(argv);
|
|
717
|
+
if (parsed.values.help) {
|
|
718
|
+
console.log(USAGE);
|
|
719
|
+
return EXIT_OK;
|
|
720
|
+
}
|
|
721
|
+
const [subcommand, extensionDir, command, prompt] = parsed.positionals;
|
|
722
|
+
if (subcommand === "deps") {
|
|
723
|
+
if (!extensionDir || parsed.positionals.length !== 2)
|
|
724
|
+
throw new UsageError("expected deps <extensionDir>");
|
|
725
|
+
if (parsed.values["data-dir"] === "")
|
|
726
|
+
throw new UsageError("--data-dir must not be empty");
|
|
727
|
+
await installExtensionDependencies({
|
|
728
|
+
extensionDir,
|
|
729
|
+
dataDir: parsed.values["data-dir"] ?? defaultDataDir(),
|
|
730
|
+
onLog: (message) => console.log(message.trimEnd()),
|
|
731
|
+
});
|
|
732
|
+
return EXIT_OK;
|
|
733
|
+
}
|
|
734
|
+
if (subcommand === "models" || subcommand === "complete") {
|
|
735
|
+
if (!extensionDir || parsed.positionals.length !== (subcommand === "models" ? 2 : 4))
|
|
736
|
+
throw new UsageError("expected models <extensionDir> or complete <extensionDir> <modelId> <prompt>");
|
|
737
|
+
const bundled = await bundleModelProvider({
|
|
738
|
+
extensionDir,
|
|
739
|
+
dataDir: parsed.values["data-dir"] ?? defaultDataDir(),
|
|
740
|
+
});
|
|
741
|
+
cleanupDirectory = dirname(bundled.entry);
|
|
742
|
+
let provider;
|
|
743
|
+
try {
|
|
744
|
+
provider = await startModelProvider({
|
|
745
|
+
...bundled,
|
|
746
|
+
...(parsed.values["data-dir"] === undefined ? {} : { dataDir: parsed.values["data-dir"] }),
|
|
747
|
+
});
|
|
748
|
+
const models = await provider.getModels();
|
|
749
|
+
if (subcommand === "models")
|
|
750
|
+
console.log(JSON.stringify(models));
|
|
751
|
+
else {
|
|
752
|
+
const model = models.find((item) => item.id === command);
|
|
753
|
+
if (!model)
|
|
754
|
+
throw new UsageError(`Unknown model ${command}`);
|
|
755
|
+
for await (const part of provider.stream(model, {
|
|
756
|
+
messages: [{ role: "user", content: [{ type: "text", text: prompt }] }],
|
|
757
|
+
providerOptions: {
|
|
758
|
+
raycast: {
|
|
759
|
+
locale: "en",
|
|
760
|
+
currentDate: new Date().toISOString(),
|
|
761
|
+
sessionId: "blast-cli",
|
|
762
|
+
},
|
|
763
|
+
},
|
|
764
|
+
})) {
|
|
765
|
+
if (part.type === "text-delta")
|
|
766
|
+
process.stdout.write(part.textDelta ?? part.text ?? "");
|
|
767
|
+
else if (part.type === "text")
|
|
768
|
+
process.stdout.write(part.text);
|
|
769
|
+
else if (part.type === "finish")
|
|
770
|
+
console.log(`\n${JSON.stringify(part)}`);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
return EXIT_OK;
|
|
774
|
+
}
|
|
775
|
+
finally {
|
|
776
|
+
await provider?.shutdown({ timeoutMs: 250 });
|
|
777
|
+
await rm(dirname(bundled.entry), { recursive: true, force: true });
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
if (subcommand !== "run" ||
|
|
781
|
+
extensionDir === undefined ||
|
|
782
|
+
command === undefined ||
|
|
783
|
+
parsed.positionals.length !== 3) {
|
|
784
|
+
throw new UsageError('expected "run <extensionDir> <command>"');
|
|
785
|
+
}
|
|
786
|
+
let timeoutMs;
|
|
787
|
+
if (parsed.values.timeout !== undefined) {
|
|
788
|
+
const value = Number(parsed.values.timeout);
|
|
789
|
+
if (!Number.isInteger(value) || value < 1) {
|
|
790
|
+
throw new UsageError("--timeout must be a positive number of milliseconds");
|
|
791
|
+
}
|
|
792
|
+
timeoutMs = value;
|
|
793
|
+
}
|
|
794
|
+
let bundleTimeoutMs;
|
|
795
|
+
if (parsed.values["bundle-timeout"] !== undefined) {
|
|
796
|
+
const value = Number(parsed.values["bundle-timeout"]);
|
|
797
|
+
if (parsed.values["bundle-timeout"].trim() === "" ||
|
|
798
|
+
!Number.isSafeInteger(value) ||
|
|
799
|
+
value < 0 ||
|
|
800
|
+
value > 2_147_483_647)
|
|
801
|
+
throw new UsageError("--bundle-timeout must be an integer between 0 and 2147483647 milliseconds");
|
|
802
|
+
bundleTimeoutMs = value;
|
|
803
|
+
}
|
|
804
|
+
const script = parsed.values.script;
|
|
805
|
+
const dataDir = parsed.values["data-dir"];
|
|
806
|
+
if (dataDir === "")
|
|
807
|
+
throw new UsageError("--data-dir must not be empty");
|
|
808
|
+
const applicationsPath = parsed.values.applications;
|
|
809
|
+
return run({
|
|
810
|
+
extensionDir,
|
|
811
|
+
command,
|
|
812
|
+
argumentSpecs: parsed.values.argument ?? [],
|
|
813
|
+
fallbackText: parsed.values["fallback-text"],
|
|
814
|
+
grants: parsed.values.grant ?? [],
|
|
815
|
+
applications: applicationsPath === undefined ? undefined : await readApplications(applicationsPath),
|
|
816
|
+
clipboard: parsed.values.clipboard,
|
|
817
|
+
aiAnswer: parsed.values["ai-answer"],
|
|
818
|
+
aiDecide: parsed.values["ai-decide"] === undefined
|
|
819
|
+
? undefined
|
|
820
|
+
: await readDecisions(parsed.values["ai-decide"]),
|
|
821
|
+
browserTabs: parsed.values["browser-tabs"] === undefined
|
|
822
|
+
? undefined
|
|
823
|
+
: await readBrowserTabs(parsed.values["browser-tabs"]),
|
|
824
|
+
oauthCode: parsed.values["oauth-code"],
|
|
825
|
+
selectedText: parsed.values["selected-text"],
|
|
826
|
+
selectedFiles: parsed.values["selected-files"] ?? [],
|
|
827
|
+
frontmost: parsed.values.frontmost,
|
|
828
|
+
dataDir: dataDir ?? defaultDataDir(),
|
|
829
|
+
scriptSource: script === undefined ? undefined : await readScript(script),
|
|
830
|
+
json: parsed.values.json === true,
|
|
831
|
+
timeoutMs,
|
|
832
|
+
bundleTimeoutMs,
|
|
833
|
+
});
|
|
834
|
+
}
|
|
835
|
+
let code;
|
|
836
|
+
try {
|
|
837
|
+
code = await main(process.argv.slice(2));
|
|
838
|
+
}
|
|
839
|
+
catch (error) {
|
|
840
|
+
if (error instanceof UsageError) {
|
|
841
|
+
console.error(`blast: ${error.message}`);
|
|
842
|
+
console.error(USAGE);
|
|
843
|
+
code = EXIT_USAGE;
|
|
844
|
+
}
|
|
845
|
+
else {
|
|
846
|
+
console.error(`# error ${error instanceof Error ? error.message : String(error)}`);
|
|
847
|
+
code = EXIT_CRASH;
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
// Let piped stdout and stderr drain before exiting so tests capture everything.
|
|
851
|
+
await Promise.all([
|
|
852
|
+
new Promise((done) => process.stdout.write("", () => done())),
|
|
853
|
+
new Promise((done) => process.stderr.write("", () => done())),
|
|
854
|
+
]);
|
|
855
|
+
process.exit(code);
|