jevway 0.1.0 → 0.2.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/INSTALL-FOR-AGENTS.md +32 -38
- package/README.md +193 -150
- package/dist/cli/daemon-call.js +78 -0
- package/dist/cli/dispatch.js +74 -0
- package/dist/cli/flags.js +152 -0
- package/dist/cli/help.js +46 -0
- package/dist/cli/journey-command.js +162 -0
- package/dist/cli/look-command.js +47 -0
- package/dist/cli/main.js +64 -0
- package/dist/cli/output.js +27 -0
- package/dist/cli/run-command.js +173 -0
- package/dist/cli/session-commands.js +132 -0
- package/dist/daemon/client.js +175 -0
- package/dist/daemon/paths.js +35 -0
- package/dist/daemon/protocol.js +28 -0
- package/dist/daemon/server.js +320 -0
- package/dist/jev/replay.js +7 -3
- package/dist/jev/retry.js +29 -0
- package/dist/journeys/journey-doc.js +2 -1
- package/dist/journeys/journey-session.js +14 -3
- package/dist/journeys/render-viewer.js +163 -150
- package/dist/mcp/instructions.js +18 -0
- package/dist/mcp/journey-tools.js +117 -0
- package/dist/mcp/results.js +53 -0
- package/dist/mcp/server.js +50 -0
- package/dist/mcp/task-tools.js +197 -0
- package/dist/mcp.js +12 -337
- package/dist/output/layout.js +50 -0
- package/dist/output/manifest.js +80 -0
- package/dist/output/run-folder.js +38 -0
- package/dist/recording/describe-step.js +66 -0
- package/dist/{task → recording}/recording-page.js +164 -18
- package/dist/{task → recording}/recording.js +33 -91
- package/dist/recording/run-page.js +155 -0
- package/dist/session/session-service.js +193 -0
- package/dist/setup/atomic-write.js +13 -0
- package/dist/setup/clients.js +180 -0
- package/dist/setup/command-output.js +15 -0
- package/dist/setup/doctor-command.js +16 -7
- package/dist/setup/doctor.js +17 -2
- package/dist/setup/env-file.js +22 -0
- package/dist/setup/setup-command.js +104 -0
- package/dist/setup/setup-wizard.js +216 -0
- package/dist/setup/skill-command.js +101 -0
- package/dist/setup/terminal-prompter.js +51 -0
- package/dist/setup/unified-diff.js +69 -0
- package/dist/task/present.js +90 -0
- package/dist/task/run-task.js +74 -11
- package/dist/task/start-task.js +21 -5
- package/package.json +12 -7
- package/skill/jevway/SKILL.md +50 -14
- package/dist/cli.js +0 -112
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { DaemonFailure } from '../daemon/client.js';
|
|
3
|
+
import { outRoot } from '../output/layout.js';
|
|
4
|
+
import { CliError, usageError } from './flags.js';
|
|
5
|
+
import { eventPrinter } from './output.js';
|
|
6
|
+
const BROWSER = /^(auto|launch|launch-headless|https?:\/\/\S+|wss?:\/\/\S+)$/;
|
|
7
|
+
/** The browser and model a new session or one-shot run uses: the flags, else `JEVWAY_BROWSER` / `JEVWAY_MODEL` / `JEVWAY_SCREENSHOTS`. */
|
|
8
|
+
export const runnerChoice = (flags, env) => {
|
|
9
|
+
const pick = (flag, variable) => (typeof flags[flag] === 'string' ? flags[flag] : env[variable]?.trim() || undefined);
|
|
10
|
+
const browser = pick('browser', 'JEVWAY_BROWSER') ?? 'auto';
|
|
11
|
+
if (!BROWSER.test(browser)) {
|
|
12
|
+
throw usageError('invalid_value', `--browser must be auto, launch, launch-headless or a debugging URL, got "${browser}"`, 'Example: --browser launch-headless');
|
|
13
|
+
}
|
|
14
|
+
const model = pick('model', 'JEVWAY_MODEL');
|
|
15
|
+
const screenshots = pick('screenshots', 'JEVWAY_SCREENSHOTS');
|
|
16
|
+
return { browser, ...(model && { model }), ...(screenshots && { screenshots }) };
|
|
17
|
+
};
|
|
18
|
+
/** A runner for `choice`; an unknown model or screenshot mode is a usage error. */
|
|
19
|
+
export const runnerFor = (deps, { browser, ...choice }) => {
|
|
20
|
+
try {
|
|
21
|
+
return deps.createRunner(browser, choice);
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
throw usageError('invalid_value', error.message, 'jevway run --help lists the models and screenshot modes');
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
/** Fails with `missing_key` (exit 2) before any browser or daemon starts when the model's key is not set. */
|
|
28
|
+
export const requireKey = (runner) => {
|
|
29
|
+
try {
|
|
30
|
+
runner.requireKey();
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
throw usageError('missing_key', error.message, 'Run jevway setup in a terminal, or see jevway doctor');
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
/** Where this caller's files go: resolved here, so they land under its working directory whoever started the daemon. */
|
|
37
|
+
export const placement = ({ env, cwd }) => ({
|
|
38
|
+
outDir: outRoot(env, cwd),
|
|
39
|
+
...(env.JEVWAY_JOURNEYS_DIR?.trim() && { journeysDir: resolve(cwd, env.JEVWAY_JOURNEYS_DIR.trim()) }),
|
|
40
|
+
cwd,
|
|
41
|
+
});
|
|
42
|
+
/** The running daemon, started when needed (a new session), or found (a session it should already hold). */
|
|
43
|
+
export const daemonFor = async (deps, need) => {
|
|
44
|
+
if ('start' in need)
|
|
45
|
+
return deps.daemon.ensure(eventPrinter(deps.io));
|
|
46
|
+
const running = await deps.daemon.find();
|
|
47
|
+
if (running)
|
|
48
|
+
return running;
|
|
49
|
+
throw new CliError(1, 'unknown_session', `Unknown ${need.kind} "${need.session}": no jevway daemon is running, and sessions end when it stops (or after its idle time).`, need.kind === 'session' ? 'Start a new one with jevway run --session new --url <url> --goal <goal>' : 'Start a new one with jevway journey start --url <url>');
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Calls the daemon and closes the connection. A connection lost mid-call is `outcome_unknown` for a call that may
|
|
53
|
+
* have clicked or typed (`writes`), pointing at `jevway look` for the session, else `daemon_unavailable`.
|
|
54
|
+
*/
|
|
55
|
+
export const callDaemon = async (connection, method, params, { writes, session, log, ...options }) => {
|
|
56
|
+
try {
|
|
57
|
+
return await connection.request(method, params, options);
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
if (!(error instanceof DaemonFailure) || !error.lost)
|
|
61
|
+
throw error;
|
|
62
|
+
if (!writes)
|
|
63
|
+
throw new CliError(1, 'daemon_unavailable', error.message, `See ${log}; jevway stop and run the command again`);
|
|
64
|
+
const id = session?.();
|
|
65
|
+
throw new CliError(1, 'outcome_unknown', 'The daemon stopped answering while the call was running; it may already have clicked or typed.', id ? `Check the page with jevway look --session ${id} before repeating the call.` : 'Find the session with jevway sessions and check its page with jevway look --session <id> before repeating the call.');
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
connection.close();
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
/** How long a client waits for a call: always longer than the daemon's own limit for it. */
|
|
72
|
+
export const TIMEOUTS = {
|
|
73
|
+
/** A task call: its time budget (`--max-seconds`, default 300; a continue may carry up to 3600) plus two minutes. */
|
|
74
|
+
task: (maxSeconds, continuing) => ((maxSeconds ?? (continuing ? 3600 : 300)) + 120) * 1000,
|
|
75
|
+
quick: 60_000,
|
|
76
|
+
/** Autopilot has no step limit of its own. */
|
|
77
|
+
journey: 30 * 60_000,
|
|
78
|
+
};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { DOCTOR_USAGE, runDoctor } from '../setup/doctor-command.js';
|
|
2
|
+
import { SETUP_USAGE, runSetup } from '../setup/setup-command.js';
|
|
3
|
+
import { SKILL_USAGE, runSkill } from '../setup/skill-command.js';
|
|
4
|
+
import { closestName, usageError } from './flags.js';
|
|
5
|
+
import { topHelp } from './help.js';
|
|
6
|
+
import { journeyCommand } from './journey-command.js';
|
|
7
|
+
import { lookCommand } from './look-command.js';
|
|
8
|
+
import { printFailure } from './output.js';
|
|
9
|
+
import { runCommand } from './run-command.js';
|
|
10
|
+
import { endCommand, serveCommand, sessionsCommand, stopCommand } from './session-commands.js';
|
|
11
|
+
const COMMANDS = {
|
|
12
|
+
run: { summary: 'hand a browser goal to Jev (one-shot, or a session with --session)', run: runCommand },
|
|
13
|
+
look: { summary: 'print what a page or a session\'s tab shows, with no model call', run: lookCommand },
|
|
14
|
+
journey: { summary: 'map mobile user journeys: start, step, autopilot, annotate, finish', run: journeyCommand },
|
|
15
|
+
sessions: { summary: 'list the open sessions and journeys', run: sessionsCommand },
|
|
16
|
+
end: { summary: 'end a session and close the tabs it opened', run: endCommand },
|
|
17
|
+
serve: { summary: 'run the session daemon in the foreground', run: serveCommand },
|
|
18
|
+
stop: { summary: 'stop the session daemon', run: stopCommand },
|
|
19
|
+
doctor: { summary: 'check the key, browser, Node and ffmpeg (--json for agents)', run: (argv, deps) => runDoctor(argv, deps.fromEnvFile), usage: DOCTOR_USAGE },
|
|
20
|
+
setup: { summary: 'set up the key, browser and MCP clients (interactive; --client <name> --print for agents)', run: (argv, deps) => runSetup(argv, deps.fromEnvFile), usage: SETUP_USAGE },
|
|
21
|
+
skill: { summary: 'print the agent skill, or --install it into a skills folder', run: (argv) => runSkill(argv), usage: SKILL_USAGE },
|
|
22
|
+
};
|
|
23
|
+
const printUsage = async (deps, usage) => {
|
|
24
|
+
await deps.io.out(usage.endsWith('\n') ? usage : `${usage}\n`);
|
|
25
|
+
return 0;
|
|
26
|
+
};
|
|
27
|
+
const commandList = () => [...Object.entries(COMMANDS).map(([name, { summary }]) => ({ name, summary })), { name: 'help [command]', summary: 'this help, or a command\'s' }];
|
|
28
|
+
const unknownCommand = (name) => {
|
|
29
|
+
const meant = closestName(name, Object.keys(COMMANDS));
|
|
30
|
+
return usageError('unknown_command', `Unknown command "${name}"`, `${meant ? `Did you mean jevway ${meant}? ` : ''}See jevway --help`);
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* `jevway <argv>`: runs one command and returns its exit code. Every failure, expected or not, becomes one
|
|
34
|
+
* `{ ok: false, error }` document on stdout: never a stack trace.
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* process.exitCode = await runCli(['run', '--url', 'https://example.com', '--goal', 'Open the docs'], deps);
|
|
38
|
+
*/
|
|
39
|
+
export const runCli = async (argv, deps) => {
|
|
40
|
+
try {
|
|
41
|
+
const [first, ...rest] = argv;
|
|
42
|
+
if (first === undefined) {
|
|
43
|
+
throw usageError('missing_command', 'No command given', 'Start with jevway run --url <url> --goal "<outcome>"; jevway --help lists the commands');
|
|
44
|
+
}
|
|
45
|
+
if (first === '--version' || first === '-v') {
|
|
46
|
+
await deps.io.out(`${deps.version}\n`);
|
|
47
|
+
return 0;
|
|
48
|
+
}
|
|
49
|
+
if (first === '--help' || first === '-h' || (first === 'help' && !rest.length)) {
|
|
50
|
+
await deps.io.out(topHelp(commandList()));
|
|
51
|
+
return 0;
|
|
52
|
+
}
|
|
53
|
+
if (first === 'help') {
|
|
54
|
+
const command = COMMANDS[rest[0]];
|
|
55
|
+
if (!command)
|
|
56
|
+
throw unknownCommand(rest[0]);
|
|
57
|
+
if (command.usage)
|
|
58
|
+
return await printUsage(deps, command.usage);
|
|
59
|
+
return await command.run([...rest.slice(1), '--help'], deps);
|
|
60
|
+
}
|
|
61
|
+
// Today's form: `jevway --goal … --url …` is `jevway run …`.
|
|
62
|
+
if (first.startsWith('-'))
|
|
63
|
+
return await runCommand(argv, deps);
|
|
64
|
+
const command = COMMANDS[first];
|
|
65
|
+
if (!command)
|
|
66
|
+
throw unknownCommand(first);
|
|
67
|
+
if (command.usage && (rest.includes('--help') || rest.includes('-h')))
|
|
68
|
+
return await printUsage(deps, command.usage);
|
|
69
|
+
return await command.run(rest, deps);
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
return printFailure(deps.io, error);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A failure the CLI prints as `{ ok: false, error: { code, message, hint } }` and exits with: 2 for bad usage, 1 for a
|
|
3
|
+
* tool error or a hand-back.
|
|
4
|
+
*/
|
|
5
|
+
export class CliError extends Error {
|
|
6
|
+
exitCode;
|
|
7
|
+
code;
|
|
8
|
+
hint;
|
|
9
|
+
constructor(exitCode, code, message, hint) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.exitCode = exitCode;
|
|
12
|
+
this.code = code;
|
|
13
|
+
this.hint = hint;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export const usageError = (code, message, hint) => new CliError(2, code, message, hint);
|
|
17
|
+
const distance = (a, b) => {
|
|
18
|
+
let previous = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
19
|
+
for (let i = 1; i <= a.length; i++) {
|
|
20
|
+
const current = [i];
|
|
21
|
+
for (let j = 1; j <= b.length; j++) {
|
|
22
|
+
current[j] = Math.min(previous[j] + 1, current[j - 1] + 1, previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
23
|
+
}
|
|
24
|
+
previous = current;
|
|
25
|
+
}
|
|
26
|
+
return previous[b.length];
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* The name `word` most likely meant: the nearest within two edits, else one it starts or ends.
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* closestName('vales', ['values', 'goal']) // 'values'
|
|
33
|
+
*/
|
|
34
|
+
export const closestName = (word, names) => {
|
|
35
|
+
const [nearest] = names.map((name) => ({ name, edits: distance(word, name) })).sort((a, b) => a.edits - b.edits);
|
|
36
|
+
if (nearest && nearest.edits <= 2)
|
|
37
|
+
return nearest.name;
|
|
38
|
+
return word.length >= 3 ? names.find((name) => name.startsWith(word) || word.startsWith(name)) : undefined;
|
|
39
|
+
};
|
|
40
|
+
const helpHint = (spec) => `See jevway ${spec.name} --help`;
|
|
41
|
+
/**
|
|
42
|
+
* Reads `argv` against the command's flags: `--name value`, `--name=value`, booleans, and up to `positionals` plain
|
|
43
|
+
* arguments. `--help`/`-h` always parse. Anything else is a `CliError` (exit 2) naming it.
|
|
44
|
+
*
|
|
45
|
+
* @example
|
|
46
|
+
* parseFlags(runSpec, ['--goal', 'Open it', '--keep-open'], { positionals: 0 })
|
|
47
|
+
* // { flags: { goal: 'Open it', 'keep-open': true }, positionals: [] }
|
|
48
|
+
*/
|
|
49
|
+
export const parseFlags = (spec, argv, { positionals: allowed }) => {
|
|
50
|
+
const flags = {};
|
|
51
|
+
const positionals = [];
|
|
52
|
+
// Help wins over any other mistake on the line.
|
|
53
|
+
if (argv.some((arg) => arg === '--help' || arg === '-h'))
|
|
54
|
+
return { flags: { help: true }, positionals };
|
|
55
|
+
for (let i = 0; i < argv.length; i++) {
|
|
56
|
+
const arg = argv[i];
|
|
57
|
+
if (arg === '--') {
|
|
58
|
+
positionals.push(...argv.slice(i + 1));
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
if (!arg.startsWith('-') || arg === '-' || /^-\d/.test(arg)) {
|
|
62
|
+
positionals.push(arg);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const [rawName, ...inline] = arg.replace(/^--?/, '').split('=');
|
|
66
|
+
const flag = spec.flags[rawName];
|
|
67
|
+
if (!flag || !arg.startsWith('--')) {
|
|
68
|
+
const meant = closestName(rawName, Object.keys(spec.flags));
|
|
69
|
+
throw usageError('unknown_flag', `Unknown flag ${arg.split('=')[0]}`, `${meant ? `Did you mean --${meant}? ` : ''}${helpHint(spec)}`);
|
|
70
|
+
}
|
|
71
|
+
if (flag.type === 'boolean') {
|
|
72
|
+
if (inline.length && !['true', 'false'].includes(inline.join('='))) {
|
|
73
|
+
throw usageError('invalid_value', `--${rawName} takes no value`, `Pass --${rawName} alone. ${helpHint(spec)}`);
|
|
74
|
+
}
|
|
75
|
+
flags[rawName] = inline.join('=') !== 'false';
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const value = inline.length ? inline.join('=') : argv[i + 1];
|
|
79
|
+
if (value === undefined || (!inline.length && value.startsWith('--'))) {
|
|
80
|
+
throw usageError('missing_value', `--${rawName} needs a value`, `--${rawName} ${flag.value ?? '<value>'}. ${helpHint(spec)}`);
|
|
81
|
+
}
|
|
82
|
+
if (!inline.length)
|
|
83
|
+
i++;
|
|
84
|
+
flags[rawName] = value;
|
|
85
|
+
}
|
|
86
|
+
if (positionals.length > allowed) {
|
|
87
|
+
throw usageError('unexpected_argument', `Unexpected argument "${positionals[allowed]}"`, `Quote values that hold spaces. ${helpHint(spec)}`);
|
|
88
|
+
}
|
|
89
|
+
return { flags, positionals };
|
|
90
|
+
};
|
|
91
|
+
/**
|
|
92
|
+
* A JSON flag parsed and checked against `schema`; errors name the flag and show `example`.
|
|
93
|
+
*
|
|
94
|
+
* @example
|
|
95
|
+
* jsonFlag('values', '{"city":"Lisbon"}', VALUES, '{"city":"Lisbon"}') // { city: 'Lisbon' }
|
|
96
|
+
*/
|
|
97
|
+
export const jsonFlag = (name, raw, schema, example) => {
|
|
98
|
+
if (raw === undefined)
|
|
99
|
+
return undefined;
|
|
100
|
+
const hint = `Example: --${name} '${example}'`;
|
|
101
|
+
let parsed;
|
|
102
|
+
try {
|
|
103
|
+
parsed = JSON.parse(raw);
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
throw usageError('invalid_value', `--${name}: ${error.message}`, hint);
|
|
107
|
+
}
|
|
108
|
+
const result = schema.safeParse(parsed);
|
|
109
|
+
if (!result.success) {
|
|
110
|
+
const issue = result.error.issues[0];
|
|
111
|
+
const where = issue?.path.length ? ` at ${issue.path.join('.')}` : '';
|
|
112
|
+
throw usageError('invalid_value', `--${name}: ${issue?.message ?? 'not valid'}${where}`, hint);
|
|
113
|
+
}
|
|
114
|
+
return result.data;
|
|
115
|
+
};
|
|
116
|
+
/** A whole number flag within `min`..`max`. */
|
|
117
|
+
export const intFlag = (name, raw, { min, max }) => {
|
|
118
|
+
if (raw === undefined)
|
|
119
|
+
return undefined;
|
|
120
|
+
const value = Number(raw);
|
|
121
|
+
if (!/^\d+$/.test(raw.trim()) || value < min || value > max) {
|
|
122
|
+
throw usageError('invalid_value', `--${name} must be a whole number from ${min} to ${max}, got "${raw}"`, `Example: --${name} ${min}`);
|
|
123
|
+
}
|
|
124
|
+
return value;
|
|
125
|
+
};
|
|
126
|
+
/** A flag that must be one of `choices`. */
|
|
127
|
+
export const choiceFlag = (name, raw, choices) => {
|
|
128
|
+
if (raw === undefined)
|
|
129
|
+
return undefined;
|
|
130
|
+
if (!choices.includes(raw)) {
|
|
131
|
+
throw usageError('invalid_value', `--${name} must be ${choices.slice(0, -1).join(', ')} or ${choices.at(-1)}, got "${raw}"`, `Example: --${name} ${choices.at(-1)}`);
|
|
132
|
+
}
|
|
133
|
+
return raw;
|
|
134
|
+
};
|
|
135
|
+
/** A comma-separated list, blanks dropped. */
|
|
136
|
+
export const listFlag = (raw) => raw
|
|
137
|
+
?.split(',')
|
|
138
|
+
.map((item) => item.trim())
|
|
139
|
+
.filter(Boolean);
|
|
140
|
+
const UNITS = { '': 1000, s: 1000, m: 60_000, h: 3_600_000 };
|
|
141
|
+
/**
|
|
142
|
+
* A duration in milliseconds: plain seconds, or a number with `s`, `m` or `h`. `0` is 0 (callers read it as never).
|
|
143
|
+
*
|
|
144
|
+
* @example
|
|
145
|
+
* durationFlag('idle-timeout', '30m') // 1800000
|
|
146
|
+
*/
|
|
147
|
+
export const durationFlag = (name, raw) => {
|
|
148
|
+
const match = /^(\d+(?:\.\d+)?)\s*([smh]?)$/.exec(raw.trim());
|
|
149
|
+
if (!match)
|
|
150
|
+
throw usageError('invalid_value', `--${name} must be seconds or a number with s, m or h, got "${raw}"`, `Example: --${name} 1h (0 means never)`);
|
|
151
|
+
return Math.round(Number(match[1]) * UNITS[match[2]]);
|
|
152
|
+
};
|
package/dist/cli/help.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
const FLAG_COLUMN = 30;
|
|
2
|
+
/**
|
|
3
|
+
* A command's help: examples first (agents copy those), then the usage line, the flags and the notes.
|
|
4
|
+
*
|
|
5
|
+
* @example
|
|
6
|
+
* renderHelp(lookSpec) // 'Examples:\n jevway look --url https://example.com\n…'
|
|
7
|
+
*/
|
|
8
|
+
export const renderHelp = (spec) => {
|
|
9
|
+
const flags = Object.entries(spec.flags).map(([name, flag]) => {
|
|
10
|
+
const left = ` --${name}${flag.value ? ` ${flag.value}` : ''}`;
|
|
11
|
+
return `${left.padEnd(FLAG_COLUMN)} ${flag.describe}`;
|
|
12
|
+
});
|
|
13
|
+
return [
|
|
14
|
+
'Examples:',
|
|
15
|
+
...spec.examples.map((example) => ` ${example}`),
|
|
16
|
+
'',
|
|
17
|
+
`jevway ${spec.name}${spec.argument ? ` ${spec.argument}` : ''} [flags]: ${spec.summary}`,
|
|
18
|
+
'',
|
|
19
|
+
'Flags:',
|
|
20
|
+
...flags,
|
|
21
|
+
`${' --help, -h'.padEnd(FLAG_COLUMN)} this help`,
|
|
22
|
+
...(spec.notes?.length ? ['', ...spec.notes] : []),
|
|
23
|
+
'',
|
|
24
|
+
].join('\n');
|
|
25
|
+
};
|
|
26
|
+
/** `jevway --help`: examples, then every command in one line each. */
|
|
27
|
+
export const topHelp = (commands) => [
|
|
28
|
+
'Examples:',
|
|
29
|
+
' jevway run --url https://en.wikipedia.org --goal "Open the article about the Rosetta Stone"',
|
|
30
|
+
' jevway run --session new --browser launch-headless --url https://example.com/signup --goal "Sign up" --values \'{"email":"ada@example.com"}\'',
|
|
31
|
+
' jevway run --session <id> --goal "Sign up" --values \'{"code":"123456"}\'',
|
|
32
|
+
' jevway look --session <id>',
|
|
33
|
+
' jevway sessions',
|
|
34
|
+
' jevway end <id>',
|
|
35
|
+
'',
|
|
36
|
+
'jevway <command> [flags]: hand a browser goal to TypeSafe Jev and get one JSON result back.',
|
|
37
|
+
'',
|
|
38
|
+
'Commands:',
|
|
39
|
+
...commands.map(({ name, summary }) => ` ${name.padEnd(18)} ${summary}`),
|
|
40
|
+
'',
|
|
41
|
+
'Output: one JSON document on stdout ({ "ok": true, … } or { "ok": false, "error": { code, message, hint } });',
|
|
42
|
+
'progress as NDJSON events on stderr. Exit 0 on success, 1 on a hand-back or a tool error, 2 on bad usage.',
|
|
43
|
+
'`jevway --goal …` is `jevway run --goal …`. `jevway help <command>` or `jevway <command> --help` for its flags.',
|
|
44
|
+
'Keys come from the environment or ~/.config/jevway/env (jevway setup, jevway doctor).',
|
|
45
|
+
'',
|
|
46
|
+
].join('\n');
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { DEVICES } from '../browser/devices.js';
|
|
4
|
+
import { callDaemon, daemonFor, placement, requireKey, runnerChoice, runnerFor, TIMEOUTS } from './daemon-call.js';
|
|
5
|
+
import { choiceFlag, closestName, intFlag, jsonFlag, parseFlags, usageError } from './flags.js';
|
|
6
|
+
import { renderHelp } from './help.js';
|
|
7
|
+
import { printDoc } from './output.js';
|
|
8
|
+
const ID = { journey: { type: 'string', value: '<id>', describe: 'the journey (or pass it as the argument)' } };
|
|
9
|
+
const NOTES = { description: { type: 'string', value: '<text>', describe: 'what the screen is, for the viewer' }, insight: { type: 'string', value: '<text>', describe: 'a UX observation' } };
|
|
10
|
+
const REPORT = ['Prints { ok: true, journey, screen: { id, screenshot: <absolute path>, … }, … }: the screen report the MCP tools return, with the screenshot\'s path instead of an inline image.'];
|
|
11
|
+
const specs = {
|
|
12
|
+
start: {
|
|
13
|
+
name: 'journey start',
|
|
14
|
+
summary: 'open a URL on an emulated phone and map its first screen',
|
|
15
|
+
examples: ['jevway journey start --url https://stays.example --intent "find and open a stay in Lisbon"', 'jevway journey start --url https://app.example --device pixel-8 --browser launch-headless'],
|
|
16
|
+
flags: {
|
|
17
|
+
url: { type: 'string', value: '<url>', describe: 'the first screen' },
|
|
18
|
+
device: { type: 'string', value: '<name>', describe: `${Object.keys(DEVICES).join(', ')} (default iphone-15)` },
|
|
19
|
+
intent: { type: 'string', value: '<text>', describe: 'focus on one journey; leave out to explore core journeys' },
|
|
20
|
+
values: { type: 'string', value: '<json>', describe: 'text autopilot may type (search queries, fake names)' },
|
|
21
|
+
'stay-within': { type: 'string', value: '<url prefix>', describe: 'URL prefix autopilot must stay under (default the same origin)' },
|
|
22
|
+
'out-dir': { type: 'string', value: '<dir>', describe: 'the exact folder for the map (default .jevway/journeys/<stamp>_<host>_<id4>)' },
|
|
23
|
+
browser: { type: 'string', value: '<source>', describe: 'auto, launch, launch-headless or a debugging URL' },
|
|
24
|
+
model: { type: 'string', value: '<name>', describe: 'the decision model (default $JEVWAY_MODEL or jev)' },
|
|
25
|
+
screenshots: { type: 'string', value: 'off|fallback|always', describe: 'marked screenshots for vision models' },
|
|
26
|
+
},
|
|
27
|
+
notes: REPORT,
|
|
28
|
+
},
|
|
29
|
+
step: {
|
|
30
|
+
name: 'journey step',
|
|
31
|
+
argument: '<id>',
|
|
32
|
+
summary: 'move the journey one step and note what you learned about the screen you leave',
|
|
33
|
+
examples: ['jevway journey step <id> --next 3 --flow "Homes/Searching homes" --description "Search results"', 'jevway journey step <id> --next back'],
|
|
34
|
+
flags: {
|
|
35
|
+
...ID,
|
|
36
|
+
next: { type: 'string', value: '<move>', describe: 'an element index from elements/jev.ranked_taps ("3:2" for an option), back, restart, scroll_down, scroll_up or screen:<id>' },
|
|
37
|
+
value: { type: 'string', value: '<text>', describe: 'type this into a text field' },
|
|
38
|
+
flow: { type: 'string', value: '<name>', describe: 'the flow the step belongs to; it sticks for later steps' },
|
|
39
|
+
...NOTES,
|
|
40
|
+
},
|
|
41
|
+
notes: REPORT,
|
|
42
|
+
},
|
|
43
|
+
autopilot: {
|
|
44
|
+
name: 'journey autopilot',
|
|
45
|
+
argument: '<id>',
|
|
46
|
+
summary: 'let Jev explore: until the intent is reached, or for --screens new screens',
|
|
47
|
+
examples: ['jevway journey autopilot <id> --screens 5', 'jevway journey autopilot <id> --intent "open a stay" --flow "Booking"'],
|
|
48
|
+
flags: {
|
|
49
|
+
...ID,
|
|
50
|
+
screens: { type: 'string', value: '<n>', describe: 'without an intent: check back after this many new screens (1 to 30)' },
|
|
51
|
+
flow: { type: 'string', value: '<name>', describe: 'record the steps under this flow' },
|
|
52
|
+
intent: { type: 'string', value: '<text>', describe: 'keep going until this is reached or a stop condition' },
|
|
53
|
+
},
|
|
54
|
+
notes: REPORT,
|
|
55
|
+
},
|
|
56
|
+
annotate: {
|
|
57
|
+
name: 'journey annotate',
|
|
58
|
+
argument: '<id>',
|
|
59
|
+
summary: 'describe a screen, or move steps into a named flow',
|
|
60
|
+
examples: ['jevway journey annotate <id> --screen s02 --description "Search results" --insight "Filters hide below the fold"', 'jevway journey annotate <id> --screen s03 --relabel \'{"from_step":2,"to_step":4,"flow":"Checkout"}\''],
|
|
61
|
+
flags: {
|
|
62
|
+
...ID,
|
|
63
|
+
screen: { type: 'string', value: '<screen id>', describe: 'the screen, e.g. s02' },
|
|
64
|
+
...NOTES,
|
|
65
|
+
relabel: { type: 'string', value: '<json>', describe: 'move steps [from_step..to_step] (0-based, see journey.json) into a flow' },
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
finish: {
|
|
69
|
+
name: 'journey finish',
|
|
70
|
+
argument: '<id>',
|
|
71
|
+
summary: 'write the final map and viewer, close the tab, and list screens still missing a description',
|
|
72
|
+
examples: ['jevway journey finish <id>', 'jevway journey finish <id> --keep-tab'],
|
|
73
|
+
flags: { ...ID, 'keep-tab': { type: 'boolean', describe: 'leave the tab open' } },
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
const overview = [
|
|
77
|
+
'Examples:',
|
|
78
|
+
' jevway journey start --url https://stays.example --intent "find and open a stay in Lisbon"',
|
|
79
|
+
' jevway journey step <id> --next 3 --description "Home with a search box"',
|
|
80
|
+
' jevway journey autopilot <id> --screens 5',
|
|
81
|
+
' jevway journey finish <id>',
|
|
82
|
+
'',
|
|
83
|
+
'jevway journey <start|step|autopilot|annotate|finish> [flags]: map mobile user journeys through the daemon.',
|
|
84
|
+
'',
|
|
85
|
+
...Object.values(specs).map(({ name, summary }) => ` ${name.padEnd(18)} ${summary}`),
|
|
86
|
+
'',
|
|
87
|
+
'jevway journey <subcommand> --help for its flags. Use made-up data for any typing; stop at login, payment and send steps.',
|
|
88
|
+
'',
|
|
89
|
+
].join('\n');
|
|
90
|
+
const LABELS = z.record(z.string(), z.string());
|
|
91
|
+
const RELABEL = z.object({ from_step: z.number().int(), to_step: z.number().int(), flow: z.string() });
|
|
92
|
+
/** `jevway journey <subcommand>`: the journey tools through the daemon. */
|
|
93
|
+
export const journeyCommand = async (argv, deps) => {
|
|
94
|
+
const [sub, ...rest] = argv;
|
|
95
|
+
if (sub === undefined || sub === '--help' || sub === '-h') {
|
|
96
|
+
await deps.io.out(overview);
|
|
97
|
+
return 0;
|
|
98
|
+
}
|
|
99
|
+
const spec = specs[sub];
|
|
100
|
+
if (!spec) {
|
|
101
|
+
const meant = closestName(sub, Object.keys(specs));
|
|
102
|
+
throw usageError('unknown_command', `Unknown command "journey ${sub}"`, `${meant ? `Did you mean jevway journey ${meant}? ` : ''}See jevway journey --help`);
|
|
103
|
+
}
|
|
104
|
+
const { flags, positionals } = parseFlags(spec, rest, { positionals: spec.argument ? 1 : 0 });
|
|
105
|
+
if (flags.help) {
|
|
106
|
+
await deps.io.out(renderHelp(spec));
|
|
107
|
+
return 0;
|
|
108
|
+
}
|
|
109
|
+
const text = (name) => (typeof flags[name] === 'string' ? flags[name] : undefined);
|
|
110
|
+
const reply = async (method, params, connection) => {
|
|
111
|
+
const result = await callDaemon(connection, method, params, { writes: method !== 'journey.annotate', log: deps.daemon.log, timeoutMs: TIMEOUTS.journey, session: () => params.journey ?? undefined });
|
|
112
|
+
await printDoc(deps.io, { ok: true, ...result });
|
|
113
|
+
return 0;
|
|
114
|
+
};
|
|
115
|
+
if (sub === 'start') {
|
|
116
|
+
const url = text('url');
|
|
117
|
+
if (!url)
|
|
118
|
+
throw usageError('missing_flag', 'jevway journey start needs --url', 'Example: jevway journey start --url https://stays.example');
|
|
119
|
+
if (!URL.canParse(url))
|
|
120
|
+
throw usageError('invalid_value', `--url must be an absolute URL, got "${url}"`, 'Example: --url https://stays.example');
|
|
121
|
+
const outDir = text('out-dir');
|
|
122
|
+
const request = {
|
|
123
|
+
url,
|
|
124
|
+
device: choiceFlag('device', text('device'), Object.keys(DEVICES)),
|
|
125
|
+
intent: text('intent'),
|
|
126
|
+
values: jsonFlag('values', text('values'), LABELS, '{"query":"Lisbon"}'),
|
|
127
|
+
stayWithin: text('stay-within'),
|
|
128
|
+
outDir: outDir && resolve(deps.cwd, outDir),
|
|
129
|
+
};
|
|
130
|
+
const choice = runnerChoice(flags, deps.env);
|
|
131
|
+
requireKey(runnerFor(deps, choice));
|
|
132
|
+
return reply('journey.start', { request, runner: choice, place: placement(deps) }, await daemonFor(deps, { start: true }));
|
|
133
|
+
}
|
|
134
|
+
const journey = positionals[0] ?? text('journey');
|
|
135
|
+
if (!journey)
|
|
136
|
+
throw usageError('missing_flag', `jevway ${spec.name} needs the journey id`, `Example: jevway ${spec.name} <id>`);
|
|
137
|
+
const connection = () => daemonFor(deps, { session: journey, kind: 'journey' });
|
|
138
|
+
const notes = { description: text('description'), insight: text('insight') };
|
|
139
|
+
switch (sub) {
|
|
140
|
+
case 'step': {
|
|
141
|
+
const next = text('next');
|
|
142
|
+
if (!next)
|
|
143
|
+
throw usageError('missing_flag', 'jevway journey step needs --next', 'Example: jevway journey step <id> --next 3');
|
|
144
|
+
const input = { next: /^\d+$/.test(next) ? Number(next) : next, value: text('value'), flow: text('flow'), ...notes };
|
|
145
|
+
return reply('journey.step', { journey, input }, await connection());
|
|
146
|
+
}
|
|
147
|
+
case 'autopilot': {
|
|
148
|
+
const input = { screens: intFlag('screens', text('screens'), { min: 1, max: 30 }), flow: text('flow'), intent: text('intent') };
|
|
149
|
+
return reply('journey.autopilot', { journey, input }, await connection());
|
|
150
|
+
}
|
|
151
|
+
case 'annotate': {
|
|
152
|
+
const screen = text('screen');
|
|
153
|
+
if (!screen)
|
|
154
|
+
throw usageError('missing_flag', 'jevway journey annotate needs --screen', 'Example: jevway journey annotate <id> --screen s02 --description "Search results"');
|
|
155
|
+
const relabel = jsonFlag('relabel', text('relabel'), RELABEL, '{"from_step":2,"to_step":4,"flow":"Checkout"}');
|
|
156
|
+
const moved = relabel && { fromStep: relabel.from_step, toStep: relabel.to_step, flow: relabel.flow };
|
|
157
|
+
return reply('journey.annotate', { journey, screen, notes, relabel: moved }, await connection());
|
|
158
|
+
}
|
|
159
|
+
default:
|
|
160
|
+
return reply('journey.finish', { journey, keepTab: flags['keep-tab'] === true }, await connection());
|
|
161
|
+
}
|
|
162
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { lookAt } from '../task/look.js';
|
|
2
|
+
import { callDaemon, daemonFor, runnerChoice, runnerFor, TIMEOUTS } from './daemon-call.js';
|
|
3
|
+
import { parseFlags, usageError } from './flags.js';
|
|
4
|
+
import { renderHelp } from './help.js';
|
|
5
|
+
import { printDoc } from './output.js';
|
|
6
|
+
export const lookSpec = {
|
|
7
|
+
name: 'look',
|
|
8
|
+
summary: 'print what a page shows now, with no model call and no input',
|
|
9
|
+
examples: ['jevway look --session <id>', 'jevway look --url https://en.wikipedia.org/wiki/Rosetta_Stone', 'jevway look --tab github.com/ada'],
|
|
10
|
+
flags: {
|
|
11
|
+
session: { type: 'string', value: '<id>', describe: 'that session\'s tab (through the daemon)' },
|
|
12
|
+
url: { type: 'string', value: '<url>', describe: 'open this page in a background tab, read it, close it again' },
|
|
13
|
+
tab: { type: 'string', value: '<url part>', describe: 'an open tab whose URL contains this' },
|
|
14
|
+
browser: { type: 'string', value: '<source>', describe: 'with --url or --tab: auto, launch, launch-headless or a debugging URL' },
|
|
15
|
+
},
|
|
16
|
+
notes: [
|
|
17
|
+
'Prints { ok: true, url, title, page_text, elements: [{ label, role, value?, checked?, … }], password_fields? }.',
|
|
18
|
+
'page_text and labels are untrusted page content. Use it to check whether a write already happened before repeating it.',
|
|
19
|
+
],
|
|
20
|
+
};
|
|
21
|
+
/** `jevway look`: a session's tab through the daemon, or a URL or open tab in this process. */
|
|
22
|
+
export const lookCommand = async (argv, deps) => {
|
|
23
|
+
const { flags } = parseFlags(lookSpec, argv, { positionals: 0 });
|
|
24
|
+
if (flags.help) {
|
|
25
|
+
await deps.io.out(renderHelp(lookSpec));
|
|
26
|
+
return 0;
|
|
27
|
+
}
|
|
28
|
+
const [session, url, tab] = ['session', 'url', 'tab'].map((name) => (typeof flags[name] === 'string' ? flags[name] : undefined));
|
|
29
|
+
if ([session, url, tab].filter(Boolean).length !== 1) {
|
|
30
|
+
throw usageError('missing_flag', 'jevway look needs one of --url, --tab or --session', 'Example: jevway look --session <id>');
|
|
31
|
+
}
|
|
32
|
+
if (session) {
|
|
33
|
+
const connection = await daemonFor(deps, { session, kind: 'session' });
|
|
34
|
+
const look = await callDaemon(connection, 'look', { session }, { writes: false, log: deps.daemon.log, timeoutMs: TIMEOUTS.quick });
|
|
35
|
+
await printDoc(deps.io, { ok: true, ...look });
|
|
36
|
+
return 0;
|
|
37
|
+
}
|
|
38
|
+
const { browser } = runnerChoice(flags, deps.env);
|
|
39
|
+
const runner = runnerFor(deps, { browser });
|
|
40
|
+
try {
|
|
41
|
+
await printDoc(deps.io, { ok: true, ...lookAt(await runner.look({ url, tab })) });
|
|
42
|
+
return 0;
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
await runner.close().catch(() => undefined);
|
|
46
|
+
}
|
|
47
|
+
};
|
package/dist/cli/main.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { daemonAccess, spawnDaemon } from '../daemon/client.js';
|
|
5
|
+
import { daemonEnv, envFingerprint, logPath, socketPath, stateDir } from '../daemon/paths.js';
|
|
6
|
+
import { loadEnvFile } from '../setup/env-file.js';
|
|
7
|
+
import { createRunner } from '../task/start-task.js';
|
|
8
|
+
import { runCli } from './dispatch.js';
|
|
9
|
+
import { printFailure } from './output.js';
|
|
10
|
+
// Keys and JEVWAY_* settings the environment lacks come from ~/.config/jevway/env.
|
|
11
|
+
const fromEnvFile = loadEnvFile();
|
|
12
|
+
// The same depth from src/cli/ and dist/cli/.
|
|
13
|
+
const { version } = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
|
|
14
|
+
const cwd = process.cwd();
|
|
15
|
+
const dir = stateDir(process.env);
|
|
16
|
+
const spawnEnv = daemonEnv(process.env, cwd);
|
|
17
|
+
let printed = false;
|
|
18
|
+
const io = {
|
|
19
|
+
// Awaited, so the document is flushed before exit: on macOS a piped stdout is asynchronous.
|
|
20
|
+
out: (text) => new Promise((resolve) => {
|
|
21
|
+
printed = true;
|
|
22
|
+
process.stdout.write(text, () => resolve());
|
|
23
|
+
}),
|
|
24
|
+
err: (text) => void process.stderr.write(text),
|
|
25
|
+
};
|
|
26
|
+
// Nothing escapes as a stack trace: an error before the result prints as the one JSON document; one after it is dropped.
|
|
27
|
+
const lastResort = async (error) => {
|
|
28
|
+
if (!printed)
|
|
29
|
+
process.exit(await printFailure(io, error));
|
|
30
|
+
};
|
|
31
|
+
process.on('uncaughtException', lastResort);
|
|
32
|
+
process.on('unhandledRejection', lastResort);
|
|
33
|
+
let presses = 0;
|
|
34
|
+
const onInterrupt = (handler) => {
|
|
35
|
+
const press = () => {
|
|
36
|
+
// A second Ctrl-C leaves at once.
|
|
37
|
+
if (++presses > 1)
|
|
38
|
+
process.exit(130);
|
|
39
|
+
handler();
|
|
40
|
+
};
|
|
41
|
+
process.on('SIGINT', press);
|
|
42
|
+
process.on('SIGTERM', press);
|
|
43
|
+
return () => {
|
|
44
|
+
process.off('SIGINT', press);
|
|
45
|
+
process.off('SIGTERM', press);
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
const code = await runCli(process.argv.slice(2), {
|
|
49
|
+
io,
|
|
50
|
+
env: process.env,
|
|
51
|
+
cwd,
|
|
52
|
+
version,
|
|
53
|
+
fromEnvFile,
|
|
54
|
+
createRunner,
|
|
55
|
+
daemon: daemonAccess({
|
|
56
|
+
socket: socketPath(dir),
|
|
57
|
+
log: logPath(dir),
|
|
58
|
+
version,
|
|
59
|
+
fingerprint: envFingerprint(spawnEnv),
|
|
60
|
+
start: () => spawnDaemon({ entry: fileURLToPath(import.meta.url), stateDir: dir, env: spawnEnv }),
|
|
61
|
+
}),
|
|
62
|
+
onInterrupt,
|
|
63
|
+
});
|
|
64
|
+
process.exit(code);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { DaemonFailure } from '../daemon/client.js';
|
|
2
|
+
import { UnknownSession } from '../session/session-service.js';
|
|
3
|
+
import { CliError } from './flags.js';
|
|
4
|
+
// Daemon error codes that mean the command line itself was wrong.
|
|
5
|
+
const USAGE_CODES = new Set(['unknown_flag', 'unknown_command', 'missing_flag', 'missing_value', 'invalid_value', 'unexpected_argument', 'missing_command', 'missing_key']);
|
|
6
|
+
export const printDoc = async (io, doc) => {
|
|
7
|
+
await io.out(`${JSON.stringify(doc, null, 2)}\n`);
|
|
8
|
+
};
|
|
9
|
+
/** Writes each event as one JSON line on stderr. */
|
|
10
|
+
export const eventPrinter = (io) => (event) => io.err(`${JSON.stringify(event)}\n`);
|
|
11
|
+
/** Any failure as the CLI reports it: its exit code and the error body, never a stack trace. */
|
|
12
|
+
export const describeFailure = (error) => {
|
|
13
|
+
if (error instanceof CliError)
|
|
14
|
+
return { exitCode: error.exitCode, code: error.code, message: error.message, hint: error.hint };
|
|
15
|
+
if (error instanceof DaemonFailure)
|
|
16
|
+
return { exitCode: USAGE_CODES.has(error.code) ? 2 : 1, code: error.code, message: error.message, hint: error.hint };
|
|
17
|
+
if (error instanceof UnknownSession)
|
|
18
|
+
return { exitCode: 1, code: error.code, message: error.message, hint: 'List the open ones with jevway sessions' };
|
|
19
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
20
|
+
return { exitCode: 1, code: 'failed', message, hint: 'The message says what failed; jevway doctor checks the key and the browser.' };
|
|
21
|
+
};
|
|
22
|
+
/** Prints `{ ok: false, error }` for `error` and returns the exit code. */
|
|
23
|
+
export const printFailure = async (io, error) => {
|
|
24
|
+
const { exitCode, ...body } = describeFailure(error);
|
|
25
|
+
await printDoc(io, { ok: false, error: body });
|
|
26
|
+
return exitCode;
|
|
27
|
+
};
|