webhelm 0.0.0-stage → 1.0.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.
- package/PRIVACY.md +21 -0
- package/README.md +54 -2
- package/bin/webhelm.mjs +2 -0
- package/package.json +32 -4
- package/runtime/actions-repo.mjs +62 -0
- package/runtime/admin.mjs +7 -0
- package/runtime/chatgpt/chatgpt-delivery-errors.mjs +94 -0
- package/runtime/chatgpt/chatgpt-probe.mjs +23 -0
- package/runtime/chatgpt/chatgpt-queue.mjs +441 -0
- package/runtime/chatgpt/lifecycle-common.mjs +79 -0
- package/runtime/chatgpt/readiness.mjs +120 -0
- package/runtime/chrome.mjs +125 -0
- package/runtime/cli.mjs +161 -0
- package/runtime/config-paths.mjs +14 -0
- package/runtime/migrate.mjs +47 -0
- package/runtime/node-daemon.mjs +476 -0
- package/runtime/platforms.mjs +89 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import net from 'node:net';
|
|
4
|
+
import { spawn, execFileSync } from 'node:child_process';
|
|
5
|
+
|
|
6
|
+
import { CONFIG_ROOT, PROFILE_DIR, requireMigratedConfig } from './config-paths.mjs';
|
|
7
|
+
const ROOT = CONFIG_ROOT;
|
|
8
|
+
const PROFILE = PROFILE_DIR;
|
|
9
|
+
const STATE = path.join(ROOT, 'managed-chrome.json');
|
|
10
|
+
const LOCK = path.join(ROOT, 'managed-chrome-node.lock');
|
|
11
|
+
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
12
|
+
|
|
13
|
+
async function endpointLive(port) {
|
|
14
|
+
try {
|
|
15
|
+
const url = `http://127.0.0.1:${port}/json/version`;
|
|
16
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(650) });
|
|
17
|
+
if (!response.ok) return false;
|
|
18
|
+
return Boolean((await response.json()).webSocketDebuggerUrl);
|
|
19
|
+
} catch { return false; }
|
|
20
|
+
}
|
|
21
|
+
function alive(pid) {
|
|
22
|
+
try { if(!pid) return false;process.kill(Number(pid), 0);return true; }
|
|
23
|
+
catch {return false;}
|
|
24
|
+
}
|
|
25
|
+
async function fromState() {
|
|
26
|
+
let state;
|
|
27
|
+
try { state=JSON.parse(fs.readFileSync(STATE, 'utf8')); } catch { return null; }
|
|
28
|
+
if (state?.pid && Number.isInteger(state.port) && alive(state.pid) && await endpointLive(state.port)) return state;
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
async function discover() {
|
|
32
|
+
if (process.platform === 'win32') return null;
|
|
33
|
+
let output;
|
|
34
|
+
try { output=execFileSync('ps', ['-axo', 'pid=,command='], {encoding:'utf8'}); }
|
|
35
|
+
catch { return null; }
|
|
36
|
+
for(const line of output.split('\n')) {
|
|
37
|
+
if(!line.includes(`--user-data-dir=${PROFILE}`) || line.includes('--type=')) continue;
|
|
38
|
+
const match=line.match(/^\s*(\d+)\s+.*?--remote-debugging-port=(\d+)/);
|
|
39
|
+
if (!match) continue;
|
|
40
|
+
const [pid,port]=match.slice(1).map(Number);
|
|
41
|
+
if (!alive(pid) || !await endpointLive(port)) continue;
|
|
42
|
+
const state={pid,port,profile_dir:PROFILE,chrome_path:line.slice(line.search(/\S/)).replace(/^\d+\s*/, '').split(' --user-data-dir=')[0]};
|
|
43
|
+
fs.writeFileSync(STATE,JSON.stringify(state,null,2));
|
|
44
|
+
return state;
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
function browserExecutable(){
|
|
49
|
+
const override=process.env.WEBHELM_CHROME_PATH||process.env.BROWSER_WORKSPACE_CHROME_PATH;
|
|
50
|
+
if(override)return override;
|
|
51
|
+
const candidates=process.platform==='darwin' ? [
|
|
52
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
53
|
+
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
|
54
|
+
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
55
|
+
] : process.platform==='win32' ? [
|
|
56
|
+
path.join(process.env.LOCALAPPDATA||'', 'Google/Chrome/Application/chrome.exe'),
|
|
57
|
+
path.join(process.env.PROGRAMFILES||'', 'Google/Chrome/Application/chrome.exe'),
|
|
58
|
+
] : ['/usr/bin/google-chrome-stable','/usr/bin/google-chrome','/usr/bin/chromium'];
|
|
59
|
+
const chosen=candidates.find(candidate=>fs.existsSync(candidate));
|
|
60
|
+
if(!chosen) throw Error('Chrome not found; set WEBHELM_CHROME_PATH');
|
|
61
|
+
return chosen;
|
|
62
|
+
}
|
|
63
|
+
function portFree(port) {
|
|
64
|
+
return new Promise(resolve=>{
|
|
65
|
+
const server=net.createServer();
|
|
66
|
+
server.once('error',()=>resolve(false));
|
|
67
|
+
server.listen(port,'127.0.0.1',()=>server.close(()=>resolve(true)));
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
async function choosePort() {
|
|
71
|
+
const initial=Number(process.env.BROWSER_WORKSPACE_CDP_PORT||9222);
|
|
72
|
+
for(let port=initial;port<initial+20;port++) if(await portFree(port))return port;
|
|
73
|
+
throw Error('No free managed Chrome CDP port');
|
|
74
|
+
}
|
|
75
|
+
async function lockOwnership(fn) {
|
|
76
|
+
fs.mkdirSync(ROOT,{recursive:true,mode:0o700});
|
|
77
|
+
let fd;
|
|
78
|
+
for(let attempt=0;attempt<30;attempt++){
|
|
79
|
+
try {fd=fs.openSync(LOCK,'wx',0o600);break;}
|
|
80
|
+
catch (error) {
|
|
81
|
+
if(error.code!=='EEXIST')throw error;
|
|
82
|
+
let pid=0;
|
|
83
|
+
try {pid=Number(fs.readFileSync(LOCK,'utf8'));}catch{}
|
|
84
|
+
if(pid>0&&!alive(pid)){try{fs.unlinkSync(LOCK);}catch{}}
|
|
85
|
+
await delay(150);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if(fd===undefined)throw Error('Managed Chrome lock timed out');
|
|
89
|
+
fs.writeSync(fd,String(process.pid));
|
|
90
|
+
try {return await fn();}
|
|
91
|
+
finally {fs.closeSync(fd);try {fs.unlinkSync(LOCK);}catch{}}
|
|
92
|
+
}
|
|
93
|
+
export async function ensureManagedChrome(){
|
|
94
|
+
// Explicit CDP endpoints also support hermetic mock-browser tests.
|
|
95
|
+
const specified=process.env.WEBHELM_CDP_URL;
|
|
96
|
+
if (specified) return {mode:'override',reused:true,endpoint:specified,profile_dir:PROFILE};
|
|
97
|
+
requireMigratedConfig();
|
|
98
|
+
return lockOwnership(async()=>{
|
|
99
|
+
const existing=await fromState()||await discover();
|
|
100
|
+
if(existing)return {...existing, mode:'managed',reused:true,endpoint:`http://127.0.0.1:${existing.port}`};
|
|
101
|
+
// An explicit endpoint takes precedence only when there is no managed instance.
|
|
102
|
+
const override=process.env.WEBHELM_CDP_URL||process.env.BU_CDP_URL;
|
|
103
|
+
if(override)return {mode:'override',reused:true,endpoint:override,profile_dir:PROFILE};
|
|
104
|
+
const port=await choosePort();
|
|
105
|
+
const chrome=browserExecutable();
|
|
106
|
+
const log=fs.openSync(path.join(ROOT,'managed-chrome.log'),'a');
|
|
107
|
+
fs.mkdirSync(PROFILE,{recursive:true});
|
|
108
|
+
const child=spawn(chrome,[`--user-data-dir=${PROFILE}`,`--remote-debugging-port=${port}`,
|
|
109
|
+
'--no-first-run','--no-default-browser-check','about:blank'],
|
|
110
|
+
{detached:true,stdio:['ignore',log,log]});
|
|
111
|
+
child.unref();
|
|
112
|
+
fs.closeSync(log);
|
|
113
|
+
for(let tries=0;tries<100;tries++){
|
|
114
|
+
if(await endpointLive(port)){
|
|
115
|
+
const state={chrome_path:chrome,pid:child.pid,port,profile_dir:PROFILE};
|
|
116
|
+
const temp=`${STATE}.${process.pid}.tmp`;
|
|
117
|
+
fs.writeFileSync(temp,JSON.stringify(state,null,2));fs.renameSync(temp,STATE);
|
|
118
|
+
return {...state,mode:'managed',endpoint:`http://127.0.0.1:${port}`,reused:false};
|
|
119
|
+
}
|
|
120
|
+
if(!alive(child.pid))throw Error('Managed Chrome exited during launch');
|
|
121
|
+
await delay(150);
|
|
122
|
+
}
|
|
123
|
+
throw Error(`Chrome did not expose CDP port ${port}`);
|
|
124
|
+
});
|
|
125
|
+
}
|
package/runtime/cli.mjs
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { randomUUID } from 'node:crypto';
|
|
7
|
+
import { ensureDaemon, request, handle, SOCKET } from './node-daemon.mjs';
|
|
8
|
+
import { PLATFORMS, listPlatforms, platformProfile, actionPath, validateConfig } from './platforms.mjs';
|
|
9
|
+
import { ACTIONS_DIR, ensureActions, syncActions, publishActions } from './actions-repo.mjs';
|
|
10
|
+
import { migrateConfig } from './migrate.mjs';
|
|
11
|
+
|
|
12
|
+
const ROOT = path.dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const emit = value => console.log(JSON.stringify(value));
|
|
14
|
+
const arg = (items, name) => {
|
|
15
|
+
const i = items.indexOf(name);
|
|
16
|
+
return i === -1 ? undefined : items[i + 1];
|
|
17
|
+
};
|
|
18
|
+
const has = (items, name) => items.includes(name);
|
|
19
|
+
|
|
20
|
+
function help() {
|
|
21
|
+
console.log(`webhelm — browser automation through one Node.js runtime
|
|
22
|
+
|
|
23
|
+
Usage:
|
|
24
|
+
webhelm migrate # after stopping Chrome and the old WebHelm daemon
|
|
25
|
+
webhelm setup
|
|
26
|
+
webhelm status
|
|
27
|
+
webhelm capacity [--workspace NAME]
|
|
28
|
+
webhelm session start [--url URL]
|
|
29
|
+
webhelm session list
|
|
30
|
+
webhelm session exec SESSION_ID [--code-file FILE] # JavaScript
|
|
31
|
+
webhelm session stop SESSION_ID|--all
|
|
32
|
+
webhelm platform list
|
|
33
|
+
webhelm platform sync
|
|
34
|
+
webhelm platform publish --message "DESCRIPTION"
|
|
35
|
+
webhelm platform profile PLATFORM
|
|
36
|
+
webhelm platform source PLATFORM ACTION
|
|
37
|
+
webhelm platform run PLATFORM ACTION [--session-id ID | --auto-session] [--config FILE] [--mode async|sync]
|
|
38
|
+
|
|
39
|
+
Agent workflows should keep their session_id across browser actions and always stop owned sessions.
|
|
40
|
+
`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function loadConfig(args) {
|
|
44
|
+
const file = arg(args, '--config');
|
|
45
|
+
if (!file) return {};
|
|
46
|
+
const cfg = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
47
|
+
if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg)) throw Error('platform config must be a JSON object');
|
|
48
|
+
return cfg;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function validateMode(platform, action, args, config) {
|
|
52
|
+
const isNewTurn = platform === 'chatgpt' && action === 'new-turn';
|
|
53
|
+
const override = arg(args, '--mode');
|
|
54
|
+
if (!isNewTurn && override !== undefined) throw Error('--mode is supported only for chatgpt/new-turn');
|
|
55
|
+
if (!isNewTurn) return null;
|
|
56
|
+
if ('sync' in config) throw Error('legacy sync is not supported; use mode: async or sync');
|
|
57
|
+
if (override !== undefined && 'mode' in config && override !== config.mode) throw Error('--mode conflicts with the new-turn config mode');
|
|
58
|
+
const mode = override ?? config.mode ?? 'async';
|
|
59
|
+
if (typeof mode !== 'string' || !['async', 'sync'].includes(mode)) throw Error('mode must be exactly async or sync');
|
|
60
|
+
if (mode === 'sync' && config.mergeable !== undefined && config.mergeable !== false) {
|
|
61
|
+
throw Error('mergeable is only supported in async mode');
|
|
62
|
+
}
|
|
63
|
+
return mode;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function runPlatform(args) {
|
|
67
|
+
const platform = args[2], action = args[3]?.replaceAll('_', '-');
|
|
68
|
+
if (!platform || !action) throw Error('platform run requires PLATFORM ACTION');
|
|
69
|
+
await ensureActions();
|
|
70
|
+
actionPath(platform, action);
|
|
71
|
+
const config = loadConfig(args);
|
|
72
|
+
const mode = validateMode(platform, action, args, config);
|
|
73
|
+
const sid = arg(args, '--session-id');
|
|
74
|
+
const auto = has(args, '--auto-session');
|
|
75
|
+
validateConfig(platform, action, config);
|
|
76
|
+
if (sid && auto) throw Error('--session-id and --auto-session are mutually exclusive');
|
|
77
|
+
if (platform === 'chatgpt' && action === 'new-turn' && mode === 'async') {
|
|
78
|
+
if (sid || auto) throw Error('async new-turn cannot use browser session options');
|
|
79
|
+
if (!config.thread_id || !config.message) throw Error('thread_id and message required');
|
|
80
|
+
if (typeof config.mergeable !== 'undefined' && typeof config.mergeable !== 'boolean') throw Error('mergeable must be a boolean');
|
|
81
|
+
const receipt = await request({op:'chatgpt-new-turn-enqueue', config: {...config, idempotency_key: config.idempotency_key || randomUUID(), mode}});
|
|
82
|
+
if (!receipt.ok || !receipt.queue_id || receipt.thread_id !== String(config.thread_id)) throw Error('ChatGPT queue returned an invalid acceptance receipt');
|
|
83
|
+
const project = config.project_id || null;
|
|
84
|
+
const prefix = project ? `/g/${encodeURIComponent(project)}` : '';
|
|
85
|
+
return {
|
|
86
|
+
platform, action, session_mode: 'durable_queue', self_heal_capable: false,
|
|
87
|
+
result: {ok:true, status:'submitted', mode:'new-turn', project_id:project,
|
|
88
|
+
thread_id:String(config.thread_id), thread_url:`https://chatgpt.com${prefix}/c/${encodeURIComponent(config.thread_id)}`,
|
|
89
|
+
verified_by:'durable-queue', user_message_id:null, queue_id: receipt.queue_id, state:receipt.state},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
if (mode === 'sync' && (!sid || auto)) throw Error('sync new-turn requires a caller-owned --session-id');
|
|
93
|
+
if (!sid && !auto) throw Error('platform run requires --session-id; use --auto-session only for one-shot actions');
|
|
94
|
+
return request({op:'platform-run', platform, action, config: {...config, ...(mode ? {mode} : {})},
|
|
95
|
+
session_id:sid, auto_session:auto, url:arg(args, '--url')});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function main(args = process.argv.slice(2)) {
|
|
99
|
+
const [group, command] = args;
|
|
100
|
+
if (!group || ['--help', '-h', 'help'].includes(group) || has(args, '--help') || has(args, '-h')) return help();
|
|
101
|
+
if (group === 'migrate') return emit(await migrateConfig());
|
|
102
|
+
if (group === 'setup') {
|
|
103
|
+
const repo=await ensureActions();
|
|
104
|
+
const chrome=await ensureDaemon();
|
|
105
|
+
return emit({status:'ready',...repo,daemon_pid:chrome.pid,managed_chrome:chrome.managed_chrome});
|
|
106
|
+
}
|
|
107
|
+
if (group === 'status') {
|
|
108
|
+
const daemon = await ensureDaemon();
|
|
109
|
+
return emit({version:'1.0.1', platform_dir:PLATFORMS, root:ROOT,
|
|
110
|
+
cli:fileURLToPath(import.meta.url),
|
|
111
|
+
admin_helper:process.env.WEBHELM_ADMIN_HELPER || (fs.existsSync(path.join(os.homedir(),'.agents/skills/webhelm/scripts/admin.mjs')) ? path.join(os.homedir(),'.agents/skills/webhelm/scripts/admin.mjs') : path.join(ROOT,'admin.mjs')),
|
|
112
|
+
managed_chrome:daemon.managed_chrome,
|
|
113
|
+
daemon:{running:true, pid:daemon.pid, session_count:daemon.session_count, queue_paused:daemon.queue_paused, socket:SOCKET}});
|
|
114
|
+
}
|
|
115
|
+
if (group === 'capacity') {
|
|
116
|
+
const payload={op:'capacity',workspace:arg(args,'--workspace')};
|
|
117
|
+
try { return emit(await request(payload)); }
|
|
118
|
+
catch(error) {
|
|
119
|
+
// Rolling upgrade: query extension metadata directly until a busy old
|
|
120
|
+
// Node daemon can drain its Sessions and load the new capacity operation.
|
|
121
|
+
if (!String(error.message).includes('unknown op: capacity')) throw error;
|
|
122
|
+
emit(await handle(payload));
|
|
123
|
+
return process.exit(0); // close this CLI's one-off CDP websocket
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (group === 'session') {
|
|
127
|
+
if (command === 'start') return emit(await request({op:'start', workspace:arg(args,'--workspace'), url:arg(args,'--url')}));
|
|
128
|
+
if (command === 'list') return emit(await request({op:'list'}));
|
|
129
|
+
if (command === 'stop') return emit(await request(has(args,'--all') ? {op:'stop-all'} : {op:'stop',session_id:args[2]}));
|
|
130
|
+
if (command === 'exec') {
|
|
131
|
+
if (!args[2]) throw Error('session exec requires SESSION_ID');
|
|
132
|
+
const file = arg(args, '--code-file');
|
|
133
|
+
const code = file ? fs.readFileSync(file, 'utf8') : fs.readFileSync(0, 'utf8');
|
|
134
|
+
const result = await request({op:'exec', session_id:args[2], code});
|
|
135
|
+
emit(result);
|
|
136
|
+
if (!result.ok) process.exitCode = 1;
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (group === 'platform') {
|
|
141
|
+
if (command === 'sync') return emit(await syncActions());
|
|
142
|
+
if (command === 'publish') return emit(await publishActions(arg(args, '--message')));
|
|
143
|
+
await ensureActions();
|
|
144
|
+
if (command === 'list') return emit(listPlatforms());
|
|
145
|
+
if (command === 'profile') return emit(platformProfile(args[2]));
|
|
146
|
+
if (command === 'source') return console.log(fs.readFileSync(actionPath(args[2],args[3]), 'utf8'));
|
|
147
|
+
if (command === 'run') {
|
|
148
|
+
const result = await runPlatform(args);
|
|
149
|
+
// Preserve the legacy caller contract even when a rolling-upgrade daemon
|
|
150
|
+
// still emits only `result.value`. Do not alter already logged stdout.
|
|
151
|
+
if(result?.result?.ok===true && result.result.value!==undefined && !String(result.result.stdout||'').trim())
|
|
152
|
+
result.result.stdout=JSON.stringify(result.result.value);
|
|
153
|
+
emit(result);
|
|
154
|
+
if (result?.result?.ok === false) process.exitCode = 1;
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
throw Error(`unknown command: ${args.join(' ')}`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
main().catch(error => { console.error(JSON.stringify({error:error.message})); process.exitCode=1; });
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
|
|
5
|
+
export const CONFIG_ROOT = path.join(os.homedir(), '.config', 'webhelm');
|
|
6
|
+
export const LEGACY_ROOT = path.join(os.homedir(), '.config', 'browser-workspace');
|
|
7
|
+
export const PROFILE_DIR = path.join(CONFIG_ROOT, 'chrome');
|
|
8
|
+
export const RUNTIME_DIR = path.join(CONFIG_ROOT, 'runtime');
|
|
9
|
+
|
|
10
|
+
// Avoid starting a second FIFO dispatcher or empty Chrome profile against legacy data.
|
|
11
|
+
export function requireMigratedConfig() {
|
|
12
|
+
if (fs.existsSync(path.join(LEGACY_ROOT, 'chrome')) && !fs.existsSync(PROFILE_DIR))
|
|
13
|
+
throw Error(`Legacy Chrome profile exists at ${LEGACY_ROOT}. Stop old Chrome and WebHelm daemon, then run webhelm migrate.`);
|
|
14
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
4
|
+
import net from 'node:net';
|
|
5
|
+
import { CONFIG_ROOT, LEGACY_ROOT } from './config-paths.mjs';
|
|
6
|
+
|
|
7
|
+
function liveSocket(socketPath) {
|
|
8
|
+
return new Promise(resolve => {
|
|
9
|
+
const socket = net.createConnection(socketPath);
|
|
10
|
+
const finish = result => { socket.destroy(); resolve(result); };
|
|
11
|
+
socket.once('connect', () => finish(true));
|
|
12
|
+
socket.once('error', () => finish(false));
|
|
13
|
+
socket.setTimeout(1200, () => finish(true)); // Fail closed if probing hangs.
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function migrateConfig({ legacyRoot = LEGACY_ROOT, configRoot = CONFIG_ROOT,
|
|
18
|
+
processList = () => execFileSync('ps', ['-axo', 'command='], { encoding: 'utf8' }),
|
|
19
|
+
socketCheck = liveSocket } = {}) {
|
|
20
|
+
const legacy = path.resolve(legacyRoot);
|
|
21
|
+
const target = path.resolve(configRoot);
|
|
22
|
+
if (legacy === target) throw Error('Legacy and new config paths are identical');
|
|
23
|
+
let oldStat;
|
|
24
|
+
try { oldStat = fs.lstatSync(legacy); }
|
|
25
|
+
catch (e) { if (e.code === 'ENOENT') return { status: 'not_needed', config_root: target }; throw e; }
|
|
26
|
+
if (oldStat.isSymbolicLink()) {
|
|
27
|
+
if (fs.existsSync(target) && fs.realpathSync(legacy) === fs.realpathSync(target))
|
|
28
|
+
return { status: 'already_migrated', config_root: target, compatibility_symlink: legacy };
|
|
29
|
+
throw Error(`Legacy path is a different symlink: ${legacy}`);
|
|
30
|
+
}
|
|
31
|
+
if (!oldStat.isDirectory()) throw Error(`Legacy path is not a directory: ${legacy}`);
|
|
32
|
+
if (fs.existsSync(target)) throw Error(`Target already exists; refusing to overwrite: ${target}`);
|
|
33
|
+
const running = processList().split('\n').filter(line =>
|
|
34
|
+
line.includes(`--user-data-dir=${path.join(legacy, 'chrome')}`) ||
|
|
35
|
+
(line.includes('node-daemon.mjs daemon') && !line.includes('migrate.mjs')));
|
|
36
|
+
if (running.length) throw Error('Chrome or WebHelm daemon is still running; stop Sessions, daemon and Chrome before migration');
|
|
37
|
+
const socket = path.join(legacy, 'runtime', 'session-node.sock');
|
|
38
|
+
if (fs.existsSync(socket) && await socketCheck(socket))
|
|
39
|
+
throw Error('Legacy daemon socket is active; refusing migration');
|
|
40
|
+
fs.renameSync(legacy, target); // Same-volume atomic directory move; preserve Chrome data and FIFO WAL.
|
|
41
|
+
try { fs.symlinkSync(target, legacy, 'dir'); }
|
|
42
|
+
catch (error) {
|
|
43
|
+
fs.renameSync(target, legacy);
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
return { status: 'migrated', config_root: target, compatibility_symlink: legacy };
|
|
47
|
+
}
|