@revyl/verify 0.0.0-stage → 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/CHANGELOG.md +35 -0
- package/LICENSE +201 -0
- package/README.md +137 -2
- package/SECURITY.md +13 -0
- package/dist/api/client.d.ts +63 -0
- package/dist/api/client.js +201 -0
- package/dist/api/types.d.ts +222 -0
- package/dist/api/types.js +1 -0
- package/dist/auth.d.ts +17 -0
- package/dist/auth.js +65 -0
- package/dist/cleanup.d.ts +13 -0
- package/dist/cleanup.js +72 -0
- package/dist/cli/init.d.ts +14 -0
- package/dist/cli/init.js +115 -0
- package/dist/cli/main.d.ts +3 -0
- package/dist/cli/main.js +127 -0
- package/dist/computer-session.d.ts +43 -0
- package/dist/computer-session.js +418 -0
- package/dist/config.d.ts +88 -0
- package/dist/config.js +212 -0
- package/dist/errors.d.ts +9 -0
- package/dist/errors.js +14 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +3 -0
- package/dist/open.d.ts +13 -0
- package/dist/open.js +33 -0
- package/dist/screen-readiness.d.ts +1 -0
- package/dist/screen-readiness.js +27 -0
- package/dist/session.d.ts +153 -0
- package/dist/session.js +443 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +6 -0
- package/docs/README.md +19 -0
- package/docs/agents.md +43 -0
- package/docs/api.md +120 -0
- package/docs/ci.md +70 -0
- package/docs/computers.md +83 -0
- package/docs/configuration.md +92 -0
- package/docs/end-to-end.md +105 -0
- package/docs/errors.md +39 -0
- package/docs/getting-started.md +88 -0
- package/docs/linux-computers-contract.md +53 -0
- package/docs/llms.txt +19 -0
- package/docs/mobile.md +49 -0
- package/docs/web.md +37 -0
- package/package.json +71 -4
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
export type Platform = 'ios' | 'android' | 'web' | 'mac' | 'linux';
|
|
2
|
+
export type MobilePlatform = 'ios' | 'android';
|
|
3
|
+
export type ComputerPlatform = 'mac' | 'linux';
|
|
4
|
+
export interface WebViewportRequest {
|
|
5
|
+
width?: number;
|
|
6
|
+
height?: number;
|
|
7
|
+
device_scale_factor?: number;
|
|
8
|
+
}
|
|
9
|
+
export interface WebSignInRequest {
|
|
10
|
+
path: string;
|
|
11
|
+
secret_name: string;
|
|
12
|
+
persona?: string;
|
|
13
|
+
}
|
|
14
|
+
export interface StartDeviceRequest {
|
|
15
|
+
platform: MobilePlatform | 'web';
|
|
16
|
+
is_simulation?: boolean;
|
|
17
|
+
session_id?: string;
|
|
18
|
+
app_id?: string;
|
|
19
|
+
build_id?: string;
|
|
20
|
+
app_url?: string;
|
|
21
|
+
device_model?: string;
|
|
22
|
+
os_version?: string;
|
|
23
|
+
idle_timeout_seconds?: number;
|
|
24
|
+
web?: {
|
|
25
|
+
viewport?: WebViewportRequest;
|
|
26
|
+
sign_in?: WebSignInRequest;
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
export interface StartDeviceResponse {
|
|
30
|
+
status: string;
|
|
31
|
+
platform: string;
|
|
32
|
+
session_id?: string | null;
|
|
33
|
+
workflow_run_id?: string | null;
|
|
34
|
+
error?: string | null;
|
|
35
|
+
message?: string | null;
|
|
36
|
+
trace_id?: string | null;
|
|
37
|
+
}
|
|
38
|
+
export type SessionStatus = 'queued' | 'starting' | 'running' | 'verifying' | 'stopping' | 'validating' | 'completed' | 'failed' | 'cancelled' | 'timeout';
|
|
39
|
+
export interface DeviceSessionDetail {
|
|
40
|
+
id: string;
|
|
41
|
+
status: SessionStatus | string;
|
|
42
|
+
platform?: string | null;
|
|
43
|
+
workflow_run_id?: string | null;
|
|
44
|
+
viewer_kind?: string | null;
|
|
45
|
+
error_message?: string | null;
|
|
46
|
+
screen_width?: number | null;
|
|
47
|
+
screen_height?: number | null;
|
|
48
|
+
}
|
|
49
|
+
export interface WorkerConnection {
|
|
50
|
+
status: 'ready' | 'not_ready' | 'cancelled' | 'stopped' | 'failed';
|
|
51
|
+
workflow_run_id: string;
|
|
52
|
+
worker_ws_url?: string | null;
|
|
53
|
+
message?: string | null;
|
|
54
|
+
}
|
|
55
|
+
export interface WorkerHealth {
|
|
56
|
+
status?: string;
|
|
57
|
+
device_connected?: boolean;
|
|
58
|
+
platform?: string;
|
|
59
|
+
screen_width?: number;
|
|
60
|
+
screen_height?: number;
|
|
61
|
+
}
|
|
62
|
+
export interface DeviceSessionStopResponse {
|
|
63
|
+
success: boolean;
|
|
64
|
+
request_accepted: boolean;
|
|
65
|
+
session_id: string;
|
|
66
|
+
status: string;
|
|
67
|
+
message: string;
|
|
68
|
+
}
|
|
69
|
+
export interface AppResponse {
|
|
70
|
+
id: string;
|
|
71
|
+
name?: string | null;
|
|
72
|
+
platform?: string | null;
|
|
73
|
+
default_branch_id?: string | null;
|
|
74
|
+
}
|
|
75
|
+
export interface BuildResponse {
|
|
76
|
+
id?: string | null;
|
|
77
|
+
app_id?: string | null;
|
|
78
|
+
artifact_kind?: string | null;
|
|
79
|
+
version?: string | null;
|
|
80
|
+
}
|
|
81
|
+
export interface BuildPage {
|
|
82
|
+
items: BuildResponse[];
|
|
83
|
+
}
|
|
84
|
+
export interface WhoAmI {
|
|
85
|
+
user_id?: string | null;
|
|
86
|
+
org_id?: string | null;
|
|
87
|
+
org_name?: string | null;
|
|
88
|
+
email?: string | null;
|
|
89
|
+
}
|
|
90
|
+
export interface WorkerActionResult {
|
|
91
|
+
success: boolean;
|
|
92
|
+
action?: string;
|
|
93
|
+
error?: string | null;
|
|
94
|
+
}
|
|
95
|
+
export type LiveStepType = 'instruction' | 'validation';
|
|
96
|
+
export interface LiveStepOutput {
|
|
97
|
+
status?: 'success' | 'warning' | 'failed' | null;
|
|
98
|
+
status_reason?: string | null;
|
|
99
|
+
/** The verdict of a validation step. Revyl reports a validation that ran as status 'success', whatever its verdict. */
|
|
100
|
+
validation_result?: boolean | null;
|
|
101
|
+
reasoning?: string | null;
|
|
102
|
+
}
|
|
103
|
+
export interface LiveStepResult {
|
|
104
|
+
success: boolean;
|
|
105
|
+
step_type: LiveStepType | string;
|
|
106
|
+
step_id: string;
|
|
107
|
+
workflow_run_id?: string | null;
|
|
108
|
+
session_id?: string | null;
|
|
109
|
+
step_output: LiveStepOutput;
|
|
110
|
+
}
|
|
111
|
+
/** HTTP 202 from execute_step: the step runs asynchronously; poll step_status/{step_id}. */
|
|
112
|
+
export interface StepAccepted {
|
|
113
|
+
step_id: string;
|
|
114
|
+
status: 'accepted';
|
|
115
|
+
}
|
|
116
|
+
export interface StepStatus {
|
|
117
|
+
step_id: string;
|
|
118
|
+
status: 'running' | 'completed' | 'failed' | 'cancelled';
|
|
119
|
+
/** Present once the status is terminal. */
|
|
120
|
+
result?: LiveStepResult | null;
|
|
121
|
+
}
|
|
122
|
+
export interface ComputerRunCreate {
|
|
123
|
+
computer_session_id: string;
|
|
124
|
+
os: 'macos' | 'linux';
|
|
125
|
+
}
|
|
126
|
+
export type ComputerState = 'starting' | 'active' | 'closing' | 'stopped' | 'failed' | 'owner_lost';
|
|
127
|
+
/** The fields Verify reads from a ComputerReport. */
|
|
128
|
+
export interface ComputerReport {
|
|
129
|
+
computer_session_id: string;
|
|
130
|
+
state: ComputerState;
|
|
131
|
+
release: 'pending' | 'confirmed' | 'unknown';
|
|
132
|
+
failure_reason?: string | null;
|
|
133
|
+
expires_at?: string | null;
|
|
134
|
+
}
|
|
135
|
+
export interface ComputerRecordingResponse {
|
|
136
|
+
url: string;
|
|
137
|
+
duration_seconds?: number | null;
|
|
138
|
+
started_at?: string | null;
|
|
139
|
+
}
|
|
140
|
+
export interface ComputerProcessHandle {
|
|
141
|
+
computer_session_id: string;
|
|
142
|
+
handle_id: string;
|
|
143
|
+
}
|
|
144
|
+
/** Binary fields are base64. */
|
|
145
|
+
export type ComputerCommand = {
|
|
146
|
+
timeout_ms: number;
|
|
147
|
+
} & ({
|
|
148
|
+
kind: 'exec';
|
|
149
|
+
argv: string[];
|
|
150
|
+
stdin?: string;
|
|
151
|
+
} | {
|
|
152
|
+
kind: 'file_read';
|
|
153
|
+
path: string;
|
|
154
|
+
} | {
|
|
155
|
+
kind: 'file_write';
|
|
156
|
+
path: string;
|
|
157
|
+
data: string;
|
|
158
|
+
} | {
|
|
159
|
+
kind: 'file_stat';
|
|
160
|
+
path: string;
|
|
161
|
+
} | {
|
|
162
|
+
kind: 'process_list';
|
|
163
|
+
} | {
|
|
164
|
+
kind: 'process_terminate';
|
|
165
|
+
handle: ComputerProcessHandle;
|
|
166
|
+
});
|
|
167
|
+
export interface ComputerExecResult {
|
|
168
|
+
kind: 'exec';
|
|
169
|
+
exit_code: number;
|
|
170
|
+
stdout?: string;
|
|
171
|
+
stderr?: string;
|
|
172
|
+
}
|
|
173
|
+
export type ComputerResult = ComputerExecResult | {
|
|
174
|
+
kind: 'file_read';
|
|
175
|
+
data: string;
|
|
176
|
+
} | {
|
|
177
|
+
kind: 'file_write';
|
|
178
|
+
byte_count: number;
|
|
179
|
+
} | {
|
|
180
|
+
kind: 'file_stat';
|
|
181
|
+
file_kind: 'file' | 'directory';
|
|
182
|
+
size_bytes: number;
|
|
183
|
+
} | {
|
|
184
|
+
kind: 'process_list';
|
|
185
|
+
processes: Array<{
|
|
186
|
+
handle: ComputerProcessHandle;
|
|
187
|
+
state: 'running' | 'exited';
|
|
188
|
+
exit_code?: number | null;
|
|
189
|
+
}>;
|
|
190
|
+
} | {
|
|
191
|
+
kind: 'process_terminate';
|
|
192
|
+
termination: 'confirmed';
|
|
193
|
+
};
|
|
194
|
+
export type ComputerExecution = {
|
|
195
|
+
outcome: 'succeeded';
|
|
196
|
+
result: ComputerResult;
|
|
197
|
+
} | {
|
|
198
|
+
outcome: 'failed';
|
|
199
|
+
error_code: 'not_found' | 'access_denied' | 'exec_nonzero';
|
|
200
|
+
result?: ComputerExecResult | null;
|
|
201
|
+
} | {
|
|
202
|
+
outcome: 'timed_out';
|
|
203
|
+
error_code: 'timed_out';
|
|
204
|
+
termination: 'confirmed' | 'unknown';
|
|
205
|
+
observed_exit_code?: number | null;
|
|
206
|
+
} | {
|
|
207
|
+
outcome: 'unknown';
|
|
208
|
+
error_code: string;
|
|
209
|
+
termination: 'confirmed' | 'unknown';
|
|
210
|
+
};
|
|
211
|
+
export interface ComputerCommandResponse {
|
|
212
|
+
computer_session_id: string;
|
|
213
|
+
operation_id: string;
|
|
214
|
+
execution: ComputerExecution;
|
|
215
|
+
evidence: 'recorded' | 'pending' | 'failed';
|
|
216
|
+
}
|
|
217
|
+
export interface ComputerScreenshot {
|
|
218
|
+
width: number;
|
|
219
|
+
height: number;
|
|
220
|
+
/** Standard base64. */
|
|
221
|
+
png: string;
|
|
222
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type CredentialSource = 'REVYL_API_KEY' | 'revyl-cli-login';
|
|
2
|
+
export interface Credentials {
|
|
3
|
+
token: string;
|
|
4
|
+
source: CredentialSource;
|
|
5
|
+
}
|
|
6
|
+
export declare function revylCredentialsPath(home?: string): string;
|
|
7
|
+
/**
|
|
8
|
+
* Resolves the token Verify sends to Revyl, in the same order as the Revyl CLI:
|
|
9
|
+
* REVYL_API_KEY first, unless a browser login in ~/.revyl/credentials.json was
|
|
10
|
+
* marked to take precedence, then that login's access token or API key.
|
|
11
|
+
*/
|
|
12
|
+
export declare function resolveCredentials(options?: {
|
|
13
|
+
env?: NodeJS.ProcessEnv;
|
|
14
|
+
home?: string;
|
|
15
|
+
nowMs?: number;
|
|
16
|
+
}): Promise<Credentials | undefined>;
|
|
17
|
+
export declare function missingCredentialsMessage(): string;
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { VerifyError } from './errors.js';
|
|
5
|
+
const ACCESS_TOKEN_EXPIRY_MARGIN_MS = 60_000;
|
|
6
|
+
export function revylCredentialsPath(home = homedir()) {
|
|
7
|
+
return join(home, '.revyl', 'credentials.json');
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Resolves the token Verify sends to Revyl, in the same order as the Revyl CLI:
|
|
11
|
+
* REVYL_API_KEY first, unless a browser login in ~/.revyl/credentials.json was
|
|
12
|
+
* marked to take precedence, then that login's access token or API key.
|
|
13
|
+
*/
|
|
14
|
+
export async function resolveCredentials(options = {}) {
|
|
15
|
+
const env = options.env ?? process.env;
|
|
16
|
+
const stored = await readStoredCredentials(revylCredentialsPath(options.home));
|
|
17
|
+
const storedToken = stored ? usableStoredToken(stored, options.nowMs ?? Date.now()) : undefined;
|
|
18
|
+
const envKey = env.REVYL_API_KEY?.trim();
|
|
19
|
+
const envKeyUsable = envKey !== undefined && envKey !== '' && !envKey.startsWith('${');
|
|
20
|
+
if (envKeyUsable && !(stored?.local_auth_override && storedToken)) {
|
|
21
|
+
return { token: envKey, source: 'REVYL_API_KEY' };
|
|
22
|
+
}
|
|
23
|
+
if (storedToken) {
|
|
24
|
+
return { token: storedToken, source: 'revyl-cli-login' };
|
|
25
|
+
}
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
export function missingCredentialsMessage() {
|
|
29
|
+
return [
|
|
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'.",
|
|
33
|
+
].join(' ');
|
|
34
|
+
}
|
|
35
|
+
async function readStoredCredentials(path) {
|
|
36
|
+
let raw;
|
|
37
|
+
try {
|
|
38
|
+
raw = await readFile(path, 'utf8');
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
if (error.code === 'ENOENT')
|
|
42
|
+
return undefined;
|
|
43
|
+
throw new VerifyError('AUTH_MISSING', `Could not read ${path}: ${error.message}`, { cause: error });
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
const parsed = JSON.parse(raw);
|
|
47
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
|
|
48
|
+
throw new Error('not an object');
|
|
49
|
+
return parsed;
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
throw new VerifyError('AUTH_MISSING', `${path} is not valid JSON. Run 'revyl auth login' again or set REVYL_API_KEY.`, { cause: error });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function usableStoredToken(stored, nowMs) {
|
|
56
|
+
if (stored.access_token && !isExpired(stored.expires_at, nowMs))
|
|
57
|
+
return stored.access_token;
|
|
58
|
+
return stored.api_key || undefined;
|
|
59
|
+
}
|
|
60
|
+
function isExpired(expiresAt, nowMs) {
|
|
61
|
+
if (!expiresAt)
|
|
62
|
+
return false;
|
|
63
|
+
const expiresAtMs = Date.parse(expiresAt);
|
|
64
|
+
return Number.isNaN(expiresAtMs) || nowMs + ACCESS_TOKEN_EXPIRY_MARGIN_MS > expiresAtMs;
|
|
65
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
interface OpenSession {
|
|
2
|
+
readonly target: string;
|
|
3
|
+
close(): Promise<void>;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Keeps Revyl sessions from outliving the script that opened them. While a session is open, Verify
|
|
7
|
+
* closes it before the process ends: on Ctrl+C or SIGTERM, on an uncaught error, and when the script
|
|
8
|
+
* finishes without calling close(). It only handles a signal or error event nobody else is listening
|
|
9
|
+
* to, so test runners and apps that manage their own shutdown keep control of it.
|
|
10
|
+
*/
|
|
11
|
+
export declare function trackSession(session: OpenSession): void;
|
|
12
|
+
export declare function untrackSession(session: OpenSession): void;
|
|
13
|
+
export {};
|
package/dist/cleanup.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { inspect } from 'node:util';
|
|
2
|
+
import { isVerifyError } from './errors.js';
|
|
3
|
+
const CLOSE_ALL_TIMEOUT_MS = 15_000;
|
|
4
|
+
const open = new Set();
|
|
5
|
+
const installed = [];
|
|
6
|
+
let exiting = false;
|
|
7
|
+
/**
|
|
8
|
+
* Keeps Revyl sessions from outliving the script that opened them. While a session is open, Verify
|
|
9
|
+
* closes it before the process ends: on Ctrl+C or SIGTERM, on an uncaught error, and when the script
|
|
10
|
+
* finishes without calling close(). It only handles a signal or error event nobody else is listening
|
|
11
|
+
* to, so test runners and apps that manage their own shutdown keep control of it.
|
|
12
|
+
*/
|
|
13
|
+
export function trackSession(session) {
|
|
14
|
+
open.add(session);
|
|
15
|
+
if (installed.length === 0)
|
|
16
|
+
install();
|
|
17
|
+
}
|
|
18
|
+
export function untrackSession(session) {
|
|
19
|
+
open.delete(session);
|
|
20
|
+
if (open.size === 0 && !exiting)
|
|
21
|
+
uninstall();
|
|
22
|
+
}
|
|
23
|
+
function install() {
|
|
24
|
+
const listen = (event, listener, onlyIfUnhandled) => {
|
|
25
|
+
if (onlyIfUnhandled && process.listenerCount(event) > 0)
|
|
26
|
+
return;
|
|
27
|
+
process.on(event, listener);
|
|
28
|
+
installed.push([event, listener]);
|
|
29
|
+
};
|
|
30
|
+
listen('SIGINT', () => onSignal(130), true);
|
|
31
|
+
listen('SIGTERM', () => onSignal(143), true);
|
|
32
|
+
listen('uncaughtException', (error) => onFatal(error), true);
|
|
33
|
+
listen('unhandledRejection', (reason) => onFatal(reason), true);
|
|
34
|
+
listen('beforeExit', onBeforeExit, false);
|
|
35
|
+
}
|
|
36
|
+
function uninstall() {
|
|
37
|
+
for (const [event, listener] of installed.splice(0))
|
|
38
|
+
process.off(event, listener);
|
|
39
|
+
}
|
|
40
|
+
function onSignal(code) {
|
|
41
|
+
if (exiting)
|
|
42
|
+
process.exit(code);
|
|
43
|
+
process.stderr.write(`\n[verify] Closing ${describeOpen()} before exiting. Press Ctrl+C again to exit now.\n`);
|
|
44
|
+
void exitAfterClosing(code);
|
|
45
|
+
}
|
|
46
|
+
function onFatal(error) {
|
|
47
|
+
if (exiting)
|
|
48
|
+
return;
|
|
49
|
+
process.stderr.write(`${isVerifyError(error) ? `${error.code}: ${error.message}` : inspect(error)}\n`);
|
|
50
|
+
if (open.size > 0)
|
|
51
|
+
process.stderr.write(`[verify] Closing ${describeOpen()} before exiting.\n`);
|
|
52
|
+
void exitAfterClosing(1);
|
|
53
|
+
}
|
|
54
|
+
function onBeforeExit() {
|
|
55
|
+
if (open.size === 0 || exiting)
|
|
56
|
+
return;
|
|
57
|
+
process.stderr.write(`[verify] Closing ${describeOpen()} the script left open. Call session.close() when you are done with a session.\n`);
|
|
58
|
+
void closeAll();
|
|
59
|
+
}
|
|
60
|
+
async function exitAfterClosing(code) {
|
|
61
|
+
exiting = true;
|
|
62
|
+
await closeAll();
|
|
63
|
+
process.exit(code);
|
|
64
|
+
}
|
|
65
|
+
async function closeAll() {
|
|
66
|
+
const closing = Promise.allSettled([...open].map((session) => session.close()));
|
|
67
|
+
await Promise.race([closing, new Promise((resolve) => setTimeout(resolve, CLOSE_ALL_TIMEOUT_MS).unref())]);
|
|
68
|
+
}
|
|
69
|
+
function describeOpen() {
|
|
70
|
+
const names = [...open].map((session) => session.target);
|
|
71
|
+
return names.length === 1 ? `the ${names[0]} session` : `${names.length} sessions (${names.join(', ')})`;
|
|
72
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Platform } from '../api/types.js';
|
|
2
|
+
export interface InitAnswers {
|
|
3
|
+
platform: Platform;
|
|
4
|
+
appId?: string;
|
|
5
|
+
url?: string;
|
|
6
|
+
}
|
|
7
|
+
export interface InitResult {
|
|
8
|
+
created: string[];
|
|
9
|
+
gitignoreUpdated: boolean;
|
|
10
|
+
}
|
|
11
|
+
export declare function validateInitAnswers(answers: InitAnswers): InitAnswers;
|
|
12
|
+
export declare function initProject(cwd: string, answers: InitAnswers, options?: {
|
|
13
|
+
force?: boolean;
|
|
14
|
+
}): Promise<InitResult>;
|
package/dist/cli/init.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { join, relative } from 'node:path';
|
|
4
|
+
import { VerifyError } from '../errors.js';
|
|
5
|
+
import { PACKAGE_NAME } from '../version.js';
|
|
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
|
+
const CONFIG_FILE = 'verify.config.ts';
|
|
8
|
+
const EXAMPLE_SCRIPT = join('verify', 'example.ts');
|
|
9
|
+
export function validateInitAnswers(answers) {
|
|
10
|
+
if (!['web', 'ios', 'android', 'mac'].includes(answers.platform)) {
|
|
11
|
+
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.");
|
|
12
|
+
}
|
|
13
|
+
if (answers.platform === 'mac' && answers.appId !== undefined) {
|
|
14
|
+
throw new VerifyError('INVALID_ARGUMENT', '--app-id does not apply to Mac computers.');
|
|
15
|
+
}
|
|
16
|
+
if (answers.appId !== undefined && !UUID.test(answers.appId)) {
|
|
17
|
+
throw new VerifyError('INVALID_ARGUMENT', '--app-id must be a Revyl app id (a UUID).');
|
|
18
|
+
}
|
|
19
|
+
if (answers.url !== undefined) {
|
|
20
|
+
if (answers.platform !== 'web')
|
|
21
|
+
throw new VerifyError('INVALID_ARGUMENT', '--url applies to web targets only.');
|
|
22
|
+
if (!/^https:\/\/[^/?#@\s]+(\/[^?#\s]*)?$/.test(answers.url)) {
|
|
23
|
+
throw new VerifyError('INVALID_ARGUMENT', '--url must be a public HTTPS URL without credentials, query, or fragment.');
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return answers;
|
|
27
|
+
}
|
|
28
|
+
export async function initProject(cwd, answers, options = {}) {
|
|
29
|
+
validateInitAnswers(answers);
|
|
30
|
+
const files = [
|
|
31
|
+
[CONFIG_FILE, configTemplate(answers)],
|
|
32
|
+
[EXAMPLE_SCRIPT, scriptTemplate(answers.platform)],
|
|
33
|
+
];
|
|
34
|
+
const existing = files.map(([path]) => path).filter((path) => existsSync(join(cwd, path)));
|
|
35
|
+
if (existing.length > 0 && !options.force) {
|
|
36
|
+
throw new VerifyError('INVALID_ARGUMENT', `${existing.join(' and ')} already exist. Re-run with --force to overwrite.`);
|
|
37
|
+
}
|
|
38
|
+
for (const [path, content] of files) {
|
|
39
|
+
await mkdir(join(cwd, path, '..'), { recursive: true });
|
|
40
|
+
await writeFile(join(cwd, path), content);
|
|
41
|
+
}
|
|
42
|
+
return { created: files.map(([path]) => relative(cwd, join(cwd, path))), gitignoreUpdated: await ignoreOutput(cwd) };
|
|
43
|
+
}
|
|
44
|
+
async function ignoreOutput(cwd) {
|
|
45
|
+
const path = join(cwd, '.gitignore');
|
|
46
|
+
const current = existsSync(path) ? await readFile(path, 'utf8') : '';
|
|
47
|
+
if (current.split(/\r?\n/).some((line) => ['.verify', '.verify/', '/.verify', '/.verify/'].includes(line.trim())))
|
|
48
|
+
return false;
|
|
49
|
+
await appendFile(path, `${current === '' || current.endsWith('\n') ? '' : '\n'}.verify/\n`);
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
function configTemplate({ platform, appId, url }) {
|
|
53
|
+
const app = appId ? `'${appId}'` : `process.env.REVYL_APP_ID ?? 'REPLACE_WITH_YOUR_REVYL_APP_ID'`;
|
|
54
|
+
const target = platform === 'mac'
|
|
55
|
+
? ` mac: { platform: 'mac' },`
|
|
56
|
+
: platform === 'web'
|
|
57
|
+
? ` web: {
|
|
58
|
+
platform: 'web',
|
|
59
|
+
app: {
|
|
60
|
+
// The Revyl web app to open: copy its id from the app's page in Revyl.
|
|
61
|
+
appId: ${app},
|
|
62
|
+
// A public HTTPS URL, such as a preview deployment. Remove it to open the app's newest Revyl build.
|
|
63
|
+
url: '${url ?? 'https://example.com'}',
|
|
64
|
+
},
|
|
65
|
+
viewport: { width: 1280, height: 800 },
|
|
66
|
+
},`
|
|
67
|
+
: ` ${platform}: {
|
|
68
|
+
platform: '${platform}',
|
|
69
|
+
app: {
|
|
70
|
+
// The Revyl app to install: each session installs its newest build. Use buildId to pin one build.
|
|
71
|
+
appId: ${app},
|
|
72
|
+
},
|
|
73
|
+
},`;
|
|
74
|
+
return `import { defineConfig } from '${PACKAGE_NAME}';
|
|
75
|
+
|
|
76
|
+
export default defineConfig({
|
|
77
|
+
targets: {
|
|
78
|
+
${target}
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
`;
|
|
82
|
+
}
|
|
83
|
+
function scriptTemplate(platform) {
|
|
84
|
+
if (platform === 'mac') {
|
|
85
|
+
return `// Mac computers are in early access; ask support@revyl.ai to enable your organization.
|
|
86
|
+
import assert from 'node:assert/strict';
|
|
87
|
+
import { open } from '@revyl/verify';
|
|
88
|
+
|
|
89
|
+
const session = await open('mac');
|
|
90
|
+
try {
|
|
91
|
+
const result = await session.exec(['uname', '-s']);
|
|
92
|
+
assert.equal(result.stdout.trim(), 'Darwin');
|
|
93
|
+
await session.writeFile('hello.txt', 'Hello from Verify!\\n');
|
|
94
|
+
assert.equal((await session.readFile('hello.txt')).toString('utf8'), 'Hello from Verify!\\n');
|
|
95
|
+
assert.equal((await session.stat('hello.txt')).kind, 'file');
|
|
96
|
+
} finally {
|
|
97
|
+
await session.close();
|
|
98
|
+
}
|
|
99
|
+
`;
|
|
100
|
+
}
|
|
101
|
+
const steps = platform === 'web'
|
|
102
|
+
? `await session.instruction('Follow the link on the page');
|
|
103
|
+
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');
|
|
105
|
+
await session.validation('The app has opened and shows its first screen without an error');`;
|
|
106
|
+
return `import { open } from '${PACKAGE_NAME}';
|
|
107
|
+
|
|
108
|
+
// Opens the "${platform}" target from verify.config.ts on Revyl and prints a link to watch it live.
|
|
109
|
+
// A failed instruction or validation throws, so this script exits non-zero, and Verify closes the session on the way out.
|
|
110
|
+
const session = await open('${platform}');
|
|
111
|
+
${steps}
|
|
112
|
+
await session.screenshot({ path: '.verify/example.png' });
|
|
113
|
+
await session.close();
|
|
114
|
+
`;
|
|
115
|
+
}
|
package/dist/cli/main.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createInterface } from 'node:readline/promises';
|
|
3
|
+
import { parseArgs } from 'node:util';
|
|
4
|
+
import { RevylClient } from '../api/client.js';
|
|
5
|
+
import { missingCredentialsMessage, resolveCredentials } from '../auth.js';
|
|
6
|
+
import { VerifyError, errorMessage, isVerifyError } from '../errors.js';
|
|
7
|
+
import { PACKAGE_NAME, VERSION } from '../version.js';
|
|
8
|
+
import { initProject, validateInitAnswers } from './init.js';
|
|
9
|
+
const HELP = `verify ${VERSION}: open Revyl cloud browsers, devices, and computers from your code
|
|
10
|
+
|
|
11
|
+
Usage
|
|
12
|
+
npx ${PACKAGE_NAME} init [--platform web|ios|android|mac] [--app-id <id>] [--url <https-url>] [--force]
|
|
13
|
+
Write verify.config.ts and an example script, verify/example.ts
|
|
14
|
+
npx ${PACKAGE_NAME} whoami
|
|
15
|
+
Check which Revyl account your credentials belong to
|
|
16
|
+
|
|
17
|
+
Authentication
|
|
18
|
+
REVYL_API_KEY, or a login from the Revyl CLI ('revyl auth login').
|
|
19
|
+
|
|
20
|
+
Docs: https://github.com/RevylAI/verify/tree/v${VERSION}/packages/verify/docs
|
|
21
|
+
`;
|
|
22
|
+
const USAGE_CODES = new Set(['INVALID_ARGUMENT', 'AUTH_MISSING', 'AUTH_REJECTED']);
|
|
23
|
+
export async function main(argv) {
|
|
24
|
+
const [command, ...rest] = argv;
|
|
25
|
+
switch (command) {
|
|
26
|
+
case undefined:
|
|
27
|
+
case '-h':
|
|
28
|
+
case '--help':
|
|
29
|
+
case 'help':
|
|
30
|
+
process.stdout.write(HELP);
|
|
31
|
+
return 0;
|
|
32
|
+
case '-v':
|
|
33
|
+
case '--version':
|
|
34
|
+
process.stdout.write(`${VERSION}\n`);
|
|
35
|
+
return 0;
|
|
36
|
+
case 'init':
|
|
37
|
+
return initCommand(rest);
|
|
38
|
+
case 'whoami':
|
|
39
|
+
return whoamiCommand();
|
|
40
|
+
default:
|
|
41
|
+
process.stderr.write(`Unknown command: ${command}\n\n${HELP}`);
|
|
42
|
+
return 2;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
async function initCommand(args) {
|
|
46
|
+
const { values } = parseArgs({
|
|
47
|
+
args,
|
|
48
|
+
options: {
|
|
49
|
+
platform: { type: 'string', short: 'p' },
|
|
50
|
+
'app-id': { type: 'string' },
|
|
51
|
+
url: { type: 'string' },
|
|
52
|
+
force: { type: 'boolean' },
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
let answers;
|
|
56
|
+
if (values.platform) {
|
|
57
|
+
answers = validateInitAnswers({
|
|
58
|
+
platform: values.platform,
|
|
59
|
+
...(values['app-id'] ? { appId: values['app-id'] } : {}),
|
|
60
|
+
...(values.url ? { url: values.url } : {}),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
else if (process.stdin.isTTY) {
|
|
64
|
+
answers = await askInitQuestions(values['app-id'], values.url);
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
throw new VerifyError('INVALID_ARGUMENT', 'Pass --platform web, ios, android, or mac when not running in a terminal.');
|
|
68
|
+
}
|
|
69
|
+
const result = await initProject(process.cwd(), answers, { force: values.force ?? false });
|
|
70
|
+
const steps = [
|
|
71
|
+
`npm install --save-dev ${PACKAGE_NAME}`,
|
|
72
|
+
"Authenticate: export REVYL_API_KEY=<key from Revyl settings>, or run 'revyl auth login'",
|
|
73
|
+
...(answers.platform === 'mac' ? [`Mac computers are not yet available in Revyl production. Early access works only on Revyl staging for enabled organizations; use the optional REVYL_BACKEND_URL override with staging credentials. Ask support@revyl.ai for access. See https://github.com/RevylAI/verify/blob/v${VERSION}/packages/verify/docs/computers.md#availability-and-errors`] : []),
|
|
74
|
+
...(answers.platform === 'mac' || answers.appId ? [] : ['Put your Revyl app id in verify.config.ts (or set REVYL_APP_ID)']),
|
|
75
|
+
'node verify/example.ts',
|
|
76
|
+
];
|
|
77
|
+
process.stdout.write([
|
|
78
|
+
`Created ${result.created.join(' and ')}${result.gitignoreUpdated ? ', and added .verify/ to .gitignore' : ''}.`,
|
|
79
|
+
'',
|
|
80
|
+
'Next:',
|
|
81
|
+
...steps.map((step, index) => ` ${index + 1}. ${step}`),
|
|
82
|
+
'',
|
|
83
|
+
].join('\n'));
|
|
84
|
+
return 0;
|
|
85
|
+
}
|
|
86
|
+
async function askInitQuestions(appId, url) {
|
|
87
|
+
const readline = createInterface({ input: process.stdin, output: process.stdout });
|
|
88
|
+
try {
|
|
89
|
+
const platform = ((await readline.question('What do you want to open? [web/ios/android/mac] (web): ')).trim().toLowerCase() || 'web');
|
|
90
|
+
if (platform === 'mac')
|
|
91
|
+
return validateInitAnswers({ platform, ...(appId ? { appId } : {}), ...(url ? { url } : {}) });
|
|
92
|
+
const id = appId ?? ((await readline.question('Revyl app id (Enter to fill in later): ')).trim() || undefined);
|
|
93
|
+
const webUrl = platform === 'web' ? (url ?? ((await readline.question('HTTPS URL to open (Enter for https://example.com): ')).trim() || undefined)) : undefined;
|
|
94
|
+
return validateInitAnswers({ platform, ...(id ? { appId: id } : {}), ...(webUrl ? { url: webUrl } : {}) });
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
readline.close();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
async function whoamiCommand() {
|
|
101
|
+
const credentials = await resolveCredentials();
|
|
102
|
+
if (!credentials)
|
|
103
|
+
throw new VerifyError('AUTH_MISSING', missingCredentialsMessage());
|
|
104
|
+
const who = await new RevylClient({ token: credentials.token }).whoAmI();
|
|
105
|
+
const name = who.email ?? who.user_id ?? 'an unnamed user';
|
|
106
|
+
const org = who.org_name ?? who.org_id;
|
|
107
|
+
process.stdout.write(`Signed in to Revyl as ${name}${org ? ` in ${org}` : ''} (credentials from ${credentials.source}).\n`);
|
|
108
|
+
return 0;
|
|
109
|
+
}
|
|
110
|
+
export async function runCli(argv) {
|
|
111
|
+
try {
|
|
112
|
+
return await main(argv);
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
if (isVerifyError(error)) {
|
|
116
|
+
process.stderr.write(`${error.code}: ${error.message}\n`);
|
|
117
|
+
return USAGE_CODES.has(error.code) ? 2 : 1;
|
|
118
|
+
}
|
|
119
|
+
if (error instanceof TypeError && error.code?.startsWith('ERR_PARSE_ARGS')) {
|
|
120
|
+
process.stderr.write(`${error.message}\nRun 'verify --help' for usage.\n`);
|
|
121
|
+
return 2;
|
|
122
|
+
}
|
|
123
|
+
process.stderr.write(`${errorMessage(error)}\n`);
|
|
124
|
+
return 1;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
process.exitCode = await runCli(process.argv.slice(2));
|