@4ier/neo 1.2.1 → 1.3.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/package.json +1 -1
- package/tools/neo.cjs +310 -44
package/package.json
CHANGED
package/tools/neo.cjs
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
// neo tab List CDP targets in the active session
|
|
29
29
|
// neo tab <index> | neo tab --url <pat> Switch active tab target
|
|
30
30
|
// neo inject [--persist] [--tab pattern] Inject Neo capture script into page target
|
|
31
|
-
// neo snapshot [-i] [-C] [--json]
|
|
31
|
+
// neo snapshot [-i] [-C] [--json] [--diff] Snapshot a11y tree with @ref mapping
|
|
32
32
|
// neo click @ref [--new-tab] Click element by @ref
|
|
33
33
|
// neo fill @ref "text" Clear then fill element by @ref
|
|
34
34
|
// neo type @ref "text" Type text without clearing
|
|
@@ -58,7 +58,13 @@ const SCHEMA_DIR = process.env.NEO_SCHEMA_DIR || path.join(process.env.HOME, '.n
|
|
|
58
58
|
const WORKFLOW_FILE_EXT = '.workflows.json';
|
|
59
59
|
const SESSION_FILE = '/tmp/neo-sessions.json';
|
|
60
60
|
const EXTENSION_ID_CACHE_FILE = '/tmp/neo-ext-id';
|
|
61
|
-
const
|
|
61
|
+
const NEO_BASE_DIR = path.join(process.env.HOME, '.neo');
|
|
62
|
+
const NEO_PROFILE = process.env.NEO_PROFILE || null;
|
|
63
|
+
function getNeoHomeDir(profile) {
|
|
64
|
+
const p = profile || NEO_PROFILE;
|
|
65
|
+
return p ? path.join(NEO_BASE_DIR, 'profiles', p) : NEO_BASE_DIR;
|
|
66
|
+
}
|
|
67
|
+
const NEO_HOME_DIR = getNeoHomeDir();
|
|
62
68
|
const NEO_CONFIG_FILE = path.join(NEO_HOME_DIR, 'config.json');
|
|
63
69
|
const NEO_EXTENSION_DIR = path.join(NEO_HOME_DIR, 'extension');
|
|
64
70
|
const DEFAULT_SESSION_NAME = '__default__';
|
|
@@ -2151,6 +2157,7 @@ function parseSnapshotArgs(argv) {
|
|
|
2151
2157
|
interactiveOnly: false,
|
|
2152
2158
|
includeCursorPointer: false,
|
|
2153
2159
|
json: false,
|
|
2160
|
+
diff: false,
|
|
2154
2161
|
selector: null,
|
|
2155
2162
|
};
|
|
2156
2163
|
const unknown = [];
|
|
@@ -2170,6 +2177,10 @@ function parseSnapshotArgs(argv) {
|
|
|
2170
2177
|
options.json = true;
|
|
2171
2178
|
continue;
|
|
2172
2179
|
}
|
|
2180
|
+
if (current === '--diff') {
|
|
2181
|
+
options.diff = true;
|
|
2182
|
+
continue;
|
|
2183
|
+
}
|
|
2173
2184
|
if (current === '--selector') {
|
|
2174
2185
|
if (args[i + 1] && !args[i + 1].startsWith('-')) {
|
|
2175
2186
|
options.selector = args[i + 1];
|
|
@@ -2397,14 +2408,43 @@ commands.version = function() {
|
|
|
2397
2408
|
console.log(`neo v${pkg.version}`);
|
|
2398
2409
|
};
|
|
2399
2410
|
|
|
2400
|
-
// neo
|
|
2411
|
+
// neo profiles — list configured profiles
|
|
2412
|
+
commands.profiles = function() {
|
|
2413
|
+
const profilesDir = path.join(NEO_BASE_DIR, 'profiles');
|
|
2414
|
+
const profiles = [];
|
|
2415
|
+
if (fs.existsSync(profilesDir)) {
|
|
2416
|
+
for (const name of fs.readdirSync(profilesDir)) {
|
|
2417
|
+
const configPath = path.join(profilesDir, name, 'config.json');
|
|
2418
|
+
if (fs.existsSync(configPath)) {
|
|
2419
|
+
profiles.push(name);
|
|
2420
|
+
}
|
|
2421
|
+
}
|
|
2422
|
+
}
|
|
2423
|
+
const defaultExists = fs.existsSync(NEO_CONFIG_FILE);
|
|
2424
|
+
if (defaultExists) console.log(` default ${NEO_HOME_DIR}`);
|
|
2425
|
+
for (const name of profiles) {
|
|
2426
|
+
console.log(` ${name.padEnd(9)} ${path.join(profilesDir, name)}`);
|
|
2427
|
+
}
|
|
2428
|
+
if (!defaultExists && profiles.length === 0) {
|
|
2429
|
+
console.log('No profiles configured. Run neo setup to create default profile.');
|
|
2430
|
+
}
|
|
2431
|
+
};
|
|
2432
|
+
|
|
2433
|
+
// neo setup [--profile <name>]
|
|
2401
2434
|
commands.setup = async function(args) {
|
|
2402
2435
|
const { positional, flags } = parseArgs(args || []);
|
|
2403
|
-
|
|
2404
|
-
|
|
2436
|
+
const profileName = typeof flags.profile === 'string' ? flags.profile : null;
|
|
2437
|
+
if (positional.length > 0 || (Object.keys(flags).length > 0 && !('profile' in flags))) {
|
|
2438
|
+
console.error('Usage: neo setup [--profile <name>]');
|
|
2405
2439
|
process.exit(1);
|
|
2406
2440
|
}
|
|
2407
2441
|
|
|
2442
|
+
// If profile specified, override home dir for this run
|
|
2443
|
+
const homeDir = profileName ? getNeoHomeDir(profileName) : NEO_HOME_DIR;
|
|
2444
|
+
const configFile = path.join(homeDir, 'config.json');
|
|
2445
|
+
const extensionDir = path.join(homeDir, 'extension');
|
|
2446
|
+
const setupSchemaDir = path.join(homeDir, 'schemas');
|
|
2447
|
+
|
|
2408
2448
|
const chromePath = detectChromeBinaryPath();
|
|
2409
2449
|
if (!chromePath) {
|
|
2410
2450
|
throw new Error('Chrome binary not found. Tried: google-chrome-stable, google-chrome, chromium-browser, chromium');
|
|
@@ -2412,20 +2452,20 @@ commands.setup = async function(args) {
|
|
|
2412
2452
|
|
|
2413
2453
|
const projectRoot = path.join(fs.realpathSync(__dirname), '..');
|
|
2414
2454
|
const extensionSourceDir = path.join(projectRoot, 'extension-dist');
|
|
2415
|
-
const setupSchemaDir = path.join(NEO_HOME_DIR, 'schemas');
|
|
2416
2455
|
|
|
2417
|
-
fs.mkdirSync(
|
|
2418
|
-
copyDirectoryRecursive(extensionSourceDir,
|
|
2456
|
+
fs.mkdirSync(homeDir, { recursive: true });
|
|
2457
|
+
copyDirectoryRecursive(extensionSourceDir, extensionDir);
|
|
2419
2458
|
fs.mkdirSync(setupSchemaDir, { recursive: true });
|
|
2420
2459
|
|
|
2421
2460
|
const config = {
|
|
2422
2461
|
chromePath,
|
|
2423
2462
|
cdpPort: 9222,
|
|
2424
2463
|
};
|
|
2425
|
-
|
|
2464
|
+
if (profileName) config.profile = profileName;
|
|
2465
|
+
fs.writeFileSync(configFile, `${JSON.stringify(config, null, 2)}\n`, 'utf8');
|
|
2426
2466
|
|
|
2427
2467
|
// Pre-register Neo extension in Chrome Preferences (zero-config install)
|
|
2428
|
-
const profileDefaultDir = path.join(
|
|
2468
|
+
const profileDefaultDir = path.join(homeDir, 'chrome-profile', 'Default');
|
|
2429
2469
|
const prefsFile = path.join(profileDefaultDir, 'Preferences');
|
|
2430
2470
|
fs.mkdirSync(profileDefaultDir, { recursive: true });
|
|
2431
2471
|
let prefs = {};
|
|
@@ -2442,12 +2482,12 @@ commands.setup = async function(args) {
|
|
|
2442
2482
|
|
|
2443
2483
|
// Generate deterministic extension ID from path (same algorithm Chrome uses for unpacked)
|
|
2444
2484
|
const crypto = require('crypto');
|
|
2445
|
-
const extRealPath = fs.realpathSync(
|
|
2485
|
+
const extRealPath = fs.realpathSync(extensionDir);
|
|
2446
2486
|
const hashHex = crypto.createHash('sha256').update(extRealPath).digest('hex').slice(0, 32);
|
|
2447
2487
|
const extensionId = [...hashHex].map(c => String.fromCharCode('a'.charCodeAt(0) + parseInt(c, 16))).join('');
|
|
2448
2488
|
|
|
2449
2489
|
// Read extension manifest for permissions
|
|
2450
|
-
const manifest = JSON.parse(fs.readFileSync(path.join(
|
|
2490
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(extensionDir, 'manifest.json'), 'utf8'));
|
|
2451
2491
|
const permissions = manifest.permissions || [];
|
|
2452
2492
|
|
|
2453
2493
|
// Register as unpacked extension (location: 4)
|
|
@@ -2486,51 +2526,54 @@ commands.setup = async function(args) {
|
|
|
2486
2526
|
fs.writeFileSync(prefsFile, JSON.stringify(prefs, null, 2) + '\n', 'utf8');
|
|
2487
2527
|
|
|
2488
2528
|
console.log('Neo setup complete');
|
|
2529
|
+
if (profileName) console.log(` Profile: ${profileName}`);
|
|
2489
2530
|
console.log(` Chrome binary: ${chromePath}`);
|
|
2490
|
-
console.log(` Extension dir: ${
|
|
2531
|
+
console.log(` Extension dir: ${extensionDir}`);
|
|
2491
2532
|
console.log(` Extension ID: ${extensionId}`);
|
|
2492
|
-
console.log(` Config file: ${
|
|
2533
|
+
console.log(` Config file: ${configFile}`);
|
|
2493
2534
|
console.log(` Schema dir: ${setupSchemaDir}`);
|
|
2494
2535
|
console.log('');
|
|
2495
2536
|
console.log('Launch Chrome with: neo start');
|
|
2496
2537
|
};
|
|
2497
2538
|
|
|
2498
|
-
// neo start
|
|
2539
|
+
// neo start [--profile <name>] [--force]
|
|
2499
2540
|
commands.start = async function(args) {
|
|
2500
2541
|
const { positional, flags } = parseArgs(args || []);
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2542
|
+
const profileName = typeof flags.profile === 'string' ? flags.profile : null;
|
|
2543
|
+
const force = 'force' in flags;
|
|
2544
|
+
|
|
2545
|
+
const homeDir = profileName ? getNeoHomeDir(profileName) : NEO_HOME_DIR;
|
|
2546
|
+
const configFile = path.join(homeDir, 'config.json');
|
|
2547
|
+
const extensionDir = path.join(homeDir, 'extension');
|
|
2505
2548
|
|
|
2506
|
-
if (!fs.existsSync(
|
|
2507
|
-
throw new Error(`Missing config: ${
|
|
2549
|
+
if (!fs.existsSync(configFile)) {
|
|
2550
|
+
throw new Error(`Missing config: ${configFile}. Run neo setup${profileName ? ' --profile ' + profileName : ''} first`);
|
|
2508
2551
|
}
|
|
2509
2552
|
|
|
2510
2553
|
let config = null;
|
|
2511
2554
|
try {
|
|
2512
|
-
config = JSON.parse(fs.readFileSync(
|
|
2555
|
+
config = JSON.parse(fs.readFileSync(configFile, 'utf8'));
|
|
2513
2556
|
} catch {
|
|
2514
|
-
throw new Error(`Invalid config JSON: ${
|
|
2557
|
+
throw new Error(`Invalid config JSON: ${configFile}`);
|
|
2515
2558
|
}
|
|
2516
2559
|
|
|
2517
2560
|
const chromePath = String(config && config.chromePath || '').trim();
|
|
2518
2561
|
const cdpPort = parseInt(String(config && config.cdpPort !== undefined ? config.cdpPort : 9222), 10);
|
|
2519
2562
|
|
|
2520
2563
|
if (!chromePath) {
|
|
2521
|
-
throw new Error(`Missing chromePath in ${
|
|
2564
|
+
throw new Error(`Missing chromePath in ${configFile}. Run neo setup again`);
|
|
2522
2565
|
}
|
|
2523
2566
|
if (!Number.isInteger(cdpPort) || cdpPort <= 0 || cdpPort > 65535) {
|
|
2524
|
-
throw new Error(`Invalid cdpPort in ${
|
|
2567
|
+
throw new Error(`Invalid cdpPort in ${configFile}: ${config && config.cdpPort}`);
|
|
2525
2568
|
}
|
|
2526
|
-
if (!fs.existsSync(
|
|
2527
|
-
throw new Error(`Missing extension directory: ${
|
|
2569
|
+
if (!fs.existsSync(extensionDir)) {
|
|
2570
|
+
throw new Error(`Missing extension directory: ${extensionDir}. Run neo setup first`);
|
|
2528
2571
|
}
|
|
2529
2572
|
if (chromePath.includes('/') && !fs.existsSync(chromePath)) {
|
|
2530
2573
|
throw new Error(`Chrome binary does not exist: ${chromePath}`);
|
|
2531
2574
|
}
|
|
2532
2575
|
|
|
2533
|
-
const userDataDir = String(config && config.userDataDir || '').trim() || path.join(
|
|
2576
|
+
const userDataDir = String(config && config.userDataDir || '').trim() || path.join(homeDir, 'chrome-profile');
|
|
2534
2577
|
fs.mkdirSync(userDataDir, { recursive: true });
|
|
2535
2578
|
|
|
2536
2579
|
const child = spawn(chromePath, [
|
|
@@ -2781,11 +2824,11 @@ commands.inject = async function(args, context = {}) {
|
|
|
2781
2824
|
console.log(`Injected Neo capture script${persisted}`);
|
|
2782
2825
|
};
|
|
2783
2826
|
|
|
2784
|
-
// neo snapshot [-i] [-C] [--json] [--selector css]
|
|
2827
|
+
// neo snapshot [-i] [-C] [--json] [--diff] [--selector css]
|
|
2785
2828
|
commands.snapshot = async function(args, context = {}) {
|
|
2786
2829
|
const { options, unknown } = parseSnapshotArgs(args || []);
|
|
2787
2830
|
if (unknown.length > 0) {
|
|
2788
|
-
console.error('Usage: neo snapshot [-i] [-C] [--json] [--selector css]');
|
|
2831
|
+
console.error('Usage: neo snapshot [-i] [-C] [--json] [--diff] [--selector css]');
|
|
2789
2832
|
process.exit(1);
|
|
2790
2833
|
}
|
|
2791
2834
|
|
|
@@ -2803,11 +2846,12 @@ commands.snapshot = async function(args, context = {}) {
|
|
|
2803
2846
|
const displayNodes = options.interactiveOnly
|
|
2804
2847
|
? assigned.nodes.filter(node => INTERACTIVE_ROLES.has(String(node.role || '').toLowerCase()))
|
|
2805
2848
|
: assigned.nodes;
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2849
|
+
const storedSnapshot = displayNodes.map(node => ({
|
|
2850
|
+
ref: node.ref,
|
|
2851
|
+
role: node.role,
|
|
2852
|
+
name: node.name,
|
|
2853
|
+
depth: node.depth,
|
|
2854
|
+
}));
|
|
2811
2855
|
|
|
2812
2856
|
if (options.includeCursorPointer) {
|
|
2813
2857
|
// TODO: Add Runtime.evaluate scan for cursor:pointer elements.
|
|
@@ -2818,6 +2862,59 @@ commands.snapshot = async function(args, context = {}) {
|
|
|
2818
2862
|
console.error('TODO: snapshot --selector is not implemented yet');
|
|
2819
2863
|
}
|
|
2820
2864
|
|
|
2865
|
+
if (options.diff) {
|
|
2866
|
+
const prev = Array.isArray(session.prevSnapshot) ? session.prevSnapshot : [];
|
|
2867
|
+
const prevMap = new Map(prev.map(node => [`${node.role}:${node.name}`, node]));
|
|
2868
|
+
const currMap = new Map(displayNodes.map(node => [`${node.role}:${node.name}`, node]));
|
|
2869
|
+
|
|
2870
|
+
const added = displayNodes.filter(node => !prevMap.has(`${node.role}:${node.name}`));
|
|
2871
|
+
const removed = prev.filter(node => !currMap.has(`${node.role}:${node.name}`));
|
|
2872
|
+
const changed = displayNodes.filter((node) => {
|
|
2873
|
+
const previousNode = prevMap.get(`${node.role}:${node.name}`);
|
|
2874
|
+
return previousNode && previousNode.depth !== node.depth;
|
|
2875
|
+
});
|
|
2876
|
+
|
|
2877
|
+
const latestSession = getSession(sessionName) || session || {};
|
|
2878
|
+
setSession(sessionName, {
|
|
2879
|
+
...latestSession,
|
|
2880
|
+
refs: assigned.refs,
|
|
2881
|
+
prevSnapshot: storedSnapshot,
|
|
2882
|
+
});
|
|
2883
|
+
|
|
2884
|
+
if (options.json) {
|
|
2885
|
+
console.log(JSON.stringify({ added, removed, changed }, null, 2));
|
|
2886
|
+
return;
|
|
2887
|
+
}
|
|
2888
|
+
|
|
2889
|
+
const lines = [];
|
|
2890
|
+
if (added.length) {
|
|
2891
|
+
lines.push(`+ Added (${added.length}):`);
|
|
2892
|
+
lines.push(formatSnapshot(added));
|
|
2893
|
+
}
|
|
2894
|
+
if (removed.length) {
|
|
2895
|
+
lines.push(`- Removed (${removed.length}):`);
|
|
2896
|
+
for (const node of removed) {
|
|
2897
|
+
const indent = ' '.repeat(node.depth || 0);
|
|
2898
|
+
lines.push(` ${indent}${node.ref} [${node.role}] "${node.name}"`);
|
|
2899
|
+
}
|
|
2900
|
+
}
|
|
2901
|
+
if (changed.length) {
|
|
2902
|
+
lines.push(`~ Changed (${changed.length}):`);
|
|
2903
|
+
lines.push(formatSnapshot(changed));
|
|
2904
|
+
}
|
|
2905
|
+
if (lines.length === 0) {
|
|
2906
|
+
lines.push('(no changes)');
|
|
2907
|
+
}
|
|
2908
|
+
console.log(lines.join('\n'));
|
|
2909
|
+
return;
|
|
2910
|
+
}
|
|
2911
|
+
|
|
2912
|
+
setSession(sessionName, {
|
|
2913
|
+
...session,
|
|
2914
|
+
refs: assigned.refs,
|
|
2915
|
+
prevSnapshot: storedSnapshot,
|
|
2916
|
+
});
|
|
2917
|
+
|
|
2821
2918
|
if (options.json) {
|
|
2822
2919
|
const refs = {};
|
|
2823
2920
|
for (const node of displayNodes) {
|
|
@@ -4802,11 +4899,13 @@ commands.read = async function(args) {
|
|
|
4802
4899
|
|
|
4803
4900
|
commands.bridge = async function(args) {
|
|
4804
4901
|
const { WebSocketServer } = require('ws');
|
|
4902
|
+
const http = require('http');
|
|
4805
4903
|
const port = parseInt(args.find(a => /^\d+$/.test(a)) || '9234', 10);
|
|
4806
4904
|
const json = args.includes('--json');
|
|
4807
4905
|
const quiet = args.includes('--quiet');
|
|
4906
|
+
const wsOnly = args.includes('--ws-only');
|
|
4808
4907
|
|
|
4809
|
-
const wss = new WebSocketServer({
|
|
4908
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
4810
4909
|
const clients = new Set();
|
|
4811
4910
|
const pendingResponses = new Map(); // id → { resolve, timer }
|
|
4812
4911
|
let cmdIdCounter = 0;
|
|
@@ -4824,9 +4923,143 @@ commands.bridge = async function(args) {
|
|
|
4824
4923
|
|
|
4825
4924
|
if (!quiet) {
|
|
4826
4925
|
process.stderr.write(`[Neo Bridge] listening on ws://127.0.0.1:${port}\n`);
|
|
4926
|
+
if (!wsOnly) process.stderr.write(`[Neo Bridge] REST API at http://127.0.0.1:${port}\n`);
|
|
4827
4927
|
process.stderr.write(`[Neo Bridge] waiting for extension to connect...\n`);
|
|
4828
4928
|
}
|
|
4829
4929
|
|
|
4930
|
+
// ─── HTTP REST handler ───────────────────────────────────
|
|
4931
|
+
function jsonResponse(res, status, data) {
|
|
4932
|
+
const body = JSON.stringify(data, null, 2);
|
|
4933
|
+
res.writeHead(status, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
|
|
4934
|
+
res.end(body);
|
|
4935
|
+
}
|
|
4936
|
+
|
|
4937
|
+
function readBody(req) {
|
|
4938
|
+
return new Promise((resolve) => {
|
|
4939
|
+
let body = '';
|
|
4940
|
+
req.on('data', c => body += c);
|
|
4941
|
+
req.on('end', () => resolve(body));
|
|
4942
|
+
});
|
|
4943
|
+
}
|
|
4944
|
+
|
|
4945
|
+
async function handleHttpRequest(req, res) {
|
|
4946
|
+
if (req.method === 'OPTIONS') {
|
|
4947
|
+
res.writeHead(204, {
|
|
4948
|
+
'Access-Control-Allow-Origin': '*',
|
|
4949
|
+
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
4950
|
+
'Access-Control-Allow-Headers': 'Content-Type',
|
|
4951
|
+
});
|
|
4952
|
+
res.end();
|
|
4953
|
+
return;
|
|
4954
|
+
}
|
|
4955
|
+
|
|
4956
|
+
const url = new URL(req.url, 'http://localhost');
|
|
4957
|
+
const pathname = url.pathname;
|
|
4958
|
+
|
|
4959
|
+
try {
|
|
4960
|
+
// GET /status
|
|
4961
|
+
if (pathname === '/status' && req.method === 'GET') {
|
|
4962
|
+
return jsonResponse(res, 200, { ok: true, data: { clients: clients.size, uptime: process.uptime() } });
|
|
4963
|
+
}
|
|
4964
|
+
|
|
4965
|
+
// GET /captures?domain=...&limit=...
|
|
4966
|
+
if (pathname === '/captures' && req.method === 'GET') {
|
|
4967
|
+
const cmdArgs = {};
|
|
4968
|
+
if (url.searchParams.has('domain')) cmdArgs.domain = url.searchParams.get('domain');
|
|
4969
|
+
if (url.searchParams.has('limit')) cmdArgs.limit = parseInt(url.searchParams.get('limit'));
|
|
4970
|
+
const resp = await sendCommand('capture.list', cmdArgs);
|
|
4971
|
+
return jsonResponse(res, 200, { ok: true, data: resp.result });
|
|
4972
|
+
}
|
|
4973
|
+
|
|
4974
|
+
// GET /captures/count
|
|
4975
|
+
if (pathname === '/captures/count' && req.method === 'GET') {
|
|
4976
|
+
const resp = await sendCommand('capture.count', {});
|
|
4977
|
+
return jsonResponse(res, 200, { ok: true, data: resp.result });
|
|
4978
|
+
}
|
|
4979
|
+
|
|
4980
|
+
// GET /captures/domains
|
|
4981
|
+
if (pathname === '/captures/domains' && req.method === 'GET') {
|
|
4982
|
+
const resp = await sendCommand('capture.domains', {});
|
|
4983
|
+
return jsonResponse(res, 200, { ok: true, data: resp.result });
|
|
4984
|
+
}
|
|
4985
|
+
|
|
4986
|
+
// GET /tabs
|
|
4987
|
+
if (pathname === '/tabs' && req.method === 'GET') {
|
|
4988
|
+
const tabs = await (await fetch(`${CDP_URL}/json/list`)).json();
|
|
4989
|
+
const pages = tabs.filter(t => t.type === 'page');
|
|
4990
|
+
return jsonResponse(res, 200, { ok: true, data: pages.map(t => ({ title: t.title, url: t.url, id: t.id })) });
|
|
4991
|
+
}
|
|
4992
|
+
|
|
4993
|
+
// GET /schemas
|
|
4994
|
+
if (pathname === '/schemas' && req.method === 'GET') {
|
|
4995
|
+
const files = fs.existsSync(SCHEMA_DIR) ? fs.readdirSync(SCHEMA_DIR).filter(f => f.endsWith('.json')) : [];
|
|
4996
|
+
const schemas = files.map(f => f.replace('.json', ''));
|
|
4997
|
+
return jsonResponse(res, 200, { ok: true, data: schemas });
|
|
4998
|
+
}
|
|
4999
|
+
|
|
5000
|
+
// GET /schemas/:domain
|
|
5001
|
+
if (pathname.startsWith('/schemas/') && req.method === 'GET') {
|
|
5002
|
+
const domain = pathname.slice('/schemas/'.length);
|
|
5003
|
+
const schemaPath = path.join(SCHEMA_DIR, `${domain}.json`);
|
|
5004
|
+
if (!fs.existsSync(schemaPath)) {
|
|
5005
|
+
return jsonResponse(res, 404, { ok: false, error: `No schema for ${domain}` });
|
|
5006
|
+
}
|
|
5007
|
+
const schema = JSON.parse(fs.readFileSync(schemaPath, 'utf8'));
|
|
5008
|
+
return jsonResponse(res, 200, { ok: true, data: schema });
|
|
5009
|
+
}
|
|
5010
|
+
|
|
5011
|
+
// GET /snapshot?interactive=true
|
|
5012
|
+
if (pathname === '/snapshot' && req.method === 'GET') {
|
|
5013
|
+
const sessionName = DEFAULT_SESSION_NAME;
|
|
5014
|
+
const session = getSession(sessionName);
|
|
5015
|
+
if (!session || !session.pageWsUrl) {
|
|
5016
|
+
return jsonResponse(res, 400, { ok: false, error: 'No active session. Run neo connect first.' });
|
|
5017
|
+
}
|
|
5018
|
+
await cdpSend(session.pageWsUrl, 'Accessibility.enable');
|
|
5019
|
+
const treeResult = await cdpSend(session.pageWsUrl, 'Accessibility.getFullAXTree');
|
|
5020
|
+
const assigned = assignRefs(treeResult && Array.isArray(treeResult.nodes) ? treeResult.nodes : []);
|
|
5021
|
+
const interactiveOnly = url.searchParams.get('interactive') === 'true';
|
|
5022
|
+
const nodes = interactiveOnly
|
|
5023
|
+
? assigned.nodes.filter(node => INTERACTIVE_ROLES.has(String(node.role || '').toLowerCase()))
|
|
5024
|
+
: assigned.nodes;
|
|
5025
|
+
setSession(sessionName, { ...session, refs: assigned.refs });
|
|
5026
|
+
return jsonResponse(res, 200, { ok: true, data: { count: nodes.length, nodes } });
|
|
5027
|
+
}
|
|
5028
|
+
|
|
5029
|
+
// POST /eval { expression: "..." }
|
|
5030
|
+
if (pathname === '/eval' && req.method === 'POST') {
|
|
5031
|
+
const body = JSON.parse(await readBody(req));
|
|
5032
|
+
const sessionName = DEFAULT_SESSION_NAME;
|
|
5033
|
+
const session = getSession(sessionName);
|
|
5034
|
+
if (!session || !session.pageWsUrl) {
|
|
5035
|
+
return jsonResponse(res, 400, { ok: false, error: 'No active session' });
|
|
5036
|
+
}
|
|
5037
|
+
const result = await cdpSend(session.pageWsUrl, 'Runtime.evaluate', {
|
|
5038
|
+
expression: body.expression,
|
|
5039
|
+
returnByValue: true,
|
|
5040
|
+
});
|
|
5041
|
+
return jsonResponse(res, 200, { ok: true, data: result.result });
|
|
5042
|
+
}
|
|
5043
|
+
|
|
5044
|
+
return jsonResponse(res, 404, { ok: false, error: 'Not found' });
|
|
5045
|
+
} catch (err) {
|
|
5046
|
+
return jsonResponse(res, 500, { ok: false, error: err.message });
|
|
5047
|
+
}
|
|
5048
|
+
}
|
|
5049
|
+
|
|
5050
|
+
// ─── HTTP Server + WebSocket upgrade ─────────────────────
|
|
5051
|
+
const server = http.createServer(wsOnly ? (req, res) => {
|
|
5052
|
+
jsonResponse(res, 404, { ok: false, error: 'REST disabled (--ws-only)' });
|
|
5053
|
+
} : handleHttpRequest);
|
|
5054
|
+
|
|
5055
|
+
server.on('upgrade', (req, socket, head) => {
|
|
5056
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
5057
|
+
wss.emit('connection', ws, req);
|
|
5058
|
+
});
|
|
5059
|
+
});
|
|
5060
|
+
|
|
5061
|
+
server.listen(port);
|
|
5062
|
+
|
|
4830
5063
|
wss.on('connection', (ws) => {
|
|
4831
5064
|
clients.add(ws);
|
|
4832
5065
|
if (!quiet) process.stderr.write(`[Neo Bridge] extension connected (${clients.size} client${clients.size > 1 ? 's' : ''})\n`);
|
|
@@ -5854,14 +6087,22 @@ commands.reload = async function() {
|
|
|
5854
6087
|
};
|
|
5855
6088
|
|
|
5856
6089
|
// neo doctor — diagnose setup issues
|
|
5857
|
-
commands.doctor = async function() {
|
|
6090
|
+
commands.doctor = async function(args) {
|
|
6091
|
+
const fix = (args || []).includes('--fix');
|
|
5858
6092
|
const checks = [];
|
|
5859
|
-
function check(name, fn) { checks.push({ name, fn }); }
|
|
6093
|
+
function check(name, fn, fixFn) { checks.push({ name, fn, fixFn }); }
|
|
5860
6094
|
|
|
5861
6095
|
check('Chrome CDP endpoint', async () => {
|
|
5862
6096
|
const resp = await fetch(`${CDP_URL}/json/version`);
|
|
5863
6097
|
const info = await resp.json();
|
|
5864
6098
|
return `${info.Browser} (${CDP_URL})`;
|
|
6099
|
+
}, async () => {
|
|
6100
|
+
process.stderr.write(' → Starting Chrome with neo start...\n');
|
|
6101
|
+
await commands.start([]);
|
|
6102
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
6103
|
+
const resp = await fetch(`${CDP_URL}/json/version`);
|
|
6104
|
+
const info = await resp.json();
|
|
6105
|
+
return `Fixed: ${info.Browser} (${CDP_URL})`;
|
|
5865
6106
|
});
|
|
5866
6107
|
|
|
5867
6108
|
check('Browser tabs', async () => {
|
|
@@ -5876,6 +6117,15 @@ commands.doctor = async function() {
|
|
|
5876
6117
|
const extensionId = parseExtensionIdFromUrl(sw.url);
|
|
5877
6118
|
if (!extensionId) return 'OK';
|
|
5878
6119
|
return `OK (${extensionId.slice(0, 8)}…)`;
|
|
6120
|
+
}, async () => {
|
|
6121
|
+
process.stderr.write(' → Running neo setup to install extension...\n');
|
|
6122
|
+
await commands.setup([]);
|
|
6123
|
+
process.stderr.write(' → Restarting Chrome...\n');
|
|
6124
|
+
await commands.start(['--force']);
|
|
6125
|
+
await new Promise(r => setTimeout(r, 3000));
|
|
6126
|
+
const sw = await findExtensionServiceWorker({ cdpUrl: CDP_URL });
|
|
6127
|
+
if (!sw) throw new Error('Still not found after fix');
|
|
6128
|
+
return 'Fixed: extension installed and loaded';
|
|
5879
6129
|
});
|
|
5880
6130
|
|
|
5881
6131
|
check('IndexedDB captures', async () => {
|
|
@@ -5890,6 +6140,9 @@ commands.doctor = async function() {
|
|
|
5890
6140
|
if (!fs.existsSync(SCHEMA_DIR)) throw new Error(`Missing: ${SCHEMA_DIR}`);
|
|
5891
6141
|
const files = fs.readdirSync(SCHEMA_DIR).filter(f => f.endsWith('.json'));
|
|
5892
6142
|
return `${files.length} schema(s) in ${SCHEMA_DIR}`;
|
|
6143
|
+
}, async () => {
|
|
6144
|
+
fs.mkdirSync(SCHEMA_DIR, { recursive: true });
|
|
6145
|
+
return `Fixed: created ${SCHEMA_DIR}`;
|
|
5893
6146
|
});
|
|
5894
6147
|
|
|
5895
6148
|
check('WebSocket bridge port', async () => {
|
|
@@ -5901,14 +6154,26 @@ commands.doctor = async function() {
|
|
|
5901
6154
|
});
|
|
5902
6155
|
});
|
|
5903
6156
|
|
|
5904
|
-
|
|
6157
|
+
let fixed = 0;
|
|
6158
|
+
for (const { name, fn, fixFn } of checks) {
|
|
5905
6159
|
try {
|
|
5906
6160
|
const result = await fn();
|
|
5907
6161
|
console.log(` ✓ ${name}: ${result}`);
|
|
5908
6162
|
} catch (err) {
|
|
5909
|
-
|
|
6163
|
+
if (fix && fixFn) {
|
|
6164
|
+
try {
|
|
6165
|
+
const result = await fixFn();
|
|
6166
|
+
console.log(` ✓ ${name}: ${result}`);
|
|
6167
|
+
fixed++;
|
|
6168
|
+
} catch (fixErr) {
|
|
6169
|
+
console.log(` ✗ ${name}: ${fixErr.message}`);
|
|
6170
|
+
}
|
|
6171
|
+
} else {
|
|
6172
|
+
console.log(` ✗ ${name}: ${err.message}${fixFn ? ' (use --fix to auto-repair)' : ''}`);
|
|
6173
|
+
}
|
|
5910
6174
|
}
|
|
5911
6175
|
}
|
|
6176
|
+
if (fix && fixed > 0) console.log(`\n Fixed ${fixed} issue(s).`);
|
|
5912
6177
|
};
|
|
5913
6178
|
|
|
5914
6179
|
async function main() {
|
|
@@ -5933,8 +6198,9 @@ Commands:
|
|
|
5933
6198
|
neo eval "<js>" --tab <pattern> Evaluate JS in page context
|
|
5934
6199
|
neo open <url> Open URL in Chrome
|
|
5935
6200
|
neo read <tab-pattern> Extract readable text from page
|
|
5936
|
-
neo setup
|
|
5937
|
-
neo start
|
|
6201
|
+
neo setup [--profile <name>] Setup ~/.neo config, extension, and schemas
|
|
6202
|
+
neo start [--profile <name>] Start Chrome with configured Neo extension
|
|
6203
|
+
neo profiles List configured profiles
|
|
5938
6204
|
neo launch <app> [--port N] Launch Electron app with CDP enabled
|
|
5939
6205
|
neo connect [port] Connect to CDP and save active session
|
|
5940
6206
|
neo connect --electron <app-name> Auto-discover Electron CDP port and connect
|
|
@@ -5943,7 +6209,7 @@ Commands:
|
|
|
5943
6209
|
neo tab List CDP targets in the active session
|
|
5944
6210
|
neo tab <index> | neo tab --url <pat> Switch active tab target
|
|
5945
6211
|
neo inject [--persist] [--tab pattern] Inject Neo capture script into page target
|
|
5946
|
-
neo snapshot [-i] [-C] [--json]
|
|
6212
|
+
neo snapshot [-i] [-C] [--json] [--diff] Snapshot a11y tree with @ref mapping
|
|
5947
6213
|
neo click @ref [--new-tab] Click element by @ref
|
|
5948
6214
|
neo fill @ref "text" Clear then fill element by @ref
|
|
5949
6215
|
neo type @ref "text" Type text without clearing
|
|
@@ -5964,7 +6230,7 @@ Commands:
|
|
|
5964
6230
|
neo export-skill <domain> Generate agent-ready API reference
|
|
5965
6231
|
neo mock <domain> [--port N] Generate mock server from schema
|
|
5966
6232
|
neo bridge [port] [--json] [--quiet] Start WebSocket bridge server
|
|
5967
|
-
neo doctor
|
|
6233
|
+
neo doctor [--fix] Diagnose setup issues (--fix to auto-repair)
|
|
5968
6234
|
neo reload Reload the Neo extension
|
|
5969
6235
|
|
|
5970
6236
|
Options (for exec):
|