visor-ai 0.2.1

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.
@@ -0,0 +1,267 @@
1
+ import fs from 'node:fs';
2
+ import net from 'node:net';
3
+ import path from 'node:path';
4
+ import { spawn } from 'node:child_process';
5
+ import { ensureDir, parseServerUrl, resolveExecutable, sleep, splitCommandLine } from './utils.js';
6
+ export const DEFAULT_STARTUP_TIMEOUT_SECONDS = 20;
7
+ function stateDir() {
8
+ return ensureDir(path.join(process.cwd(), '.visor', 'appium'));
9
+ }
10
+ function slug(serverUrl) {
11
+ const address = parseServerUrl(serverUrl);
12
+ return `${address.host.replaceAll(':', '_')}_${address.port}`;
13
+ }
14
+ function metaPath(serverUrl) {
15
+ return path.join(stateDir(), `${slug(serverUrl)}.json`);
16
+ }
17
+ function logPath(serverUrl) {
18
+ return path.join(stateDir(), `${slug(serverUrl)}.log`);
19
+ }
20
+ function pidExists(pid) {
21
+ if (pid <= 0) {
22
+ return false;
23
+ }
24
+ try {
25
+ process.kill(pid, 0);
26
+ return true;
27
+ }
28
+ catch {
29
+ return false;
30
+ }
31
+ }
32
+ function readMeta(serverUrl) {
33
+ const filePath = metaPath(serverUrl);
34
+ if (!fs.existsSync(filePath)) {
35
+ return null;
36
+ }
37
+ try {
38
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
39
+ }
40
+ catch {
41
+ return null;
42
+ }
43
+ }
44
+ function writeMeta(serverUrl, meta) {
45
+ const filePath = metaPath(serverUrl);
46
+ fs.writeFileSync(filePath, JSON.stringify(meta, null, 2), 'utf8');
47
+ return filePath;
48
+ }
49
+ function cleanupMeta(serverUrl) {
50
+ const filePath = metaPath(serverUrl);
51
+ if (fs.existsSync(filePath)) {
52
+ fs.unlinkSync(filePath);
53
+ }
54
+ }
55
+ function localAppiumBin() {
56
+ const executable = process.platform === 'win32' ? 'appium.cmd' : 'appium';
57
+ return path.resolve(process.cwd(), 'node_modules', '.bin', executable);
58
+ }
59
+ function terminatePid(pid, force) {
60
+ const signal = force ? 'SIGKILL' : 'SIGTERM';
61
+ if (process.platform === 'win32') {
62
+ process.kill(pid, signal);
63
+ return;
64
+ }
65
+ try {
66
+ process.kill(-pid, signal);
67
+ }
68
+ catch {
69
+ process.kill(pid, signal);
70
+ }
71
+ }
72
+ export async function isAppiumReachable(serverUrl, timeout = 1000) {
73
+ const address = parseServerUrl(serverUrl);
74
+ return new Promise((resolve) => {
75
+ const socket = net.createConnection({
76
+ host: address.host,
77
+ port: address.port
78
+ }, () => {
79
+ socket.destroy();
80
+ resolve(true);
81
+ });
82
+ socket.setTimeout(timeout);
83
+ socket.on('timeout', () => {
84
+ socket.destroy();
85
+ resolve(false);
86
+ });
87
+ socket.on('error', () => {
88
+ socket.destroy();
89
+ resolve(false);
90
+ });
91
+ });
92
+ }
93
+ export function resolveAppiumCommand(overrideCmd) {
94
+ if (overrideCmd?.trim()) {
95
+ return overrideCmd.trim();
96
+ }
97
+ const envCmd = process.env.VISOR_APPIUM_CMD?.trim();
98
+ if (envCmd) {
99
+ return envCmd;
100
+ }
101
+ const localBin = localAppiumBin();
102
+ if (fs.existsSync(localBin)) {
103
+ return localBin;
104
+ }
105
+ if (resolveExecutable('npx')) {
106
+ return 'npx appium';
107
+ }
108
+ if (resolveExecutable('appium')) {
109
+ return 'appium';
110
+ }
111
+ throw new Error('Unable to find Appium launcher. Install project dependencies, run `npm install`, or set VISOR_APPIUM_CMD / --appium-cmd.');
112
+ }
113
+ export function injectServerBinding(cmdParts, host, port) {
114
+ const hasPort = cmdParts.includes('--port') || cmdParts.includes('-p');
115
+ const hasAddress = cmdParts.includes('--address') || cmdParts.includes('-a');
116
+ const withBinding = [...cmdParts];
117
+ if (!hasAddress) {
118
+ withBinding.push('--address', host);
119
+ }
120
+ if (!hasPort) {
121
+ withBinding.push('--port', String(port));
122
+ }
123
+ return withBinding;
124
+ }
125
+ export async function statusManagedAppium(serverUrl) {
126
+ const reachable = await isAppiumReachable(serverUrl);
127
+ const meta = readMeta(serverUrl);
128
+ if (!meta) {
129
+ return {
130
+ serverUrl: serverUrl,
131
+ reachable,
132
+ managed: false,
133
+ pid: null,
134
+ command: null,
135
+ metadataPath: metaPath(serverUrl),
136
+ logPath: logPath(serverUrl)
137
+ };
138
+ }
139
+ if (!pidExists(meta.pid)) {
140
+ cleanupMeta(serverUrl);
141
+ return {
142
+ serverUrl: serverUrl,
143
+ reachable,
144
+ managed: false,
145
+ pid: null,
146
+ command: null,
147
+ metadataPath: metaPath(serverUrl),
148
+ logPath: logPath(serverUrl)
149
+ };
150
+ }
151
+ return {
152
+ serverUrl: serverUrl,
153
+ reachable,
154
+ managed: true,
155
+ pid: meta.pid,
156
+ command: meta.command,
157
+ metadataPath: metaPath(serverUrl),
158
+ logPath: logPath(serverUrl)
159
+ };
160
+ }
161
+ export async function startManagedAppium(serverUrl, appiumCmd, startupTimeoutS = DEFAULT_STARTUP_TIMEOUT_SECONDS) {
162
+ const existingStatus = await statusManagedAppium(serverUrl);
163
+ if (Boolean(existingStatus.reachable)) {
164
+ return {
165
+ ...existingStatus,
166
+ alreadyRunning: true,
167
+ started: false
168
+ };
169
+ }
170
+ const resolvedCmd = resolveAppiumCommand(appiumCmd);
171
+ const server = parseServerUrl(serverUrl);
172
+ const cmdParts = injectServerBinding(splitCommandLine(resolvedCmd), server.host, server.port);
173
+ if (cmdParts.length === 0) {
174
+ throw new Error('Appium command is empty after parsing.');
175
+ }
176
+ const targetLogPath = logPath(serverUrl);
177
+ ensureDir(path.dirname(targetLogPath));
178
+ const logFd = fs.openSync(targetLogPath, 'a');
179
+ const child = spawn(cmdParts[0], cmdParts.slice(1), {
180
+ detached: process.platform !== 'win32',
181
+ stdio: ['ignore', logFd, logFd]
182
+ });
183
+ fs.closeSync(logFd);
184
+ const metadataPath = writeMeta(serverUrl, {
185
+ pid: child.pid ?? -1,
186
+ command: resolvedCmd,
187
+ serverUrl,
188
+ startedAt: Date.now()
189
+ });
190
+ const deadline = Date.now() + Math.max(100, startupTimeoutS * 1000);
191
+ while (Date.now() < deadline) {
192
+ if (await isAppiumReachable(serverUrl)) {
193
+ child.unref();
194
+ return {
195
+ serverUrl,
196
+ reachable: true,
197
+ managed: true,
198
+ pid: child.pid,
199
+ command: resolvedCmd,
200
+ metadataPath,
201
+ logPath: targetLogPath,
202
+ started: true,
203
+ alreadyRunning: false
204
+ };
205
+ }
206
+ if (child.exitCode !== null) {
207
+ cleanupMeta(serverUrl);
208
+ throw new Error(`Appium exited before becoming ready (exit code ${child.exitCode}). See log at ${targetLogPath}`);
209
+ }
210
+ await sleep(250);
211
+ }
212
+ try {
213
+ if (child.pid) {
214
+ terminatePid(child.pid, true);
215
+ }
216
+ }
217
+ catch {
218
+ // best effort cleanup
219
+ }
220
+ cleanupMeta(serverUrl);
221
+ throw new Error(`Appium did not become reachable within ${startupTimeoutS.toFixed(1)}s. See log at ${targetLogPath}`);
222
+ }
223
+ export async function stopManagedAppium(serverUrl, force = false, timeoutS = 5) {
224
+ const meta = readMeta(serverUrl);
225
+ if (!meta) {
226
+ return {
227
+ serverUrl,
228
+ stopped: false,
229
+ managed: false,
230
+ reason: 'no_managed_process',
231
+ reachable: await isAppiumReachable(serverUrl),
232
+ metadataPath: metaPath(serverUrl),
233
+ logPath: logPath(serverUrl)
234
+ };
235
+ }
236
+ if (!pidExists(meta.pid)) {
237
+ cleanupMeta(serverUrl);
238
+ return {
239
+ serverUrl,
240
+ stopped: false,
241
+ managed: false,
242
+ reason: 'stale_metadata',
243
+ reachable: await isAppiumReachable(serverUrl),
244
+ pid: meta.pid,
245
+ metadataPath: metaPath(serverUrl),
246
+ logPath: logPath(serverUrl)
247
+ };
248
+ }
249
+ terminatePid(meta.pid, force);
250
+ const deadline = Date.now() + Math.max(100, timeoutS * 1000);
251
+ while (Date.now() < deadline) {
252
+ if (!pidExists(meta.pid)) {
253
+ cleanupMeta(serverUrl);
254
+ return {
255
+ serverUrl,
256
+ stopped: true,
257
+ managed: true,
258
+ pid: meta.pid,
259
+ reachable: await isAppiumReachable(serverUrl),
260
+ metadataPath: metaPath(serverUrl),
261
+ logPath: logPath(serverUrl)
262
+ };
263
+ }
264
+ await sleep(100);
265
+ }
266
+ throw new Error(`Failed to stop managed Appium process ${meta.pid} within ${timeoutS.toFixed(1)}s. Retry with --force.`);
267
+ }