@web-auto/camo 0.1.2
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/LICENSE +21 -0
- package/README.md +243 -0
- package/bin/camo.mjs +22 -0
- package/package.json +39 -0
- package/scripts/build.mjs +19 -0
- package/scripts/bump-version.mjs +38 -0
- package/scripts/install.mjs +76 -0
- package/scripts/release.sh +54 -0
- package/src/cli.mjs +199 -0
- package/src/commands/browser.mjs +462 -0
- package/src/commands/cookies.mjs +69 -0
- package/src/commands/create.mjs +98 -0
- package/src/commands/init.mjs +68 -0
- package/src/commands/lifecycle.mjs +256 -0
- package/src/commands/mouse.mjs +49 -0
- package/src/commands/profile.mjs +46 -0
- package/src/commands/system.mjs +14 -0
- package/src/commands/window.mjs +31 -0
- package/src/lifecycle/cleanup.mjs +83 -0
- package/src/lifecycle/lock.mjs +122 -0
- package/src/lifecycle/session-registry.mjs +163 -0
- package/src/utils/args.mjs +25 -0
- package/src/utils/browser-service.mjs +194 -0
- package/src/utils/config.mjs +90 -0
- package/src/utils/fingerprint.mjs +181 -0
- package/src/utils/help.mjs +128 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Browser session cleanup and resource reclamation
|
|
4
|
+
*/
|
|
5
|
+
import { BROWSER_SERVICE_URL } from '../utils/config.mjs';
|
|
6
|
+
|
|
7
|
+
export async function cleanupSession(profileId) {
|
|
8
|
+
try {
|
|
9
|
+
const r = await fetch(`${BROWSER_SERVICE_URL}/command`, {
|
|
10
|
+
method: 'POST',
|
|
11
|
+
headers: { 'Content-Type': 'application/json' },
|
|
12
|
+
body: JSON.stringify({ action: 'stop', args: { profileId } }),
|
|
13
|
+
signal: AbortSignal.timeout(5000),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
if (!r.ok) {
|
|
17
|
+
const body = await r.json().catch(() => ({}));
|
|
18
|
+
throw new Error(body?.error || `HTTP ${r.status}`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return await r.json();
|
|
22
|
+
} catch (err) {
|
|
23
|
+
return { ok: false, error: err.message };
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function forceCleanupSession(profileId) {
|
|
28
|
+
try {
|
|
29
|
+
const r = await fetch(`${BROWSER_SERVICE_URL}/command`, {
|
|
30
|
+
method: 'POST',
|
|
31
|
+
headers: { 'Content-Type': 'application/json' },
|
|
32
|
+
body: JSON.stringify({ action: 'stop', args: { profileId, force: true } }),
|
|
33
|
+
signal: AbortSignal.timeout(10000),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
return await r.json();
|
|
37
|
+
} catch (err) {
|
|
38
|
+
return { ok: false, error: err.message };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function shutdownBrowserService() {
|
|
43
|
+
try {
|
|
44
|
+
const r = await fetch(`${BROWSER_SERVICE_URL}/command`, {
|
|
45
|
+
method: 'POST',
|
|
46
|
+
headers: { 'Content-Type': 'application/json' },
|
|
47
|
+
body: JSON.stringify({ action: 'service:shutdown', args: {} }),
|
|
48
|
+
signal: AbortSignal.timeout(10000),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
return await r.json();
|
|
52
|
+
} catch (err) {
|
|
53
|
+
return { ok: false, error: err.message };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function getActiveSessions() {
|
|
58
|
+
try {
|
|
59
|
+
const r = await fetch(`${BROWSER_SERVICE_URL}/command`, {
|
|
60
|
+
method: 'POST',
|
|
61
|
+
headers: { 'Content-Type': 'application/json' },
|
|
62
|
+
body: JSON.stringify({ action: 'getStatus', args: {} }),
|
|
63
|
+
signal: AbortSignal.timeout(3000),
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const body = await r.json();
|
|
67
|
+
return body?.sessions || [];
|
|
68
|
+
} catch (err) {
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function cleanupAllSessions() {
|
|
74
|
+
const sessions = await getActiveSessions();
|
|
75
|
+
const results = [];
|
|
76
|
+
|
|
77
|
+
for (const session of sessions) {
|
|
78
|
+
const result = await cleanupSession(session.profileId);
|
|
79
|
+
results.push({ profileId: session.profileId, ...result });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return results;
|
|
83
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Lock manager for browser session lifecycle
|
|
4
|
+
* Prevents multiple instances from conflicting
|
|
5
|
+
*/
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
|
|
10
|
+
const LOCK_DIR = path.join(os.homedir(), '.webauto', 'locks');
|
|
11
|
+
|
|
12
|
+
function ensureLockDir() {
|
|
13
|
+
if (!fs.existsSync(LOCK_DIR)) {
|
|
14
|
+
fs.mkdirSync(LOCK_DIR, { recursive: true });
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function getLockFile(profileId) {
|
|
19
|
+
return path.join(LOCK_DIR, `${profileId}.lock`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function getLockInfo(profileId) {
|
|
23
|
+
const lockFile = getLockFile(profileId);
|
|
24
|
+
if (!fs.existsSync(lockFile)) return null;
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const content = fs.readFileSync(lockFile, 'utf-8');
|
|
28
|
+
return JSON.parse(content);
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function acquireLock(profileId, sessionInfo = {}) {
|
|
35
|
+
ensureLockDir();
|
|
36
|
+
const lockFile = getLockFile(profileId);
|
|
37
|
+
|
|
38
|
+
const lockData = {
|
|
39
|
+
profileId,
|
|
40
|
+
pid: process.pid,
|
|
41
|
+
startTime: Date.now(),
|
|
42
|
+
...sessionInfo,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
fs.writeFileSync(lockFile, JSON.stringify(lockData, null, 2));
|
|
46
|
+
return lockData;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function releaseLock(profileId) {
|
|
50
|
+
const lockFile = getLockFile(profileId);
|
|
51
|
+
if (fs.existsSync(lockFile)) {
|
|
52
|
+
fs.unlinkSync(lockFile);
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function isLocked(profileId) {
|
|
59
|
+
const lockFile = getLockFile(profileId);
|
|
60
|
+
return fs.existsSync(lockFile);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function cleanupStaleLocks() {
|
|
64
|
+
ensureLockDir();
|
|
65
|
+
const files = fs.readdirSync(LOCK_DIR);
|
|
66
|
+
const now = Date.now();
|
|
67
|
+
const staleThreshold = 24 * 60 * 60 * 1000; // 24 hours
|
|
68
|
+
|
|
69
|
+
let cleaned = 0;
|
|
70
|
+
for (const file of files) {
|
|
71
|
+
if (!file.endsWith('.lock')) continue;
|
|
72
|
+
|
|
73
|
+
const lockFile = path.join(LOCK_DIR, file);
|
|
74
|
+
try {
|
|
75
|
+
const stats = fs.statSync(lockFile);
|
|
76
|
+
const age = now - stats.mtimeMs;
|
|
77
|
+
|
|
78
|
+
if (age > staleThreshold) {
|
|
79
|
+
fs.unlinkSync(lockFile);
|
|
80
|
+
cleaned++;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const content = fs.readFileSync(lockFile, 'utf-8');
|
|
85
|
+
const lockData = JSON.parse(content);
|
|
86
|
+
|
|
87
|
+
// Check if process is still alive
|
|
88
|
+
if (lockData.pid) {
|
|
89
|
+
try {
|
|
90
|
+
process.kill(lockData.pid, 0); // Signal 0 checks existence
|
|
91
|
+
} catch {
|
|
92
|
+
// Process not running, lock is stale
|
|
93
|
+
fs.unlinkSync(lockFile);
|
|
94
|
+
cleaned++;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
} catch (err) {
|
|
98
|
+
// Corrupted lock file, remove it
|
|
99
|
+
try {
|
|
100
|
+
fs.unlinkSync(lockFile);
|
|
101
|
+
cleaned++;
|
|
102
|
+
} catch {}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return cleaned;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function listActiveLocks() {
|
|
110
|
+
ensureLockDir();
|
|
111
|
+
const files = fs.readdirSync(LOCK_DIR);
|
|
112
|
+
const locks = [];
|
|
113
|
+
|
|
114
|
+
for (const file of files) {
|
|
115
|
+
if (!file.endsWith('.lock')) continue;
|
|
116
|
+
const profileId = file.replace('.lock', '');
|
|
117
|
+
const info = getLockInfo(profileId);
|
|
118
|
+
if (info) locks.push({ profileId, ...info });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return locks;
|
|
122
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Session registry - persistent session state management
|
|
4
|
+
* Stores session metadata locally for recovery/reconnection
|
|
5
|
+
*/
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
|
|
10
|
+
const SESSION_DIR = path.join(os.homedir(), '.webauto', 'sessions');
|
|
11
|
+
|
|
12
|
+
function ensureSessionDir() {
|
|
13
|
+
if (!fs.existsSync(SESSION_DIR)) {
|
|
14
|
+
fs.mkdirSync(SESSION_DIR, { recursive: true });
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function getSessionFile(profileId) {
|
|
19
|
+
return path.join(SESSION_DIR, `${profileId}.json`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function registerSession(profileId, sessionInfo = {}) {
|
|
23
|
+
ensureSessionDir();
|
|
24
|
+
const sessionFile = getSessionFile(profileId);
|
|
25
|
+
|
|
26
|
+
const sessionData = {
|
|
27
|
+
profileId,
|
|
28
|
+
pid: process.pid,
|
|
29
|
+
startTime: Date.now(),
|
|
30
|
+
lastSeen: Date.now(),
|
|
31
|
+
status: 'active',
|
|
32
|
+
...sessionInfo,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
fs.writeFileSync(sessionFile, JSON.stringify(sessionData, null, 2));
|
|
36
|
+
return sessionData;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function updateSession(profileId, updates = {}) {
|
|
40
|
+
const sessionFile = getSessionFile(profileId);
|
|
41
|
+
if (!fs.existsSync(sessionFile)) {
|
|
42
|
+
return registerSession(profileId, updates);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
const existing = JSON.parse(fs.readFileSync(sessionFile, 'utf-8'));
|
|
47
|
+
const updated = {
|
|
48
|
+
...existing,
|
|
49
|
+
...updates,
|
|
50
|
+
lastSeen: Date.now(),
|
|
51
|
+
profileId,
|
|
52
|
+
};
|
|
53
|
+
fs.writeFileSync(sessionFile, JSON.stringify(updated, null, 2));
|
|
54
|
+
return updated;
|
|
55
|
+
} catch {
|
|
56
|
+
return registerSession(profileId, updates);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function getSessionInfo(profileId) {
|
|
61
|
+
const sessionFile = getSessionFile(profileId);
|
|
62
|
+
if (!fs.existsSync(sessionFile)) return null;
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
const content = fs.readFileSync(sessionFile, 'utf-8');
|
|
66
|
+
return JSON.parse(content);
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function unregisterSession(profileId) {
|
|
73
|
+
const sessionFile = getSessionFile(profileId);
|
|
74
|
+
if (fs.existsSync(sessionFile)) {
|
|
75
|
+
fs.unlinkSync(sessionFile);
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function listRegisteredSessions() {
|
|
82
|
+
ensureSessionDir();
|
|
83
|
+
const files = fs.readdirSync(SESSION_DIR);
|
|
84
|
+
const sessions = [];
|
|
85
|
+
|
|
86
|
+
for (const file of files) {
|
|
87
|
+
if (!file.endsWith('.json')) continue;
|
|
88
|
+
const profileId = file.replace('.json', '');
|
|
89
|
+
const info = getSessionInfo(profileId);
|
|
90
|
+
if (info) sessions.push(info);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return sessions;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function markSessionReconnecting(profileId) {
|
|
97
|
+
return updateSession(profileId, { status: 'reconnecting', reconnectAttempt: Date.now() });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function markSessionActive(profileId, updates = {}) {
|
|
101
|
+
return updateSession(profileId, { status: 'active', ...updates });
|
|
102
|
+
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function markSessionClosed(profileId) {
|
|
106
|
+
const sessionFile = getSessionFile(profileId);
|
|
107
|
+
if (fs.existsSync(sessionFile)) {
|
|
108
|
+
const existing = JSON.parse(fs.readFileSync(sessionFile, 'utf-8'));
|
|
109
|
+
existing.status = 'closed';
|
|
110
|
+
existing.closedAt = Date.now();
|
|
111
|
+
fs.writeFileSync(sessionFile, JSON.stringify(existing, null, 2));
|
|
112
|
+
}
|
|
113
|
+
return unregisterSession(profileId);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function cleanupStaleSessions(maxAgeMs = 7 * 24 * 60 * 60 * 1000) {
|
|
117
|
+
ensureSessionDir();
|
|
118
|
+
const files = fs.readdirSync(SESSION_DIR);
|
|
119
|
+
const now = Date.now();
|
|
120
|
+
let cleaned = 0;
|
|
121
|
+
|
|
122
|
+
for (const file of files) {
|
|
123
|
+
if (!file.endsWith('.json')) continue;
|
|
124
|
+
|
|
125
|
+
const sessionFile = path.join(SESSION_DIR, file);
|
|
126
|
+
try {
|
|
127
|
+
const content = fs.readFileSync(sessionFile, 'utf-8');
|
|
128
|
+
const session = JSON.parse(content);
|
|
129
|
+
|
|
130
|
+
// Clean if session is too old or marked as closed
|
|
131
|
+
if (session.status === 'closed' || (session.lastSeen && (now - session.lastSeen) > maxAgeMs)) {
|
|
132
|
+
fs.unlinkSync(sessionFile);
|
|
133
|
+
cleaned++;
|
|
134
|
+
}
|
|
135
|
+
} catch {
|
|
136
|
+
// Remove corrupted session files
|
|
137
|
+
try {
|
|
138
|
+
fs.unlinkSync(sessionFile);
|
|
139
|
+
cleaned++;
|
|
140
|
+
} catch {}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return cleaned;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export async function recoverSession(profileId, checkHealthFn) {
|
|
148
|
+
const info = getSessionInfo(profileId);
|
|
149
|
+
if (!info) return null;
|
|
150
|
+
|
|
151
|
+
// Check if browser service is still running
|
|
152
|
+
const isHealthy = await checkHealthFn();
|
|
153
|
+
|
|
154
|
+
if (!isHealthy) {
|
|
155
|
+
// Service is down, mark as needing recovery
|
|
156
|
+
markSessionReconnecting(profileId);
|
|
157
|
+
return { status: 'needs_recovery', info };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Service is up, check if session is still active
|
|
161
|
+
markSessionActive(profileId, { recoveredAt: Date.now() });
|
|
162
|
+
return { status: 'recovered', info };
|
|
163
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export function resolveProfileId(args, argIndex, getDefaultProfile) {
|
|
2
|
+
let profileId = args[argIndex];
|
|
3
|
+
if (!profileId) {
|
|
4
|
+
profileId = getDefaultProfile();
|
|
5
|
+
}
|
|
6
|
+
return profileId;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function ensureUrlScheme(rawUrl) {
|
|
10
|
+
if (typeof rawUrl !== 'string') return rawUrl;
|
|
11
|
+
const trimmed = rawUrl.trim();
|
|
12
|
+
if (!trimmed) return trimmed;
|
|
13
|
+
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed)) return trimmed;
|
|
14
|
+
return `https://${trimmed}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function looksLikeUrlToken(token) {
|
|
18
|
+
if (!token || typeof token !== 'string') return false;
|
|
19
|
+
if (token.includes('://')) return true;
|
|
20
|
+
return /^[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)+/.test(token);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function getPositionals(args, startIndex = 1) {
|
|
24
|
+
return args.slice(startIndex).filter((a) => a && !a.startsWith('--'));
|
|
25
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { execSync } from 'node:child_process';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import { BROWSER_SERVICE_URL, loadConfig, setRepoRoot } from './config.mjs';
|
|
7
|
+
|
|
8
|
+
export async function callAPI(action, payload = {}) {
|
|
9
|
+
const r = await fetch(`${BROWSER_SERVICE_URL}/command`, {
|
|
10
|
+
method: 'POST',
|
|
11
|
+
headers: { 'Content-Type': 'application/json' },
|
|
12
|
+
body: JSON.stringify({ action, args: payload }),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
let body;
|
|
16
|
+
try {
|
|
17
|
+
body = await r.json();
|
|
18
|
+
} catch {
|
|
19
|
+
const text = await r.text();
|
|
20
|
+
throw new Error(`HTTP ${r.status}: ${text}`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (!r.ok) throw new Error(body?.error || `HTTP ${r.status}`);
|
|
24
|
+
return body;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function getSessionByProfile(profileId) {
|
|
28
|
+
const status = await callAPI('getStatus', {});
|
|
29
|
+
return status?.sessions?.find((s) => s.profileId === profileId) || null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function checkBrowserService() {
|
|
33
|
+
try {
|
|
34
|
+
const r = await fetch(`${BROWSER_SERVICE_URL}/health`, { signal: AbortSignal.timeout(2000) });
|
|
35
|
+
return r.ok;
|
|
36
|
+
} catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function detectCamoufoxPath() {
|
|
42
|
+
try {
|
|
43
|
+
const cmd = process.platform === 'win32' ? 'python -m camoufox path' : 'python3 -m camoufox path';
|
|
44
|
+
const out = execSync(cmd, {
|
|
45
|
+
encoding: 'utf8',
|
|
46
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
47
|
+
});
|
|
48
|
+
const lines = out.trim().split(/\r?\n/);
|
|
49
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
50
|
+
const line = lines[i].trim();
|
|
51
|
+
if (line && (line.startsWith('/') || line.match(/^[A-Z]:\\/))) return line;
|
|
52
|
+
}
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function ensureCamoufox() {
|
|
60
|
+
if (detectCamoufoxPath()) return;
|
|
61
|
+
console.log('Camoufox is not found. Installing...');
|
|
62
|
+
execSync('npx --yes --package=camoufox camoufox fetch', { stdio: 'inherit' });
|
|
63
|
+
if (!detectCamoufoxPath()) {
|
|
64
|
+
throw new Error('Camoufox install finished but executable was not detected');
|
|
65
|
+
}
|
|
66
|
+
console.log('Camoufox installed.');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const START_SCRIPT_REL = path.join('runtime', 'infra', 'utils', 'scripts', 'service', 'start-browser-service.mjs');
|
|
70
|
+
|
|
71
|
+
function hasStartScript(root) {
|
|
72
|
+
if (!root) return false;
|
|
73
|
+
return fs.existsSync(path.join(root, START_SCRIPT_REL));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function walkUpForRepoRoot(startDir) {
|
|
77
|
+
if (!startDir) return null;
|
|
78
|
+
let cursor = path.resolve(startDir);
|
|
79
|
+
for (;;) {
|
|
80
|
+
if (hasStartScript(cursor)) return cursor;
|
|
81
|
+
const parent = path.dirname(cursor);
|
|
82
|
+
if (parent === cursor) return null;
|
|
83
|
+
cursor = parent;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function scanCommonRepoRoots() {
|
|
88
|
+
const home = os.homedir();
|
|
89
|
+
const roots = [
|
|
90
|
+
path.join(home, 'Documents', 'github'),
|
|
91
|
+
path.join(home, 'github'),
|
|
92
|
+
path.join(home, 'code'),
|
|
93
|
+
path.join(home, 'projects'),
|
|
94
|
+
path.join('/Volumes', 'extension', 'code'),
|
|
95
|
+
path.join('C:', 'code'),
|
|
96
|
+
path.join('D:', 'code'),
|
|
97
|
+
path.join('C:', 'projects'),
|
|
98
|
+
path.join('D:', 'projects'),
|
|
99
|
+
path.join('C:', 'Users', os.userInfo().username, 'code'),
|
|
100
|
+
path.join('C:', 'Users', os.userInfo().username, 'projects'),
|
|
101
|
+
path.join('C:', 'Users', os.userInfo().username, 'Documents', 'github'),
|
|
102
|
+
].filter(Boolean);
|
|
103
|
+
|
|
104
|
+
for (const root of roots) {
|
|
105
|
+
if (!fs.existsSync(root)) continue;
|
|
106
|
+
try {
|
|
107
|
+
const entries = fs.readdirSync(root, { withFileTypes: true });
|
|
108
|
+
for (const entry of entries) {
|
|
109
|
+
if (!entry.isDirectory()) continue;
|
|
110
|
+
if (!entry.name.toLowerCase().includes('webauto')) continue;
|
|
111
|
+
const candidate = path.join(root, entry.name);
|
|
112
|
+
if (hasStartScript(candidate)) return candidate;
|
|
113
|
+
}
|
|
114
|
+
} catch {
|
|
115
|
+
// ignore scanning errors and continue
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function findRepoRootCandidate() {
|
|
123
|
+
const cfg = loadConfig();
|
|
124
|
+
const candidates = [
|
|
125
|
+
process.env.WEBAUTO_REPO_ROOT,
|
|
126
|
+
cfg.repoRoot,
|
|
127
|
+
process.cwd(),
|
|
128
|
+
path.join('/Volumes', 'extension', 'code', 'webauto'),
|
|
129
|
+
path.join('/Volumes', 'extension', 'code', 'WebAuto'),
|
|
130
|
+
path.join(os.homedir(), 'Documents', 'github', 'webauto'),
|
|
131
|
+
path.join(os.homedir(), 'Documents', 'github', 'WebAuto'),
|
|
132
|
+
path.join(os.homedir(), 'github', 'webauto'),
|
|
133
|
+
path.join(os.homedir(), 'github', 'WebAuto'),
|
|
134
|
+
path.join('C:', 'code', 'webauto'),
|
|
135
|
+
path.join('C:', 'code', 'WebAuto'),
|
|
136
|
+
path.join('C:', 'Users', os.userInfo().username, 'code', 'webauto'),
|
|
137
|
+
path.join('C:', 'Users', os.userInfo().username, 'code', 'WebAuto'),
|
|
138
|
+
].filter(Boolean);
|
|
139
|
+
|
|
140
|
+
for (const root of candidates) {
|
|
141
|
+
if (hasStartScript(root)) {
|
|
142
|
+
if (cfg.repoRoot !== root) {
|
|
143
|
+
setRepoRoot(root);
|
|
144
|
+
}
|
|
145
|
+
return root;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
for (const startDir of [process.cwd()]) {
|
|
150
|
+
const found = walkUpForRepoRoot(startDir);
|
|
151
|
+
if (found) {
|
|
152
|
+
if (cfg.repoRoot !== found) {
|
|
153
|
+
setRepoRoot(found);
|
|
154
|
+
}
|
|
155
|
+
return found;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const scanned = scanCommonRepoRoots();
|
|
160
|
+
if (scanned) {
|
|
161
|
+
if (cfg.repoRoot !== scanned) {
|
|
162
|
+
setRepoRoot(scanned);
|
|
163
|
+
}
|
|
164
|
+
return scanned;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export async function ensureBrowserService() {
|
|
171
|
+
if (await checkBrowserService()) return;
|
|
172
|
+
|
|
173
|
+
const repoRoot = findRepoRootCandidate();
|
|
174
|
+
if (!repoRoot) {
|
|
175
|
+
throw new Error(
|
|
176
|
+
`Cannot locate browser-service start script (${START_SCRIPT_REL}). ` +
|
|
177
|
+
'Run from webauto repo once or set WEBAUTO_REPO_ROOT=/path/to/webauto.',
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const scriptPath = path.join(repoRoot, START_SCRIPT_REL);
|
|
182
|
+
console.log('Starting browser-service daemon...');
|
|
183
|
+
execSync(`node "${scriptPath}"`, { stdio: 'inherit', cwd: repoRoot });
|
|
184
|
+
|
|
185
|
+
for (let i = 0; i < 20; i += 1) {
|
|
186
|
+
await new Promise((r) => setTimeout(r, 400));
|
|
187
|
+
if (await checkBrowserService()) {
|
|
188
|
+
console.log('Browser-service is ready.');
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
throw new Error('Browser-service failed to become healthy within timeout');
|
|
194
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
|
|
6
|
+
export const CONFIG_DIR = path.join(os.homedir(), '.webauto');
|
|
7
|
+
export const PROFILES_DIR = path.join(CONFIG_DIR, 'profiles');
|
|
8
|
+
export const CONFIG_FILE = path.join(CONFIG_DIR, 'camo-cli.json');
|
|
9
|
+
export const BROWSER_SERVICE_URL = process.env.WEBAUTO_BROWSER_URL || 'http://127.0.0.1:7704';
|
|
10
|
+
|
|
11
|
+
export function ensureDir(p) {
|
|
12
|
+
if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function readJson(p) {
|
|
16
|
+
try {
|
|
17
|
+
if (!fs.existsSync(p)) return null;
|
|
18
|
+
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function writeJson(p, data) {
|
|
25
|
+
ensureDir(path.dirname(p));
|
|
26
|
+
fs.writeFileSync(p, JSON.stringify(data, null, 2));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function loadConfig() {
|
|
30
|
+
const raw = readJson(CONFIG_FILE) || {};
|
|
31
|
+
return {
|
|
32
|
+
defaultProfile: typeof raw.defaultProfile === 'string' ? raw.defaultProfile : null,
|
|
33
|
+
repoRoot: typeof raw.repoRoot === 'string' ? raw.repoRoot : null,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function saveConfig(config) {
|
|
38
|
+
writeJson(CONFIG_FILE, config);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function listProfiles() {
|
|
42
|
+
if (!fs.existsSync(PROFILES_DIR)) return [];
|
|
43
|
+
return fs.readdirSync(PROFILES_DIR, { withFileTypes: true })
|
|
44
|
+
.filter((d) => d.isDirectory())
|
|
45
|
+
.map((d) => d.name)
|
|
46
|
+
.filter((name) => !name.includes(':') && !name.includes('/') && !name.startsWith('.'))
|
|
47
|
+
.sort();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function isValidProfileId(profileId) {
|
|
51
|
+
return typeof profileId === 'string' && /^[a-zA-Z0-9._-]+$/.test(profileId);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function createProfile(profileId) {
|
|
55
|
+
if (!isValidProfileId(profileId)) {
|
|
56
|
+
throw new Error('Invalid profileId. Use only letters, numbers, dot, underscore, dash.');
|
|
57
|
+
}
|
|
58
|
+
const profileDir = path.join(PROFILES_DIR, profileId);
|
|
59
|
+
if (fs.existsSync(profileDir)) throw new Error(`Profile already exists: ${profileId}`);
|
|
60
|
+
ensureDir(profileDir);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function deleteProfile(profileId) {
|
|
64
|
+
const profileDir = path.join(PROFILES_DIR, profileId);
|
|
65
|
+
if (!fs.existsSync(profileDir)) throw new Error(`Profile not found: ${profileId}`);
|
|
66
|
+
fs.rmSync(profileDir, { recursive: true, force: true });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function setDefaultProfile(profileId) {
|
|
70
|
+
const cfg = loadConfig();
|
|
71
|
+
cfg.defaultProfile = profileId;
|
|
72
|
+
saveConfig(cfg);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function setRepoRoot(repoRoot) {
|
|
76
|
+
const cfg = loadConfig();
|
|
77
|
+
cfg.repoRoot = repoRoot;
|
|
78
|
+
saveConfig(cfg);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function getDefaultProfile() {
|
|
82
|
+
return loadConfig().defaultProfile;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const START_SCRIPT_REL = path.join('runtime', 'infra', 'utils', 'scripts', 'service', 'start-browser-service.mjs');
|
|
86
|
+
|
|
87
|
+
export function hasStartScript(root) {
|
|
88
|
+
if (!root) return false;
|
|
89
|
+
return fs.existsSync(path.join(root, START_SCRIPT_REL));
|
|
90
|
+
}
|