@revyl/verify 0.3.1 → 0.4.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.
@@ -91,7 +91,7 @@ export class RevylClient {
91
91
  timeoutMs: options.timeoutMs,
92
92
  signal: options.signal,
93
93
  retry: options.retry ?? false,
94
- headers: { 'X-Revyl-Agent': 'Verify' },
94
+ headers: { 'X-Revyl-Agent': 'Verify', ...options.headers },
95
95
  });
96
96
  }
97
97
  async sendOnce(method, url, options) {
@@ -198,7 +198,7 @@ export function edgeBlockedError(response) {
198
198
  }
199
199
  const REFUSAL_REASONS = new Set([
200
200
  'web_runtime_unavailable', 'web_runtime_not_configured', 'computer_limit', 'runtime_unavailable', 'computer_runtime_unavailable',
201
- 'computer_at_capacity', 'computer_not_running', 'computer_cannot_host_platform', 'computer_not_found',
201
+ 'computer_at_capacity', 'computer_platform_at_capacity', 'computer_not_running', 'computer_cannot_host_platform', 'computer_not_found',
202
202
  'web_build_required', 'web_build_not_supported', 'ios_build_required', 'ios_build_not_supported',
203
203
  'android_build_required', 'android_build_not_supported', 'web_session_conflict', 'ios_session_conflict',
204
204
  'android_session_conflict', 'not_available_on_platform', 'request_timeout', 'protocol_error',
package/dist/auth.js CHANGED
@@ -28,8 +28,8 @@ export async function resolveCredentials(options = {}) {
28
28
  export function missingCredentialsMessage() {
29
29
  return [
30
30
  'No Revyl credentials found.',
31
- 'Set REVYL_API_KEY to an API key from https://app.revyl.ai (Settings → API keys),',
32
- "or sign in once with the Revyl CLI: 'revyl auth login'.",
31
+ "Sign in through your browser with 'npx @revyl/verify login' in a terminal,",
32
+ 'or set REVYL_API_KEY to an API key from https://app.revyl.ai (Settings → API keys).',
33
33
  ].join(' ');
34
34
  }
35
35
  async function readStoredCredentials(path) {
@@ -8,6 +8,7 @@ export interface InitResult {
8
8
  created: string[];
9
9
  gitignoreUpdated: boolean;
10
10
  }
11
+ export declare function webAppIdMissingMessage(url: string | undefined): string;
11
12
  export declare function validateInitAnswers(answers: InitAnswers): InitAnswers;
12
13
  export declare function initProject(cwd: string, answers: InitAnswers, options?: {
13
14
  force?: boolean;
package/dist/cli/init.js CHANGED
@@ -5,7 +5,15 @@ import { VerifyError } from '../errors.js';
5
5
  import { PACKAGE_NAME } from '../version.js';
6
6
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
7
7
  const CONFIG_FILE = 'verify.config.ts';
8
- const EXAMPLE_SCRIPT = join('verify', 'example.ts');
8
+ const EXAMPLE_SCRIPT = join('verify', 'example.mts');
9
+ const SETTINGS_BUNDLE_ID = { ios: 'com.apple.Preferences', android: 'com.android.settings' };
10
+ export function webAppIdMissingMessage(url) {
11
+ return [
12
+ 'A Web target needs the id of a Web app in Revyl, so init wrote no files.',
13
+ 'Create one at https://app.revyl.ai: click Create app, keep Web selected, and enter your site\'s public HTTPS URL. Then copy the app id from the app\'s page.',
14
+ `Then run: npx ${PACKAGE_NAME} init --platform web --app-id <app-id> --url ${url ?? 'https://example.com'}`,
15
+ ].join('\n');
16
+ }
9
17
  export function validateInitAnswers(answers) {
10
18
  if (!['web', 'ios', 'android', 'mac'].includes(answers.platform)) {
11
19
  throw new VerifyError('INVALID_ARGUMENT', "--platform must be 'web', 'ios', 'android', or 'mac'. Linux init is unavailable; use open({ platform: 'linux' }) or a Linux config target.");
@@ -23,13 +31,16 @@ export function validateInitAnswers(answers) {
23
31
  throw new VerifyError('INVALID_ARGUMENT', '--url must be a public HTTPS URL without credentials, query, or fragment.');
24
32
  }
25
33
  }
34
+ if (answers.platform === 'web' && answers.appId === undefined) {
35
+ throw new VerifyError('INVALID_ARGUMENT', webAppIdMissingMessage(answers.url));
36
+ }
26
37
  return answers;
27
38
  }
28
39
  export async function initProject(cwd, answers, options = {}) {
29
40
  validateInitAnswers(answers);
30
41
  const files = [
31
42
  [CONFIG_FILE, configTemplate(answers)],
32
- [EXAMPLE_SCRIPT, scriptTemplate(answers.platform)],
43
+ [EXAMPLE_SCRIPT, scriptTemplate(answers)],
33
44
  ];
34
45
  const existing = files.map(([path]) => path).filter((path) => existsSync(join(cwd, path)));
35
46
  if (existing.length > 0 && !options.force) {
@@ -50,25 +61,33 @@ async function ignoreOutput(cwd) {
50
61
  return true;
51
62
  }
52
63
  function configTemplate({ platform, appId, url }) {
53
- const app = appId ? `'${appId}'` : `process.env.REVYL_APP_ID ?? 'REPLACE_WITH_YOUR_REVYL_APP_ID'`;
54
64
  const target = platform === 'mac'
55
65
  ? ` mac: { platform: 'mac' },`
56
66
  : platform === 'web'
57
67
  ? ` web: {
58
68
  platform: 'web',
59
69
  app: {
60
- // The Revyl web app to open: copy its id from the app's page in Revyl.
61
- appId: ${app},
70
+ // The Revyl Web app to open.
71
+ appId: '${appId}',
62
72
  // A public HTTPS URL, such as a preview deployment. Remove it to open the app's newest Revyl build.
63
73
  url: '${url ?? 'https://example.com'}',
64
74
  },
65
75
  viewport: { width: 1280, height: 800 },
66
76
  },`
67
- : ` ${platform}: {
77
+ : appId
78
+ ? ` ${platform}: {
68
79
  platform: '${platform}',
69
80
  app: {
70
81
  // The Revyl app to install: each session installs its newest build. Use buildId to pin one build.
71
- appId: ${app},
82
+ appId: '${appId}',
83
+ },
84
+ },`
85
+ : ` ${platform}: {
86
+ platform: '${platform}',
87
+ app: {
88
+ // The device's built-in Settings app, so the example runs before you upload anything.
89
+ // To test your own app, replace this line with appId: '<your Revyl app id>'.
90
+ bundleId: '${SETTINGS_BUNDLE_ID[platform]}',
72
91
  },
73
92
  },`;
74
93
  return `import { defineConfig } from '${PACKAGE_NAME}';
@@ -80,7 +99,7 @@ ${target}
80
99
  });
81
100
  `;
82
101
  }
83
- function scriptTemplate(platform) {
102
+ function scriptTemplate({ platform, appId }) {
84
103
  if (platform === 'mac') {
85
104
  return `// Mac computers are in early access; ask support@revyl.ai to enable your organization.
86
105
  import assert from 'node:assert/strict';
@@ -101,7 +120,13 @@ try {
101
120
  const steps = platform === 'web'
102
121
  ? `await session.instruction('Follow the link on the page');
103
122
  await session.validation('A page about domains reserved for documentation is shown');`
104
- : `await session.instruction('Wait for the app to finish opening and show its first screen');
123
+ : !appId && platform === 'ios'
124
+ ? `await session.instruction('Open General, then About');
125
+ await session.validation('The screen shows the operating system version');`
126
+ : !appId && platform === 'android'
127
+ ? `await session.instruction('Open the screen that shows information about this device, such as About or About phone, then scroll or navigate until the operating system version is visible');
128
+ await session.validation('The screen shows the version of the operating system');`
129
+ : `await session.instruction('Wait for the app to finish opening and show its first screen');
105
130
  await session.validation('The app has opened and shows its first screen without an error');`;
106
131
  return `import { open } from '${PACKAGE_NAME}';
107
132
 
@@ -1,6 +1,6 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { readFile, writeFile } from 'node:fs/promises';
3
- import { spawn } from 'node:child_process';
3
+ import { spawn, spawnSync } from 'node:child_process';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { VerifyError } from '../errors.js';
6
6
  import { PACKAGE_NAME } from '../version.js';
@@ -32,24 +32,29 @@ export async function installVerify(cwd) {
32
32
  const manager = detectPackageManager(cwd);
33
33
  if (!existsSync(join(cwd, 'package.json')))
34
34
  await writeFile(join(cwd, 'package.json'), '{\n "private": true,\n "type": "module"\n}\n', { flag: 'wx' });
35
+ const shell = process.platform === 'win32';
35
36
  const args = manager === 'npm'
36
37
  ? ['install', '--save-dev', '--ignore-scripts']
37
- : manager === 'yarn'
38
- ? ['add', '--dev', '--mode=skip-builds']
39
- : ['add', '--dev', '--ignore-scripts'];
38
+ : manager === 'pnpm'
39
+ ? ['add', '-D', '--ignore-scripts']
40
+ : manager === 'yarn'
41
+ ? yarnAddArgs(cwd, shell)
42
+ : ['add', '--dev', '--ignore-scripts'];
40
43
  args.push(PACKAGE_NAME);
41
44
  process.stdout.write(`Installing ${PACKAGE_NAME} with ${manager}...\n`);
42
45
  await new Promise((resolve, reject) => {
43
- const env = Object.fromEntries(['PATH', 'HOME', 'USERPROFILE', 'TMPDIR', 'TEMP', 'TMP'].flatMap((key) => {
44
- const value = process.env[key];
45
- return value === undefined ? [] : [[key, value]];
46
- }));
47
- const child = spawn(manager, args, { cwd, env, stdio: 'inherit', shell: process.platform === 'win32', timeout: 180_000, killSignal: 'SIGKILL' });
46
+ const child = spawn(manager, args, { cwd, stdio: 'inherit', shell, timeout: 180_000, killSignal: 'SIGKILL' });
48
47
  const fail = () => reject(new VerifyError('CONFIG_INVALID', `Could not install ${PACKAGE_NAME} with ${manager}. Install it manually, then retry init --no-install.`));
49
48
  child.once('error', fail);
50
49
  child.once('exit', (code) => code === 0 ? resolve() : fail());
51
50
  });
52
51
  }
52
+ function yarnAddArgs(cwd, shell) {
53
+ const major = Number.parseInt(spawnSync('yarn', ['--version'], { cwd, shell, encoding: 'utf8', timeout: 60_000 }).stdout?.trim() ?? '', 10);
54
+ if (major === 1)
55
+ return ['add', '--dev', '--ignore-scripts'];
56
+ return major >= 3 ? ['add', '--dev', '--mode=skip-build'] : ['add', '--dev'];
57
+ }
53
58
  function detectPackageManager(cwd) {
54
59
  const lockfiles = [['pnpm-lock.yaml', 'pnpm'], ['yarn.lock', 'yarn'], ['bun.lock', 'bun'], ['bun.lockb', 'bun'], ['package-lock.json', 'npm'], ['npm-shrinkwrap.json', 'npm']];
55
60
  for (let directory = cwd;; directory = dirname(directory)) {
package/dist/cli/login.js CHANGED
@@ -53,7 +53,7 @@ export async function signIn(options = {}) {
53
53
  continue;
54
54
  }
55
55
  if (credential.state === 'denied')
56
- throw new VerifyError('AUTH_REJECTED', 'The Revyl login request was denied. Run your script again to sign in, or use revyl auth login.');
56
+ throw new VerifyError('AUTH_REJECTED', 'The Revyl login request was denied. Run your script again to sign in, or run npx @revyl/verify login.');
57
57
  if (credential.state !== 'approved')
58
58
  throw invalidResponse();
59
59
  const token = requiredString(credential, 'api_key_token');
@@ -80,11 +80,11 @@ export async function signIn(options = {}) {
80
80
  await writePrivateState('credentials.json', stored);
81
81
  return { token, source: 'revyl-cli-login' };
82
82
  }
83
- throw new VerifyError('AUTH_MISSING', 'The Revyl login request expired. Run your script again to sign in, or use revyl auth login.');
83
+ throw new VerifyError('AUTH_MISSING', 'The Revyl login request expired. Run your script again to sign in, or run npx @revyl/verify login.');
84
84
  }
85
85
  catch (error) {
86
86
  if (signal.aborted)
87
- throw new VerifyError('AUTH_MISSING', 'Revyl sign-in was cancelled or timed out. Run your script again to sign in, or use revyl auth login.');
87
+ throw new VerifyError('AUTH_MISSING', 'Revyl sign-in was cancelled or timed out. Run your script again to sign in, or run npx @revyl/verify login.');
88
88
  throw error;
89
89
  }
90
90
  finally {
@@ -98,12 +98,12 @@ async function post(backendUrl, path, body, signal) {
98
98
  response = await fetch(new URL(path, backendUrl), { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify(body), redirect: 'error', signal: AbortSignal.any([signal, AbortSignal.timeout(15_000)]) });
99
99
  }
100
100
  catch {
101
- throw new VerifyError('AUTH_MISSING', 'Could not reach Revyl to sign in. Run your script again, use revyl auth login, or set REVYL_API_KEY.');
101
+ throw new VerifyError('AUTH_MISSING', 'Could not reach Revyl to sign in. Run your script again, run npx @revyl/verify login, or set REVYL_API_KEY.');
102
102
  }
103
103
  if (response.status === 404)
104
- throw new VerifyError('AUTH_MISSING', 'The Revyl login request is unavailable or expired. Run your script again, or use revyl auth login.');
104
+ throw new VerifyError('AUTH_MISSING', 'The Revyl login request is unavailable or expired. Run your script again, or run npx @revyl/verify login.');
105
105
  if (!response.ok)
106
- throw new VerifyError('AUTH_REJECTED', `Revyl sign-in failed (HTTP ${response.status}). Run your script again, use revyl auth login, or set REVYL_API_KEY.`);
106
+ throw new VerifyError('AUTH_REJECTED', `Revyl sign-in failed (HTTP ${response.status}). Run your script again, run npx @revyl/verify login, or set REVYL_API_KEY.`);
107
107
  let value;
108
108
  try {
109
109
  value = await response.json();
@@ -130,7 +130,7 @@ function optionalString(value, key) {
130
130
  return text || undefined;
131
131
  }
132
132
  function invalidResponse() {
133
- return new VerifyError('AUTH_REJECTED', 'Revyl returned an invalid sign-in response. Run your script again, use revyl auth login, or set REVYL_API_KEY.');
133
+ return new VerifyError('AUTH_REJECTED', 'Revyl returned an invalid sign-in response. Run your script again, run npx @revyl/verify login, or set REVYL_API_KEY.');
134
134
  }
135
135
  function tokenExpiry(token) {
136
136
  try {
package/dist/cli/main.js CHANGED
@@ -1,25 +1,48 @@
1
1
  #!/usr/bin/env node
2
+ import { rm } from 'node:fs/promises';
2
3
  import { createInterface } from 'node:readline/promises';
3
4
  import { parseArgs } from 'node:util';
4
5
  import { RevylClient } from '../api/client.js';
5
- import { missingCredentialsMessage, resolveCredentials } from '../auth.js';
6
+ import { missingCredentialsMessage, resolveCredentials, revylCredentialsPath } from '../auth.js';
6
7
  import { VerifyError, errorMessage, isVerifyError } from '../errors.js';
7
8
  import { PACKAGE_NAME, VERSION } from '../version.js';
8
- import { initProject, validateInitAnswers } from './init.js';
9
+ import { initProject, validateInitAnswers, webAppIdMissingMessage } from './init.js';
9
10
  import { installVerify, packageHasVerify } from './install.js';
10
11
  import { signIn } from './login.js';
12
+ const INIT_USAGE = `npx ${PACKAGE_NAME} init [--platform ios|android|web|mac] [--app-id <id>] [--url <https-url>] [--force] [--yes] [--no-install]`;
13
+ const DOCS = `https://github.com/RevylAI/verify/tree/v${VERSION}/packages/verify/docs`;
11
14
  const HELP = `verify ${VERSION}: open Revyl cloud browsers, devices, and computers from your code
12
15
 
13
16
  Usage
14
- npx ${PACKAGE_NAME} init [--platform web|ios|android|mac] [--app-id <id>] [--url <https-url>] [--force] [--yes] [--no-install]
15
- Install Verify, write verify.config.ts and an example script, then check authentication
17
+ ${INIT_USAGE}
18
+ Install Verify, write verify.config.ts and an example script, and sign in
19
+ npx ${PACKAGE_NAME} login
20
+ Sign in to Revyl through your browser
21
+ npx ${PACKAGE_NAME} logout
22
+ Remove the saved Revyl login from this computer
16
23
  npx ${PACKAGE_NAME} whoami
17
24
  Check which Revyl account your credentials belong to
18
25
 
19
26
  Authentication
20
- REVYL_API_KEY, or a login from the Revyl CLI ('revyl auth login').
27
+ In a terminal, init, login, and your first open() sign you in through your browser and save the
28
+ login to ~/.revyl/credentials.json, which the Revyl CLI shares. In CI, set REVYL_API_KEY instead.
21
29
 
22
- Docs: https://github.com/RevylAI/verify/tree/v${VERSION}/packages/verify/docs
30
+ Docs: ${DOCS}
31
+ `;
32
+ const INIT_HELP = `Usage: ${INIT_USAGE}
33
+
34
+ Writes verify.config.ts and verify/example.mts, installs Verify when the project lacks it, and signs
35
+ you in through your browser when no credentials are saved.
36
+
37
+ --platform ios, android, web, or mac. Without it, init asks. iOS and Android without --app-id
38
+ open the device's built-in Settings app, so the example runs before you upload anything.
39
+ --app-id The Revyl app to open. Required for web; create a Web app at https://app.revyl.ai first.
40
+ --url The public HTTPS URL a web target opens (default https://example.com).
41
+ --force Overwrite existing files.
42
+ --yes Install Verify without asking.
43
+ --no-install Skip installing Verify.
44
+
45
+ Docs: ${DOCS}/getting-started.md
23
46
  `;
24
47
  const USAGE_CODES = new Set(['INVALID_ARGUMENT', 'AUTH_MISSING', 'AUTH_REJECTED']);
25
48
  export async function main(argv) {
@@ -37,8 +60,18 @@ export async function main(argv) {
37
60
  return 0;
38
61
  case 'init':
39
62
  return initCommand(rest);
40
- case 'whoami':
41
- return whoamiCommand();
63
+ case 'login':
64
+ case 'logout':
65
+ case 'whoami': {
66
+ const { values } = parseArgs({ args: rest, options: { help: { type: 'boolean', short: 'h' } } });
67
+ if (values.help) {
68
+ process.stdout.write(HELP);
69
+ return 0;
70
+ }
71
+ if (command === 'login')
72
+ return signInAndCheck(Boolean(process.stdin.isTTY) && !process.env.CI);
73
+ return command === 'logout' ? logoutCommand() : whoamiCommand();
74
+ }
42
75
  default:
43
76
  process.stderr.write(`Unknown command: ${command}\n\n${HELP}`);
44
77
  return 2;
@@ -54,8 +87,13 @@ async function initCommand(args) {
54
87
  force: { type: 'boolean' },
55
88
  yes: { type: 'boolean' },
56
89
  'no-install': { type: 'boolean' },
90
+ help: { type: 'boolean', short: 'h' },
57
91
  },
58
92
  });
93
+ if (values.help) {
94
+ process.stdout.write(INIT_HELP);
95
+ return 0;
96
+ }
59
97
  const interactive = Boolean(process.stdin.isTTY) && !process.env.CI;
60
98
  let answers;
61
99
  if (values.platform) {
@@ -86,44 +124,46 @@ async function initCommand(args) {
86
124
  answers ??= await askInitQuestions(values['app-id'], values.url);
87
125
  const result = await initProject(process.cwd(), answers, { force: values.force ?? false });
88
126
  process.stdout.write(`Created ${result.created.join(' and ')}${result.gitignoreUpdated ? ', and added .verify/ to .gitignore' : ''}.\n`);
127
+ await signInAndCheck(interactive);
128
+ const steps = answers.platform === 'mac'
129
+ ? ['Mac computers are early access. Ask support@revyl.ai about access.', 'Once your organization has Mac access, run: node verify/example.mts']
130
+ : answers.appId
131
+ ? ['Run: node verify/example.mts']
132
+ : ['Run: node verify/example.mts', 'To test your own app, put its Revyl app id in verify.config.ts in place of the Settings bundle id.'];
133
+ process.stdout.write([
134
+ 'Next:',
135
+ ...steps.map((step, index) => ` ${index + 1}. ${step}`),
136
+ '',
137
+ ].join('\n'));
138
+ return 0;
139
+ }
140
+ async function signInAndCheck(interactive) {
89
141
  const credentials = await resolveCredentials();
90
142
  if (!credentials) {
91
- if (interactive) {
92
- await signIn();
93
- }
94
- else {
143
+ if (!interactive)
95
144
  throw new VerifyError('AUTH_MISSING', `${missingCredentialsMessage()} In non-interactive environments, set the REVYL_API_KEY secret.`);
96
- }
145
+ await signIn();
97
146
  }
98
147
  try {
99
- await whoamiCommand();
148
+ return await whoamiCommand();
100
149
  }
101
150
  catch (error) {
102
151
  if (!interactive || !isVerifyError(error) || error.code !== 'AUTH_REJECTED')
103
152
  throw error;
104
153
  await signIn({ force: true });
105
- await whoamiCommand();
154
+ return whoamiCommand();
106
155
  }
107
- const steps = [
108
- 'Configure the generated verify.config.ts if needed',
109
- ...(answers.platform === 'mac' ? ['Mac computers are early access. Ask support@revyl.ai about access.'] : []),
110
- ...(answers.platform === 'mac' || answers.appId ? [] : ['Put your Revyl app id in verify.config.ts (or set REVYL_APP_ID)']),
111
- 'node verify/example.ts',
112
- ];
113
- process.stdout.write([
114
- 'Next:',
115
- ...steps.map((step, index) => ` ${index + 1}. ${step}`),
116
- '',
117
- ].join('\n'));
118
- return 0;
119
156
  }
120
157
  async function askInitQuestions(appId, url) {
121
158
  const readline = createInterface({ input: process.stdin, output: process.stdout });
122
159
  try {
123
- const platform = ((await readline.question('What do you want to open? [web/ios/android/mac] (web): ')).trim().toLowerCase() || 'web');
160
+ const platform = ((await readline.question('What do you want to open? [ios/android/web/mac] (ios): ')).trim().toLowerCase() || 'ios');
124
161
  if (platform === 'mac')
125
162
  return validateInitAnswers({ platform, ...(appId ? { appId } : {}), ...(url ? { url } : {}) });
126
- const id = appId ?? ((await readline.question('Revyl app id (Enter to fill in later): ')).trim() || undefined);
163
+ const question = platform === 'web' ? 'Revyl Web app id (Enter if you have none yet): ' : 'Revyl app id (Enter to use the built-in Settings app): ';
164
+ const id = appId ?? ((await readline.question(question)).trim() || undefined);
165
+ if (platform === 'web' && !id)
166
+ throw new VerifyError('INVALID_ARGUMENT', webAppIdMissingMessage(url));
127
167
  const webUrl = platform === 'web' ? (url ?? ((await readline.question('HTTPS URL to open (Enter for https://example.com): ')).trim() || undefined)) : undefined;
128
168
  return validateInitAnswers({ platform, ...(id ? { appId: id } : {}), ...(webUrl ? { url: webUrl } : {}) });
129
169
  }
@@ -141,7 +181,27 @@ async function whoamiCommand() {
141
181
  process.stdout.write(`Signed in to Revyl as ${name}${org ? ` in ${org}` : ''} (credentials from ${credentials.source}).\n`);
142
182
  return 0;
143
183
  }
184
+ async function logoutCommand() {
185
+ try {
186
+ await rm(revylCredentialsPath());
187
+ }
188
+ catch (error) {
189
+ if (error.code !== 'ENOENT')
190
+ throw new VerifyError('AUTH_MISSING', `Could not remove ~/.revyl/credentials.json: ${errorMessage(error)}`);
191
+ process.stdout.write('No saved Revyl login on this computer.\n');
192
+ return 0;
193
+ }
194
+ process.stdout.write('Signed out: removed ~/.revyl/credentials.json, which the Revyl CLI shares. The API key it held still works until you delete it in Revyl (Settings → API keys).\n');
195
+ if (process.env.REVYL_API_KEY?.trim())
196
+ process.stdout.write('REVYL_API_KEY is still set in this environment, so Verify keeps using it.\n');
197
+ return 0;
198
+ }
144
199
  export async function runCli(argv) {
200
+ const [major = 0, minor = 0] = process.versions.node.split('.').map(Number);
201
+ if (major < 22 || (major === 22 && minor < 18)) {
202
+ process.stderr.write(`Verify needs Node.js 22.18 or later; this is Node.js ${process.version}.\n`);
203
+ return 1;
204
+ }
145
205
  try {
146
206
  return await main(argv);
147
207
  }
@@ -27,6 +27,15 @@ export declare class ComputerSession implements Session {
27
27
  open(target: HostedTargetConfig): Promise<OpenedSession>;
28
28
  report(): Promise<Record<string, unknown>>;
29
29
  navigate(): Promise<never>;
30
+ tap(): Promise<never>;
31
+ type(): Promise<never>;
32
+ swipe(): Promise<never>;
33
+ pressKey(): Promise<never>;
34
+ back(): Promise<never>;
35
+ openUrl(): Promise<never>;
36
+ launchApp(): Promise<never>;
37
+ locate(): Promise<never>;
38
+ screenSize(): Promise<never>;
30
39
  get expiresAt(): Date | undefined;
31
40
  recording(): Promise<ComputerRecording | null>;
32
41
  instruction(): Promise<never>;
@@ -123,6 +123,33 @@ export class ComputerSession {
123
123
  navigate() {
124
124
  return this.unavailable('navigate');
125
125
  }
126
+ tap() {
127
+ return this.unavailable('tap');
128
+ }
129
+ type() {
130
+ return this.unavailable('type');
131
+ }
132
+ swipe() {
133
+ return this.unavailable('swipe');
134
+ }
135
+ pressKey() {
136
+ return this.unavailable('pressKey');
137
+ }
138
+ back() {
139
+ return this.unavailable('back');
140
+ }
141
+ openUrl() {
142
+ return this.unavailable('openUrl');
143
+ }
144
+ launchApp() {
145
+ return this.unavailable('launchApp');
146
+ }
147
+ locate() {
148
+ return this.unavailable('locate');
149
+ }
150
+ screenSize() {
151
+ return this.unavailable('screenSize');
152
+ }
126
153
  get expiresAt() {
127
154
  return this.expiresAtMs === undefined ? undefined : new Date(this.expiresAtMs);
128
155
  }
package/dist/errors.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- export type VerifyErrorCode = 'INVALID_ARGUMENT' | 'CONFIG_NOT_FOUND' | 'CONFIG_INVALID' | 'AUTH_MISSING' | 'COMPUTER_ID_MISSING' | 'AUTH_REJECTED' | 'EDGE_BLOCKED' | 'API_ERROR' | 'SESSION_START_FAILED' | 'SESSION_ENDED' | 'SESSION_BUSY' | 'COMPUTER_LIMIT' | 'NOT_AVAILABLE_ON_PLATFORM' | 'ACTION_FAILED' | 'ACTION_UNCERTAIN' | 'INSTRUCTION_FAILED' | 'VALIDATION_FAILED' | 'STEP_FAILED' | 'COMMAND_FAILED' | 'NOT_FOUND' | 'ACCESS_DENIED' | 'TIMEOUT';
2
- export type RefusalReason = 'web_runtime_unavailable' | 'web_runtime_not_configured' | 'computer_limit' | 'runtime_unavailable' | 'computer_runtime_unavailable' | 'computer_at_capacity' | 'computer_not_running' | 'computer_cannot_host_platform' | 'computer_not_found' | 'web_build_required' | 'web_build_not_supported' | 'ios_build_required' | 'ios_build_not_supported' | 'android_build_required' | 'android_build_not_supported' | 'web_session_conflict' | 'ios_session_conflict' | 'android_session_conflict' | 'not_available_on_platform' | 'request_timeout' | 'protocol_error';
1
+ export type VerifyErrorCode = 'INVALID_ARGUMENT' | 'CONFIG_NOT_FOUND' | 'CONFIG_INVALID' | 'AUTH_MISSING' | 'COMPUTER_ID_MISSING' | 'AUTH_REJECTED' | 'EDGE_BLOCKED' | 'API_ERROR' | 'SESSION_START_FAILED' | 'SESSION_ENDED' | 'SESSION_BUSY' | 'COMPUTER_LIMIT' | 'NOT_AVAILABLE_ON_PLATFORM' | 'ACTION_FAILED' | 'ACTION_UNCERTAIN' | 'INSTRUCTION_FAILED' | 'VALIDATION_FAILED' | 'STEP_FAILED' | 'COMMAND_FAILED' | 'NOT_FOUND' | 'ACCESS_DENIED' | 'MODEL_ERROR' | 'MODEL_KEY_MISSING' | 'TIMEOUT';
2
+ export type RefusalReason = 'web_runtime_unavailable' | 'web_runtime_not_configured' | 'computer_limit' | 'runtime_unavailable' | 'computer_runtime_unavailable' | 'computer_at_capacity' | 'computer_platform_at_capacity' | 'computer_not_running' | 'computer_cannot_host_platform' | 'computer_not_found' | 'web_build_required' | 'web_build_not_supported' | 'ios_build_required' | 'ios_build_not_supported' | 'android_build_required' | 'android_build_not_supported' | 'web_session_conflict' | 'ios_session_conflict' | 'android_session_conflict' | 'not_available_on_platform' | 'request_timeout' | 'protocol_error';
3
3
  export declare class VerifyError extends Error {
4
4
  readonly code: VerifyErrorCode;
5
5
  readonly reason?: RefusalReason;
package/dist/index.d.ts CHANGED
@@ -6,3 +6,8 @@ export type { HostedTargetConfig, MobileAppConfig, MobileTargetConfig, ComputerT
6
6
  export type { Platform, ComputerPlatform, MobilePlatform } from './api/types.js';
7
7
  export { VerifyError, isVerifyError } from './errors.js';
8
8
  export type { RefusalReason, VerifyErrorCode } from './errors.js';
9
+ export { localAgent } from './agent/loop.js';
10
+ export type { Grounding, LocalAgent, LocalAgentOptions, Location, ModelRun, ModelTurn, Point, SwipeDirection, Target } from './agent/loop.js';
11
+ export type { CustomModel, ModelOption } from './agent/providers.js';
12
+ export { anthropic, openaiCompatible } from './agent/model.js';
13
+ export type { AnthropicOptions, JsonSchema, ModelAdapter, ModelReply, ModelRequest, ModelTool, ModelToolCall, ModelUsage, OpenAICompatibleOptions } from './agent/model.js';
package/dist/index.js CHANGED
@@ -1,3 +1,5 @@
1
1
  export { attach, currentComputer, open } from './open.js';
2
2
  export { defineConfig } from './config.js';
3
3
  export { VerifyError, isVerifyError } from './errors.js';
4
+ export { localAgent } from './agent/loop.js';
5
+ export { anthropic, openaiCompatible } from './agent/model.js';
package/dist/open.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import { type TargetConfig } from './config.js';
2
2
  import { type OpenedSession } from './session.js';
3
3
  import { ComputerSession } from './computer-session.js';
4
+ import type { LocalAgent } from './agent/loop.js';
5
+ import { type ModelOption } from './agent/providers.js';
4
6
  export interface OpenOptions {
5
7
  /** The config file to read target names from. Defaults to the nearest verify.config.ts at or above the current folder. */
6
8
  config?: string;
@@ -8,6 +10,10 @@ export interface OpenOptions {
8
10
  log?: false | ((line: string) => void);
9
11
  backendUrl?: string;
10
12
  appUrl?: string;
13
+ /** Runs every instruction and validation on your model, such as 'anthropic/claude-sonnet-5-5', instead of Revyl's agent. Browsers and devices only. */
14
+ model?: ModelOption;
15
+ /** Runs every instruction and validation on a custom agent from localAgent(). Pass model or agent, not both. */
16
+ agent?: LocalAgent;
11
17
  }
12
18
  export interface AttachOptions extends Pick<OpenOptions, 'log' | 'backendUrl' | 'appUrl'> {
13
19
  stopOnClose?: boolean;
package/dist/open.js CHANGED
@@ -5,6 +5,7 @@ import { DEFAULT_STEP_TIMEOUT_MS, loadConfig, validateTarget } from './config.js
5
5
  import { VerifyError } from './errors.js';
6
6
  import { DeviceSession } from './session.js';
7
7
  import { ComputerSession } from './computer-session.js';
8
+ import { chooseAgent } from './agent/providers.js';
8
9
  const signIns = new Map();
9
10
  async function credentialsForUse(options) {
10
11
  const credentials = await resolveCredentials();
@@ -53,10 +54,14 @@ export async function open(target, options = {}) {
53
54
  else {
54
55
  resolved = validateTarget(target);
55
56
  }
57
+ const agent = chooseAgent(options, 'open()');
58
+ if (agent && (resolved.platform === 'mac' || resolved.platform === 'linux')) {
59
+ throw new VerifyError('NOT_AVAILABLE_ON_PLATFORM', 'Your own model runs steps on browsers and devices, not on computers.');
60
+ }
56
61
  const credentials = await credentialsForUse(options);
57
62
  const log = options.log === false ? undefined : (options.log ?? ((line) => void process.stderr.write(`[verify] ${line}\n`)));
58
63
  const client = new RevylClient({ token: credentials.token, backendUrl: options.backendUrl });
59
64
  if (resolved.platform === 'mac' || resolved.platform === 'linux')
60
65
  return ComputerSession.start(client, resolved, { stepTimeout, log, appUrl: options.appUrl });
61
- return DeviceSession.start(client, resolved, { stepTimeout, log, appUrl: options.appUrl });
66
+ return DeviceSession.start(client, resolved, { stepTimeout, log, appUrl: options.appUrl, ...(agent ? { agent } : {}) });
62
67
  }