@dassi_ai/cli 0.3.0 → 0.5.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/.claude-plugin/plugin.json +1 -1
- package/README.md +30 -0
- package/_launch-pipe.mjs +102 -0
- package/daemon-client.mjs +227 -0
- package/dassi.mjs +41 -225
- package/help-text.mjs +3 -1
- package/launch.mjs +235 -54
- package/package.json +4 -2
- package/skills/operate/command-reference.md +15 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dassi",
|
|
3
3
|
"description": "Drive the Dassi Chrome extension from Claude Code. Pick tabs/groups, then run AI agent or browser tools against them.",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.3",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Omnify Labs",
|
|
7
7
|
"email": "team@dassi.ai"
|
package/README.md
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
Standalone CLI for the [Dassi](../extension/README.md) Chrome extension — run browser automation from the terminal.
|
|
4
4
|
|
|
5
|
+
## Quickstart
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# 1. Install the Dassi extension from the Chrome Web Store and sign in.
|
|
9
|
+
# 2. Open the Dassi side panel once — this wakes the extension so the CLI can reach it.
|
|
10
|
+
# 3. Run a command (no install needed):
|
|
11
|
+
npx @dassi_ai/cli@latest list-tabs # find a TAB ID
|
|
12
|
+
npx @dassi_ai/cli@latest run "summarize this page" --tab <id>
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Prefer a permanent `dassi` command? `npm install -g @dassi_ai/cli` (Node ≥ 20.11), then drop the `npx @dassi_ai/cli@latest` prefix.
|
|
16
|
+
|
|
17
|
+
Two things that trip people up:
|
|
18
|
+
- **`run` needs a target** — pass `--tab <id>` (or `--group <id>` / `--group-title <name>`). `--profile` only selects *which* connected Chrome, not the tab.
|
|
19
|
+
- **Multiple Chrome windows/profiles connected?** Commands need `--profile <label>`; run `dassi list-profiles` to see the labels (one profile = no flag needed).
|
|
20
|
+
|
|
5
21
|
## Installation
|
|
6
22
|
|
|
7
23
|
```bash
|
|
@@ -62,9 +78,23 @@ dassi launch --label qa --dist some/dist # custom label + dist
|
|
|
62
78
|
dassi list-tabs --profile dev # find a tab id in the launched profile
|
|
63
79
|
dassi run "summarize this page" --tab <id> --profile dev
|
|
64
80
|
dassi launch --stop # close the "dev" Chrome (or --stop-all)
|
|
81
|
+
# Pass extra args to Chrome by appending `-- <args>` after all CLI flags
|
|
82
|
+
# (forwarded verbatim in both load modes; rejected on non-launch commands):
|
|
83
|
+
dassi launch -- --headless=new --remote-debugging-port=9222
|
|
65
84
|
# Note: launch reuses the default daemon (port 18790). To launch on an isolated
|
|
66
85
|
# port (DASSI_BRIDGE_PORT=18791 dassi launch), no default daemon may be running —
|
|
67
86
|
# it errors clearly otherwise, since it can't confirm the running daemon's port.
|
|
87
|
+
#
|
|
88
|
+
# How the extension is loaded (auto-detected per Chrome binary):
|
|
89
|
+
# • Branded Google Chrome (137+) disabled the `--load-extension` flag, so launch
|
|
90
|
+
# installs the dist at runtime via the `Extensions.loadUnpacked` CDP command over
|
|
91
|
+
# `--remote-debugging-pipe`. Because such an extension lives only as long as the
|
|
92
|
+
# debugging pipe, launch spawns a detached `__launch-hold` helper that keeps the
|
|
93
|
+
# pipe open; `--stop` kills the helper, which closes the pipe and its Chrome.
|
|
94
|
+
# • Chrome for Testing / Chromium still honour `--load-extension` (persistent), so
|
|
95
|
+
# launch uses that directly there — no helper. Point at one with `--chrome <path>`.
|
|
96
|
+
# • The mode is auto-detected from the binary; override with `--load-mode auto|pipe|flag`
|
|
97
|
+
# (e.g. `dassi launch --chrome <cft> --load-mode pipe` to exercise the pipe path on CfT).
|
|
68
98
|
|
|
69
99
|
# Show version / help
|
|
70
100
|
dassi --version
|
package/_launch-pipe.mjs
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CDP-over-pipe transport for `dassi launch`. Chrome 137+ branded builds disabled the
|
|
3
|
+
* `--load-extension` flag, so the dist is installed at runtime via the
|
|
4
|
+
* `Extensions.loadUnpacked` CDP command over `--remote-debugging-pipe`. This module is
|
|
5
|
+
* the minimal, dependency-free client for that exchange.
|
|
6
|
+
*
|
|
7
|
+
* PRIVATE (underscore-prefixed): import these symbols from `launch.mjs`, which re-exports
|
|
8
|
+
* them. `launch.mjs` is the single public gateway for the launch module.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Minimal CDP client over Chrome's `--remote-debugging-pipe` transport. Messages are
|
|
13
|
+
* JSON framed with a trailing NUL (`\0`) byte; replies are matched back by `id`.
|
|
14
|
+
* No external deps — the CLI only needs to issue a couple of commands at launch.
|
|
15
|
+
* @param {{ writable: import('stream').Writable; readable: import('stream').Readable }} pipe
|
|
16
|
+
* `writable` = Chrome's CDP input (fd 3); `readable` = Chrome's CDP output (fd 4).
|
|
17
|
+
* @returns {{ send: (method: string, params?: object) => Promise<object>; dispose: () => void }}
|
|
18
|
+
*/
|
|
19
|
+
export function cdpPipeClient({ writable, readable }) {
|
|
20
|
+
let nextId = 1;
|
|
21
|
+
const pending = new Map();
|
|
22
|
+
let buf = Buffer.alloc(0);
|
|
23
|
+
const onData = (chunk) => {
|
|
24
|
+
buf = Buffer.concat([buf, chunk]);
|
|
25
|
+
let nul;
|
|
26
|
+
// Reason: a single chunk may carry several NUL-delimited frames, or a partial one —
|
|
27
|
+
// drain every complete frame and keep the remainder buffered for the next chunk.
|
|
28
|
+
while ((nul = buf.indexOf(0)) !== -1) {
|
|
29
|
+
const frame = buf.subarray(0, nul).toString('utf8');
|
|
30
|
+
buf = buf.subarray(nul + 1);
|
|
31
|
+
if (!frame) continue;
|
|
32
|
+
let msg;
|
|
33
|
+
try { msg = JSON.parse(frame); } catch { continue; }
|
|
34
|
+
const entry = msg.id != null ? pending.get(msg.id) : undefined;
|
|
35
|
+
if (!entry) continue; // an event, or a reply we're not waiting on
|
|
36
|
+
pending.delete(msg.id);
|
|
37
|
+
if (msg.error) entry.reject(new Error(msg.error.message || `CDP error ${msg.error.code ?? ''}`));
|
|
38
|
+
else entry.resolve(msg.result ?? {});
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
readable.on('data', onData);
|
|
42
|
+
return {
|
|
43
|
+
send(method, params = {}) {
|
|
44
|
+
const id = nextId++;
|
|
45
|
+
return new Promise((resolve, reject) => {
|
|
46
|
+
pending.set(id, { resolve, reject });
|
|
47
|
+
writable.write(`${JSON.stringify({ id, method, params })}\0`);
|
|
48
|
+
});
|
|
49
|
+
},
|
|
50
|
+
dispose() {
|
|
51
|
+
readable.off('data', onData);
|
|
52
|
+
for (const { reject } of pending.values()) reject(new Error('CDP pipe disposed'));
|
|
53
|
+
pending.clear();
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Install the unpacked dist into the just-launched Chrome over the CDP pipe, then
|
|
60
|
+
* open the seed options page (so the extension persists label+bridgePort and dials
|
|
61
|
+
* the daemon). This is the runtime replacement for the disabled `--load-extension`.
|
|
62
|
+
*
|
|
63
|
+
* On success the pipe is left OPEN (still draining Chrome's CDP output so its buffer
|
|
64
|
+
* can't fill) and a `dispose()` is returned — the caller MUST call it once the
|
|
65
|
+
* profile has registered. Reason: branded Chrome discards an automation-created tab
|
|
66
|
+
* (the seed page) if the debugging pipe closes before it finishes loading; keeping
|
|
67
|
+
* the pipe alive until the bridge registers lets the seed run.
|
|
68
|
+
* @param {{ child: import('child_process').ChildProcess; distPath: string; label: string; bridgePort: number; timeoutMs?: number }} opts
|
|
69
|
+
* @returns {Promise<{ id: string; dispose: () => void }>} the loaded extension id + a pipe closer.
|
|
70
|
+
* @throws if the pipe fds are missing or a CDP command errors/times out.
|
|
71
|
+
*/
|
|
72
|
+
export async function loadExtensionOverPipe({ child, distPath, label, bridgePort, timeoutMs = 15000 }) {
|
|
73
|
+
const writable = child.stdio?.[3];
|
|
74
|
+
const readable = child.stdio?.[4];
|
|
75
|
+
if (!writable || !readable) {
|
|
76
|
+
throw new Error('Chrome was not spawned with a CDP pipe (fds 3/4 missing).');
|
|
77
|
+
}
|
|
78
|
+
const cdp = cdpPipeClient({ writable, readable });
|
|
79
|
+
// Reason: capture + clear the timer so a fast-resolving CDP command doesn't leave a
|
|
80
|
+
// pending setTimeout keeping the event loop alive for the full timeoutMs.
|
|
81
|
+
const withTimeout = (p, what) => {
|
|
82
|
+
let timer;
|
|
83
|
+
const timeout = new Promise((_, r) => {
|
|
84
|
+
timer = setTimeout(() => r(new Error(`CDP ${what} timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
85
|
+
});
|
|
86
|
+
return Promise.race([p, timeout]).finally(() => clearTimeout(timer));
|
|
87
|
+
};
|
|
88
|
+
try {
|
|
89
|
+
const loaded = await withTimeout(cdp.send('Extensions.loadUnpacked', { path: distPath }), 'Extensions.loadUnpacked');
|
|
90
|
+
const id = loaded.id;
|
|
91
|
+
if (!id) throw new Error('Extensions.loadUnpacked returned no extension id.');
|
|
92
|
+
// Reason: open the seed page AFTER the extension exists (it isn't loaded at
|
|
93
|
+
// startup anymore), so the options page persists label+bridgePort and the
|
|
94
|
+
// extension's bridge connects to the daemon's actual port.
|
|
95
|
+
const seed = `chrome-extension://${id}/options.html?label=${encodeURIComponent(label)}&bridgePort=${bridgePort}`;
|
|
96
|
+
await withTimeout(cdp.send('Target.createTarget', { url: seed }), 'Target.createTarget');
|
|
97
|
+
return { id, dispose: () => cdp.dispose() };
|
|
98
|
+
} catch (err) {
|
|
99
|
+
cdp.dispose();
|
|
100
|
+
throw err;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daemon client — the CLI side of the daemon protocol. Extracted from
|
|
3
|
+
* dassi.mjs to keep that file within the CLAUDE.md 500-line limit.
|
|
4
|
+
*
|
|
5
|
+
* Covers spawning the daemon, waiting for its ready file, the Unix-socket
|
|
6
|
+
* NDJSON transport, and the interactive onboarding flow (install prompt +
|
|
7
|
+
* sign-in poll loop).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import * as net from 'net';
|
|
11
|
+
import * as fs from 'fs';
|
|
12
|
+
import * as path from 'path';
|
|
13
|
+
import * as child_process from 'child_process';
|
|
14
|
+
import { fileURLToPath } from 'url';
|
|
15
|
+
import {
|
|
16
|
+
getSocketPath,
|
|
17
|
+
getReadyFile,
|
|
18
|
+
isDaemonRunning,
|
|
19
|
+
parseReadyPayload,
|
|
20
|
+
DASSI_EXTENSION_ID,
|
|
21
|
+
} from './dassi-shared.mjs';
|
|
22
|
+
|
|
23
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
const DAEMON_SCRIPT = path.join(__dirname, 'dassi-daemon.mjs');
|
|
25
|
+
const READY_POLL_MS = 100;
|
|
26
|
+
const READY_TIMEOUT_MS = 30_000;
|
|
27
|
+
const LOGIN_POLL_MS = 2_000;
|
|
28
|
+
const LOGIN_TIMEOUT_MS = 5 * 60_000;
|
|
29
|
+
const CHROME_WEB_STORE_URL = `https://chromewebstore.google.com/detail/dassi-ai-browser-agent-fo/${DASSI_EXTENSION_ID}`;
|
|
30
|
+
|
|
31
|
+
// ─── Daemon management ────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Ensures the daemon is running for the given session.
|
|
35
|
+
* Spawns it as a detached background process if the PID file is missing or stale.
|
|
36
|
+
* @param {string} session
|
|
37
|
+
*/
|
|
38
|
+
export function ensureDaemonRunning(session) {
|
|
39
|
+
if (isDaemonRunning(session)) return;
|
|
40
|
+
|
|
41
|
+
// Reason: detached + unref means the daemon outlives the CLI process
|
|
42
|
+
const daemon = child_process.spawn(process.execPath, [DAEMON_SCRIPT], {
|
|
43
|
+
detached: true,
|
|
44
|
+
stdio: 'ignore',
|
|
45
|
+
env: { ...process.env, DASSI_SESSION: session },
|
|
46
|
+
});
|
|
47
|
+
daemon.unref();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Polls for the ready file until it appears or the timeout elapses.
|
|
52
|
+
* Resolves with the parsed ready payload.
|
|
53
|
+
* @param {string} readyFile
|
|
54
|
+
* @param {number} [timeoutMs]
|
|
55
|
+
* @returns {Promise<{ status: string; [key: string]: unknown }>}
|
|
56
|
+
*/
|
|
57
|
+
export function waitForReady(readyFile, timeoutMs = READY_TIMEOUT_MS) {
|
|
58
|
+
return new Promise((resolve, reject) => {
|
|
59
|
+
const deadline = Date.now() + timeoutMs;
|
|
60
|
+
const check = () => {
|
|
61
|
+
if (fs.existsSync(readyFile)) {
|
|
62
|
+
try {
|
|
63
|
+
resolve(parseReadyPayload(fs.readFileSync(readyFile, 'utf8')));
|
|
64
|
+
return;
|
|
65
|
+
} catch { /* file may be partially written — keep polling */ }
|
|
66
|
+
}
|
|
67
|
+
if (Date.now() >= deadline) {
|
|
68
|
+
const seconds = Math.round(timeoutMs / 1000);
|
|
69
|
+
reject(new Error(`Timed out waiting for Dassi daemon to start (${seconds}s)`));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
setTimeout(check, READY_POLL_MS);
|
|
73
|
+
};
|
|
74
|
+
check();
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Connects to the daemon Unix socket, sends one NDJSON command, and reads the response.
|
|
80
|
+
* @param {string} socketPath - Unix socket path.
|
|
81
|
+
* @param {object} command - Command object to send (will be JSON-stringified).
|
|
82
|
+
* @returns {Promise<object>} Parsed response object.
|
|
83
|
+
*/
|
|
84
|
+
export function sendCommand(socketPath, command) {
|
|
85
|
+
return new Promise((resolve, reject) => {
|
|
86
|
+
const socket = net.createConnection(socketPath);
|
|
87
|
+
let buffer = '';
|
|
88
|
+
let settled = false;
|
|
89
|
+
|
|
90
|
+
// Reason: guard against double-settlement since 'close' fires after socket.destroy()
|
|
91
|
+
// in the normal data path, and we don't want the close handler to re-resolve
|
|
92
|
+
const settle = (fn, val) => {
|
|
93
|
+
if (settled) return;
|
|
94
|
+
settled = true;
|
|
95
|
+
fn(val);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
socket.on('connect', () => {
|
|
99
|
+
socket.write(JSON.stringify(command) + '\n');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
socket.on('data', (chunk) => {
|
|
103
|
+
buffer += chunk.toString();
|
|
104
|
+
const nl = buffer.indexOf('\n');
|
|
105
|
+
if (nl !== -1) {
|
|
106
|
+
const line = buffer.slice(0, nl);
|
|
107
|
+
socket.destroy();
|
|
108
|
+
try { settle(resolve, JSON.parse(line)); }
|
|
109
|
+
catch { settle(reject, new Error(`Invalid JSON from daemon: ${line}`)); }
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
socket.on('error', (err) => settle(reject, err));
|
|
114
|
+
|
|
115
|
+
socket.on('close', () => {
|
|
116
|
+
if (settled) return;
|
|
117
|
+
// Reason: attempt to parse whatever arrived before the socket closed unexpectedly
|
|
118
|
+
if (buffer.trim()) {
|
|
119
|
+
try { settle(resolve, JSON.parse(buffer.trim())); }
|
|
120
|
+
catch { settle(reject, new Error(`Invalid JSON from daemon: ${buffer.trim()}`)); }
|
|
121
|
+
} else {
|
|
122
|
+
settle(reject, new Error('Daemon closed connection without a response'));
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ─── Login helpers ────────────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Defense-in-depth check for `optionsUrl` before handing it to the `open`
|
|
132
|
+
* package. Legit URLs always come from `chrome.runtime.getURL('options.html')`
|
|
133
|
+
* → `chrome-extension://<id>/options.html`. Anything else is unexpected and
|
|
134
|
+
* we should not auto-launch it (the `open` package shells out to the OS URL
|
|
135
|
+
* handler).
|
|
136
|
+
* @param {unknown} optionsUrl
|
|
137
|
+
* @returns {boolean}
|
|
138
|
+
*/
|
|
139
|
+
export function isValidOptionsUrl(optionsUrl) {
|
|
140
|
+
return typeof optionsUrl === 'string' && /^chrome-extension:\/\/[a-z]{32}\//.test(optionsUrl);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Polls the daemon's status until the user signs in (extension reports
|
|
145
|
+
* authenticated=true) or the LOGIN_TIMEOUT_MS deadline elapses. On entry,
|
|
146
|
+
* best-effort auto-opens the extension's options page (subject to the
|
|
147
|
+
* isValidOptionsUrl guard above).
|
|
148
|
+
* @param {string} socketPath - Path to the daemon Unix socket.
|
|
149
|
+
* @param {string | undefined} optionsUrl - URL of the Dassi options page.
|
|
150
|
+
* @returns {Promise<void>} Resolves on successful login; throws on timeout.
|
|
151
|
+
*/
|
|
152
|
+
export async function waitForLogin(socketPath, optionsUrl) {
|
|
153
|
+
console.error(`⚠️ Dassi is installed but you're not signed in.\n Opening the Dassi settings page...\n`);
|
|
154
|
+
|
|
155
|
+
// Auto-open the options page — best-effort (package may not be installed).
|
|
156
|
+
// Reason: only open URLs the extension would legitimately produce
|
|
157
|
+
// (chrome.runtime.getURL → `chrome-extension://<id>/options.html`).
|
|
158
|
+
// Defense-in-depth: even though optionsUrl is sourced from the daemon's
|
|
159
|
+
// ready file (which lives in our owner-only ~/.dassi/ dir), validating the
|
|
160
|
+
// protocol prevents `open` from launching arbitrary URIs/shell-handlers if
|
|
161
|
+
// the file is ever tampered with.
|
|
162
|
+
if (isValidOptionsUrl(optionsUrl)) {
|
|
163
|
+
try {
|
|
164
|
+
const { default: open } = await import('open');
|
|
165
|
+
await open(String(optionsUrl));
|
|
166
|
+
} catch {
|
|
167
|
+
console.error(` Please open: ${optionsUrl}`);
|
|
168
|
+
}
|
|
169
|
+
} else if (optionsUrl) {
|
|
170
|
+
console.error(` Refusing to auto-open unexpected URL: ${optionsUrl}\n Please open the Dassi settings page manually.`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Poll the daemon socket every LOGIN_POLL_MS until authenticated
|
|
174
|
+
console.error(' Waiting for sign-in... (Ctrl+C to cancel)');
|
|
175
|
+
const deadline = Date.now() + LOGIN_TIMEOUT_MS;
|
|
176
|
+
let pollIndex = 0;
|
|
177
|
+
while (Date.now() < deadline) {
|
|
178
|
+
await new Promise((r) => setTimeout(r, LOGIN_POLL_MS));
|
|
179
|
+
try {
|
|
180
|
+
// Reason: increment poll index so each status request has a unique id for tracing
|
|
181
|
+
const resp = await sendCommand(socketPath, { id: `poll-auth-${pollIndex++}`, action: 'status' });
|
|
182
|
+
if (resp.success && resp.data?.authenticated) {
|
|
183
|
+
console.error(` ✓ Signed in as ${resp.data.email}\n ✓ Ready\n`);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
} catch { /* daemon may not be socket-ready yet — keep polling */ }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
throw new Error('Login timed out. Please sign in and try again.');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ─── Daemon readiness ─────────────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Ensures the daemon is running and ready, handling onboarding if needed.
|
|
196
|
+
* @param {string} session
|
|
197
|
+
* @returns {Promise<string>} The daemon's Unix socket path.
|
|
198
|
+
*/
|
|
199
|
+
export async function ensureDaemonReady(session) {
|
|
200
|
+
const socketPath = getSocketPath(session);
|
|
201
|
+
const readyFile = getReadyFile(session);
|
|
202
|
+
|
|
203
|
+
// Reason: delete any stale ready file from a previous run so the new daemon
|
|
204
|
+
// writes a fresh one. Skip if a daemon is already running to avoid racing.
|
|
205
|
+
if (!isDaemonRunning(session)) {
|
|
206
|
+
try { fs.unlinkSync(readyFile); } catch { /* ok if missing */ }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
ensureDaemonRunning(session);
|
|
210
|
+
|
|
211
|
+
const ready = await waitForReady(readyFile);
|
|
212
|
+
|
|
213
|
+
if (ready.status === 'extension_not_installed') {
|
|
214
|
+
console.error(
|
|
215
|
+
`❌ Dassi extension not detected.\n\n` +
|
|
216
|
+
` Install it from:\n ${CHROME_WEB_STORE_URL}\n\n` +
|
|
217
|
+
` Then run this command again.`
|
|
218
|
+
);
|
|
219
|
+
process.exit(1);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (ready.status === 'needs_login') {
|
|
223
|
+
await waitForLogin(socketPath, ready.optionsUrl ? String(ready.optionsUrl) : undefined);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return socketPath;
|
|
227
|
+
}
|
package/dassi.mjs
CHANGED
|
@@ -4,35 +4,25 @@
|
|
|
4
4
|
* Protocol-compatible with agent-browser: {id, action, ...} → {id, success, data/error}
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import * as net from 'net';
|
|
8
7
|
import * as fs from 'fs';
|
|
9
8
|
import * as path from 'path';
|
|
10
9
|
import * as child_process from 'child_process';
|
|
11
10
|
import { fileURLToPath, pathToFileURL } from 'url';
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
isDaemonRunning,
|
|
16
|
-
parseReadyPayload,
|
|
17
|
-
validateSession,
|
|
18
|
-
DASSI_EXTENSION_ID,
|
|
19
|
-
getAppDir,
|
|
20
|
-
getLaunchesFile,
|
|
21
|
-
} from './dassi-shared.mjs';
|
|
22
|
-
import { handleLaunch, handleStop } from './launch.mjs';
|
|
11
|
+
import { getSocketPath, validateSession, getAppDir, getLaunchesFile } from './dassi-shared.mjs';
|
|
12
|
+
import { ensureDaemonRunning, sendCommand, ensureDaemonReady } from './daemon-client.mjs';
|
|
13
|
+
import { dispatchLaunch } from './launch.mjs';
|
|
23
14
|
import { parseToolCommand, requireTarget, parseStrictInt } from './tool-commands.mjs';
|
|
24
15
|
import { runWithGroupExpansion } from './group-expansion.mjs';
|
|
25
16
|
import { formatResponse } from './format-response.mjs';
|
|
26
17
|
import { HELP_TEXT } from './help-text.mjs';
|
|
27
18
|
|
|
28
19
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
29
|
-
const DAEMON_SCRIPT = path.join(__dirname, 'dassi-daemon.mjs');
|
|
30
20
|
const VERSION = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf8')).version;
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
21
|
+
|
|
22
|
+
// Reason: tests import isValidOptionsUrl from this module (it moved to
|
|
23
|
+
// daemon-client.mjs with the rest of the daemon-client unit) — re-export to
|
|
24
|
+
// keep the public surface and the tests unchanged.
|
|
25
|
+
export { isValidOptionsUrl } from './daemon-client.mjs';
|
|
36
26
|
|
|
37
27
|
// ─── Arg parsing ──────────────────────────────────────────────────────────────
|
|
38
28
|
|
|
@@ -47,6 +37,16 @@ const CHROME_WEB_STORE_URL = `https://chromewebstore.google.com/detail/dassi-ai-
|
|
|
47
37
|
export function parseCliArgs(argv) {
|
|
48
38
|
const args = [...argv];
|
|
49
39
|
|
|
40
|
+
// Reason: hoist the `--` split to BEFORE global flag parsing so that Chrome
|
|
41
|
+
// passthrough tokens like --json, --label, --profile, or --stop can never be
|
|
42
|
+
// mistakenly consumed as CLI flags. Everything after the first `--` token is
|
|
43
|
+
// sliced out here and stored verbatim in `chromeArgs`; the remaining `args`
|
|
44
|
+
// array is then parsed as usual with no risk of collision. Non-launch commands
|
|
45
|
+
// that supply a `--` separator are rejected loudly below (after the action is
|
|
46
|
+
// known) so callers get a clear error instead of silent data loss.
|
|
47
|
+
const sepIdx = args.indexOf('--');
|
|
48
|
+
const chromeArgs = sepIdx === -1 ? [] : args.splice(sepIdx).slice(1);
|
|
49
|
+
|
|
50
50
|
// Reason: handle --version and --help before any flag parsing so they work
|
|
51
51
|
// even when other flags like --session are incomplete (e.g. `dassi --version --session`)
|
|
52
52
|
if (consumeFlag(args, '--version')) {
|
|
@@ -67,6 +67,13 @@ export function parseCliArgs(argv) {
|
|
|
67
67
|
const command = args.shift();
|
|
68
68
|
if (!command) throw new Error('No command specified. Run: dassi --help');
|
|
69
69
|
|
|
70
|
+
// Reason: chromeArgs (tokens after `--`) are only meaningful for the `launch`
|
|
71
|
+
// command. Any other command that supplies `--` tokens would have them silently
|
|
72
|
+
// dropped — fail loudly instead so the caller knows their intent was not honoured.
|
|
73
|
+
if (chromeArgs.length > 0 && command !== 'launch') {
|
|
74
|
+
throw new Error("'--' is only valid with the launch command");
|
|
75
|
+
}
|
|
76
|
+
|
|
70
77
|
if (command === 'list-tabs') {
|
|
71
78
|
const all = consumeFlag(args, '--all');
|
|
72
79
|
return finish('list_tabs', all ? { all: true } : {});
|
|
@@ -86,10 +93,16 @@ export function parseCliArgs(argv) {
|
|
|
86
93
|
}
|
|
87
94
|
|
|
88
95
|
if (command === 'launch') {
|
|
89
|
-
if (consumeFlag(args, '--stop-all'))
|
|
96
|
+
if (consumeFlag(args, '--stop-all')) {
|
|
97
|
+
// Reason: chromeArgs with --stop-all makes no sense — reject to prevent confusion.
|
|
98
|
+
if (chromeArgs.length > 0) throw new Error("'--' chrome args cannot be combined with launch --stop-all");
|
|
99
|
+
return finish('launch_stop', { all: true });
|
|
100
|
+
}
|
|
90
101
|
const stopIdx = args.indexOf('--stop');
|
|
91
102
|
if (stopIdx !== -1) {
|
|
92
103
|
args.splice(stopIdx, 1);
|
|
104
|
+
// Reason: chromeArgs with --stop makes no sense — reject to prevent confusion.
|
|
105
|
+
if (chromeArgs.length > 0) throw new Error("'--' chrome args cannot be combined with launch --stop");
|
|
93
106
|
// Reason: resolve the stop label like the start path does — a positional
|
|
94
107
|
// token after --stop wins, else the global --label/--profile (parsed into
|
|
95
108
|
// `profile` before this branch), else the default. Keeps `--stop --label qa`
|
|
@@ -108,15 +121,22 @@ export function parseCliArgs(argv) {
|
|
|
108
121
|
if (!/^[a-zA-Z0-9_-]{1,64}$/.test(launchLabel)) {
|
|
109
122
|
throw new Error('--label must be 1–64 characters of letters, digits, hyphen, or underscore.');
|
|
110
123
|
}
|
|
124
|
+
const loadMode = getFlag(args, '--load-mode') ?? 'auto'; // auto (detect) | pipe | flag
|
|
125
|
+
if (!['auto', 'pipe', 'flag'].includes(loadMode)) throw new Error('--load-mode must be one of: auto, pipe, flag.');
|
|
111
126
|
return finish('launch', {
|
|
112
127
|
label: launchLabel,
|
|
113
128
|
dist: getFlag(args, '--dist') ?? null,
|
|
114
129
|
chrome: getFlag(args, '--chrome') ?? null,
|
|
115
130
|
profileDir: getFlag(args, '--profile-dir') ?? null,
|
|
131
|
+
loadMode,
|
|
116
132
|
timeoutMs: timeoutRaw ? parseStrictInt(timeoutRaw, '--timeout') : 30000,
|
|
133
|
+
chromeArgs,
|
|
117
134
|
});
|
|
118
135
|
}
|
|
119
136
|
|
|
137
|
+
// Hidden helper command (detached child of `dassi launch` pipe mode); config via DASSI_HOLD_* env.
|
|
138
|
+
if (command === '__launch-hold') return finish('launch_hold', {});
|
|
139
|
+
|
|
120
140
|
if (command === 'run') {
|
|
121
141
|
const prompt = args.shift();
|
|
122
142
|
if (!prompt) throw new Error('run requires a prompt argument');
|
|
@@ -193,167 +213,6 @@ function consumeFlag(args, flag) {
|
|
|
193
213
|
return true;
|
|
194
214
|
}
|
|
195
215
|
|
|
196
|
-
// ─── Daemon management ────────────────────────────────────────────────────────
|
|
197
|
-
|
|
198
|
-
/**
|
|
199
|
-
* Ensures the daemon is running for the given session.
|
|
200
|
-
* Spawns it as a detached background process if the PID file is missing or stale.
|
|
201
|
-
* @param {string} session
|
|
202
|
-
*/
|
|
203
|
-
export function ensureDaemonRunning(session) {
|
|
204
|
-
if (isDaemonRunning(session)) return;
|
|
205
|
-
|
|
206
|
-
// Reason: detached + unref means the daemon outlives the CLI process
|
|
207
|
-
const daemon = child_process.spawn(process.execPath, [DAEMON_SCRIPT], {
|
|
208
|
-
detached: true,
|
|
209
|
-
stdio: 'ignore',
|
|
210
|
-
env: { ...process.env, DASSI_SESSION: session },
|
|
211
|
-
});
|
|
212
|
-
daemon.unref();
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
/**
|
|
216
|
-
* Polls for the ready file until it appears or the timeout elapses.
|
|
217
|
-
* Resolves with the parsed ready payload.
|
|
218
|
-
* @param {string} readyFile
|
|
219
|
-
* @param {number} [timeoutMs]
|
|
220
|
-
* @returns {Promise<{ status: string; [key: string]: unknown }>}
|
|
221
|
-
*/
|
|
222
|
-
export function waitForReady(readyFile, timeoutMs = READY_TIMEOUT_MS) {
|
|
223
|
-
return new Promise((resolve, reject) => {
|
|
224
|
-
const deadline = Date.now() + timeoutMs;
|
|
225
|
-
const check = () => {
|
|
226
|
-
if (fs.existsSync(readyFile)) {
|
|
227
|
-
try {
|
|
228
|
-
resolve(parseReadyPayload(fs.readFileSync(readyFile, 'utf8')));
|
|
229
|
-
return;
|
|
230
|
-
} catch { /* file may be partially written — keep polling */ }
|
|
231
|
-
}
|
|
232
|
-
if (Date.now() >= deadline) {
|
|
233
|
-
const seconds = Math.round(timeoutMs / 1000);
|
|
234
|
-
reject(new Error(`Timed out waiting for Dassi daemon to start (${seconds}s)`));
|
|
235
|
-
return;
|
|
236
|
-
}
|
|
237
|
-
setTimeout(check, READY_POLL_MS);
|
|
238
|
-
};
|
|
239
|
-
check();
|
|
240
|
-
});
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
/**
|
|
244
|
-
* Connects to the daemon Unix socket, sends one NDJSON command, and reads the response.
|
|
245
|
-
* @param {string} socketPath - Unix socket path.
|
|
246
|
-
* @param {object} command - Command object to send (will be JSON-stringified).
|
|
247
|
-
* @returns {Promise<object>} Parsed response object.
|
|
248
|
-
*/
|
|
249
|
-
export function sendCommand(socketPath, command) {
|
|
250
|
-
return new Promise((resolve, reject) => {
|
|
251
|
-
const socket = net.createConnection(socketPath);
|
|
252
|
-
let buffer = '';
|
|
253
|
-
let settled = false;
|
|
254
|
-
|
|
255
|
-
// Reason: guard against double-settlement since 'close' fires after socket.destroy()
|
|
256
|
-
// in the normal data path, and we don't want the close handler to re-resolve
|
|
257
|
-
const settle = (fn, val) => {
|
|
258
|
-
if (settled) return;
|
|
259
|
-
settled = true;
|
|
260
|
-
fn(val);
|
|
261
|
-
};
|
|
262
|
-
|
|
263
|
-
socket.on('connect', () => {
|
|
264
|
-
socket.write(JSON.stringify(command) + '\n');
|
|
265
|
-
});
|
|
266
|
-
|
|
267
|
-
socket.on('data', (chunk) => {
|
|
268
|
-
buffer += chunk.toString();
|
|
269
|
-
const nl = buffer.indexOf('\n');
|
|
270
|
-
if (nl !== -1) {
|
|
271
|
-
const line = buffer.slice(0, nl);
|
|
272
|
-
socket.destroy();
|
|
273
|
-
try { settle(resolve, JSON.parse(line)); }
|
|
274
|
-
catch { settle(reject, new Error(`Invalid JSON from daemon: ${line}`)); }
|
|
275
|
-
}
|
|
276
|
-
});
|
|
277
|
-
|
|
278
|
-
socket.on('error', (err) => settle(reject, err));
|
|
279
|
-
|
|
280
|
-
socket.on('close', () => {
|
|
281
|
-
if (settled) return;
|
|
282
|
-
// Reason: attempt to parse whatever arrived before the socket closed unexpectedly
|
|
283
|
-
if (buffer.trim()) {
|
|
284
|
-
try { settle(resolve, JSON.parse(buffer.trim())); }
|
|
285
|
-
catch { settle(reject, new Error(`Invalid JSON from daemon: ${buffer.trim()}`)); }
|
|
286
|
-
} else {
|
|
287
|
-
settle(reject, new Error('Daemon closed connection without a response'));
|
|
288
|
-
}
|
|
289
|
-
});
|
|
290
|
-
});
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
// ─── Login helpers ────────────────────────────────────────────────────────────
|
|
294
|
-
|
|
295
|
-
/**
|
|
296
|
-
* Defense-in-depth check for `optionsUrl` before handing it to the `open`
|
|
297
|
-
* package. Legit URLs always come from `chrome.runtime.getURL('options.html')`
|
|
298
|
-
* → `chrome-extension://<id>/options.html`. Anything else is unexpected and
|
|
299
|
-
* we should not auto-launch it (the `open` package shells out to the OS URL
|
|
300
|
-
* handler).
|
|
301
|
-
* @param {unknown} optionsUrl
|
|
302
|
-
* @returns {boolean}
|
|
303
|
-
*/
|
|
304
|
-
export function isValidOptionsUrl(optionsUrl) {
|
|
305
|
-
return typeof optionsUrl === 'string' && /^chrome-extension:\/\/[a-z]{32}\//.test(optionsUrl);
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
/**
|
|
309
|
-
* Polls the daemon's status until the user signs in (extension reports
|
|
310
|
-
* authenticated=true) or the LOGIN_TIMEOUT_MS deadline elapses. On entry,
|
|
311
|
-
* best-effort auto-opens the extension's options page (subject to the
|
|
312
|
-
* isValidOptionsUrl guard above).
|
|
313
|
-
* @param {string} socketPath - Path to the daemon Unix socket.
|
|
314
|
-
* @param {string | undefined} optionsUrl - URL of the Dassi options page.
|
|
315
|
-
* @returns {Promise<void>} Resolves on successful login; throws on timeout.
|
|
316
|
-
*/
|
|
317
|
-
export async function waitForLogin(socketPath, optionsUrl) {
|
|
318
|
-
console.error(`⚠️ Dassi is installed but you're not signed in.\n Opening the Dassi settings page...\n`);
|
|
319
|
-
|
|
320
|
-
// Auto-open the options page — best-effort (package may not be installed).
|
|
321
|
-
// Reason: only open URLs the extension would legitimately produce
|
|
322
|
-
// (chrome.runtime.getURL → `chrome-extension://<id>/options.html`).
|
|
323
|
-
// Defense-in-depth: even though optionsUrl is sourced from the daemon's
|
|
324
|
-
// ready file (which lives in our owner-only ~/.dassi/ dir), validating the
|
|
325
|
-
// protocol prevents `open` from launching arbitrary URIs/shell-handlers if
|
|
326
|
-
// the file is ever tampered with.
|
|
327
|
-
if (isValidOptionsUrl(optionsUrl)) {
|
|
328
|
-
try {
|
|
329
|
-
const { default: open } = await import('open');
|
|
330
|
-
await open(String(optionsUrl));
|
|
331
|
-
} catch {
|
|
332
|
-
console.error(` Please open: ${optionsUrl}`);
|
|
333
|
-
}
|
|
334
|
-
} else if (optionsUrl) {
|
|
335
|
-
console.error(` Refusing to auto-open unexpected URL: ${optionsUrl}\n Please open the Dassi settings page manually.`);
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
// Poll the daemon socket every LOGIN_POLL_MS until authenticated
|
|
339
|
-
console.error(' Waiting for sign-in... (Ctrl+C to cancel)');
|
|
340
|
-
const deadline = Date.now() + LOGIN_TIMEOUT_MS;
|
|
341
|
-
let pollIndex = 0;
|
|
342
|
-
while (Date.now() < deadline) {
|
|
343
|
-
await new Promise((r) => setTimeout(r, LOGIN_POLL_MS));
|
|
344
|
-
try {
|
|
345
|
-
// Reason: increment poll index so each status request has a unique id for tracing
|
|
346
|
-
const resp = await sendCommand(socketPath, { id: `poll-auth-${pollIndex++}`, action: 'status' });
|
|
347
|
-
if (resp.success && resp.data?.authenticated) {
|
|
348
|
-
console.error(` ✓ Signed in as ${resp.data.email}\n ✓ Ready\n`);
|
|
349
|
-
return;
|
|
350
|
-
}
|
|
351
|
-
} catch { /* daemon may not be socket-ready yet — keep polling */ }
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
throw new Error('Login timed out. Please sign in and try again.');
|
|
355
|
-
}
|
|
356
|
-
|
|
357
216
|
// ─── Immediate actions ────────────────────────────────────────────────────────
|
|
358
217
|
|
|
359
218
|
// HELP_TEXT lives in ./help-text.mjs (extracted to keep this file within the size limit).
|
|
@@ -375,43 +234,6 @@ function handleImmediateAction(action) {
|
|
|
375
234
|
return false;
|
|
376
235
|
}
|
|
377
236
|
|
|
378
|
-
// ─── Daemon readiness ─────────────────────────────────────────────────────────
|
|
379
|
-
|
|
380
|
-
/**
|
|
381
|
-
* Ensures the daemon is running and ready, handling onboarding if needed.
|
|
382
|
-
* @param {string} session
|
|
383
|
-
* @returns {Promise<string>} The daemon's Unix socket path.
|
|
384
|
-
*/
|
|
385
|
-
async function ensureDaemonReady(session) {
|
|
386
|
-
const socketPath = getSocketPath(session);
|
|
387
|
-
const readyFile = getReadyFile(session);
|
|
388
|
-
|
|
389
|
-
// Reason: delete any stale ready file from a previous run so the new daemon
|
|
390
|
-
// writes a fresh one. Skip if a daemon is already running to avoid racing.
|
|
391
|
-
if (!isDaemonRunning(session)) {
|
|
392
|
-
try { fs.unlinkSync(readyFile); } catch { /* ok if missing */ }
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
ensureDaemonRunning(session);
|
|
396
|
-
|
|
397
|
-
const ready = await waitForReady(readyFile);
|
|
398
|
-
|
|
399
|
-
if (ready.status === 'extension_not_installed') {
|
|
400
|
-
console.error(
|
|
401
|
-
`❌ Dassi extension not detected.\n\n` +
|
|
402
|
-
` Install it from:\n ${CHROME_WEB_STORE_URL}\n\n` +
|
|
403
|
-
` Then run this command again.`
|
|
404
|
-
);
|
|
405
|
-
process.exit(1);
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
if (ready.status === 'needs_login') {
|
|
409
|
-
await waitForLogin(socketPath, ready.optionsUrl ? String(ready.optionsUrl) : undefined);
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
return socketPath;
|
|
413
|
-
}
|
|
414
|
-
|
|
415
237
|
// ─── Entry point ──────────────────────────────────────────────────────────────
|
|
416
238
|
|
|
417
239
|
/**
|
|
@@ -431,14 +253,8 @@ export async function run() {
|
|
|
431
253
|
const { action, params, session, json, profile } = parsed;
|
|
432
254
|
handleImmediateAction(action);
|
|
433
255
|
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
return;
|
|
437
|
-
}
|
|
438
|
-
if (action === 'launch_stop') {
|
|
439
|
-
handleStop(params, { launchesFile: getLaunchesFile() });
|
|
440
|
-
return;
|
|
441
|
-
}
|
|
256
|
+
// Launch family (launch / --stop / __launch-hold) → launch.mjs.
|
|
257
|
+
if (await dispatchLaunch(action, params, { spawn: child_process.spawn, ensureDaemonRunning, getSocketPath, sendCommand, appDir: getAppDir(), launchesFile: getLaunchesFile() })) return;
|
|
442
258
|
|
|
443
259
|
const socketPath = await ensureDaemonReady(session);
|
|
444
260
|
|
package/help-text.mjs
CHANGED
|
@@ -27,8 +27,10 @@ export const HELP_TEXT =
|
|
|
27
27
|
' status Check extension status\n' +
|
|
28
28
|
' bug-report [-o file] Export debug logs from all contexts\n' +
|
|
29
29
|
' raw <json> Send raw JSON command\n' +
|
|
30
|
-
' launch [--label <name>] [--dist <path>]\n' +
|
|
30
|
+
' launch [--label <name>] [--dist <path>] [--chrome <path>]\n' +
|
|
31
|
+
' [--load-mode auto|pipe|flag] [-- <chrome args…>]\n' +
|
|
31
32
|
' Open Chrome with a dev dist loaded\n' +
|
|
33
|
+
' (args after -- go to Chrome, e.g. --headless=new)\n' +
|
|
32
34
|
' launch --stop [label] | --stop-all\n' +
|
|
33
35
|
' Stop a launched Chrome\n\n' +
|
|
34
36
|
'Options:\n' +
|
package/launch.mjs
CHANGED
|
@@ -3,8 +3,14 @@
|
|
|
3
3
|
* building Chrome launch args, and tracking launched instances.
|
|
4
4
|
*/
|
|
5
5
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
6
|
+
import { execFileSync, spawn as childSpawn } from 'child_process';
|
|
6
7
|
import * as path from 'path';
|
|
7
8
|
import { DASSI_EXTENSION_ID, getDaemonBridgePort, DEFAULT_BRIDGE_PORT, isDaemonRunning as isDaemonRunningImpl } from './dassi-shared.mjs';
|
|
9
|
+
import { loadExtensionOverPipe } from './_launch-pipe.mjs';
|
|
10
|
+
// `_launch-pipe.mjs` is private (underscore-prefixed): `launch.mjs` is the SINGLE public
|
|
11
|
+
// gateway for the launch module. Re-export the pipe transport here so every consumer
|
|
12
|
+
// (including the unit tests) imports it from this one surface, never from the private file.
|
|
13
|
+
export { cdpPipeClient, loadExtensionOverPipe } from './_launch-pipe.mjs';
|
|
8
14
|
|
|
9
15
|
/**
|
|
10
16
|
* Resolve the path to an installed Chrome binary.
|
|
@@ -42,18 +48,38 @@ export function resolveDistPath({ dist = null, cwd = process.cwd(), exists = exi
|
|
|
42
48
|
}
|
|
43
49
|
|
|
44
50
|
/**
|
|
45
|
-
* Build the Chrome
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
51
|
+
* Build the Chrome args for a pipe-driven launch.
|
|
52
|
+
*
|
|
53
|
+
* The extension is NOT loaded via the `--load-extension` flag: Chrome 137+ branded
|
|
54
|
+
* builds reject it (`ERR_BLOCKED_BY_CLIENT`), and the `--disable-features=
|
|
55
|
+
* DisableLoadExtensionCommandLineSwitch` escape hatch is gone in Chrome 149. Instead
|
|
56
|
+
* the dist is installed at runtime over the CDP pipe via `Extensions.loadUnpacked`
|
|
57
|
+
* (see {@link loadExtensionOverPipe}). That command is only accepted over the
|
|
58
|
+
* `--remote-debugging-pipe` transport (fds 3/4) — not `--remote-debugging-port` —
|
|
59
|
+
* and requires `--enable-unsafe-extension-debugging`. Works on branded Chrome,
|
|
60
|
+
* Chrome for Testing, and Chromium alike.
|
|
61
|
+
* @param {{ profileDir: string; chromeArgs?: string[] }} opts
|
|
62
|
+
* @returns {string[]} Chrome args.
|
|
63
|
+
*/
|
|
64
|
+
export function buildLaunchArgs({ profileDir, chromeArgs = [] }) {
|
|
65
|
+
return [
|
|
66
|
+
'--remote-debugging-pipe',
|
|
67
|
+
'--enable-unsafe-extension-debugging',
|
|
68
|
+
`--user-data-dir=${profileDir}`,
|
|
69
|
+
'--no-first-run',
|
|
70
|
+
'--no-default-browser-check',
|
|
71
|
+
...chromeArgs,
|
|
72
|
+
];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Build Chrome args for the legacy `--load-extension` path (Chrome for Testing /
|
|
77
|
+
* Chromium, which still honour it and load the extension *persistently*). The dist
|
|
78
|
+
* is loaded at startup, so the seed options URL can ride along as the startup tab.
|
|
79
|
+
* @param {{ distPath: string; profileDir: string; label: string; bridgePort: number; chromeArgs?: string[] }} opts
|
|
50
80
|
* @returns {string[]} Chrome args (the final entry is the seed URL to open).
|
|
51
81
|
*/
|
|
52
|
-
export function
|
|
53
|
-
// Reason: seed BOTH label and bridgePort — the options page persists them so the
|
|
54
|
-
// launched extension connects to the daemon's actual port (matters when
|
|
55
|
-
// DASSI_BRIDGE_PORT moves the daemon off the default 18790; without it the
|
|
56
|
-
// extension falls back to 18790 and the isolated-port launch times out).
|
|
82
|
+
export function buildFlagArgs({ distPath, profileDir, label, bridgePort, chromeArgs = [] }) {
|
|
57
83
|
const seed = `options.html?label=${encodeURIComponent(label)}&bridgePort=${bridgePort}`;
|
|
58
84
|
return [
|
|
59
85
|
`--load-extension=${distPath}`,
|
|
@@ -61,10 +87,31 @@ export function buildLaunchArgs({ distPath, profileDir, label, bridgePort }) {
|
|
|
61
87
|
`--user-data-dir=${profileDir}`,
|
|
62
88
|
'--no-first-run',
|
|
63
89
|
'--no-default-browser-check',
|
|
90
|
+
// Reason: extra args must precede the positional seed URL — Chrome treats
|
|
91
|
+
// everything after the first positional as URLs to open.
|
|
92
|
+
...chromeArgs,
|
|
64
93
|
`chrome-extension://${DASSI_EXTENSION_ID}/${seed}`,
|
|
65
94
|
];
|
|
66
95
|
}
|
|
67
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Does this Chrome binary still support a *persistent* `--load-extension`?
|
|
99
|
+
* Branded Google Chrome 137+ disabled it (ERR_BLOCKED_BY_CLIENT); Chrome for Testing
|
|
100
|
+
* and Chromium still honour it. Detected from `--version` output. Defaults to false
|
|
101
|
+
* (→ pipe path) on any error — the pipe path works everywhere, so it's the safe
|
|
102
|
+
* fallback.
|
|
103
|
+
* @param {string} chromePath
|
|
104
|
+
* @param {(p: string) => string} [runVersion] inject for tests
|
|
105
|
+
* @returns {boolean} true → use `--load-extension`; false → use the CDP pipe.
|
|
106
|
+
*/
|
|
107
|
+
export function chromeSupportsLoadFlag(chromePath, runVersion = (p) => execFileSync(p, ['--version'], { encoding: 'utf8' })) {
|
|
108
|
+
try {
|
|
109
|
+
return /for testing|chromium/i.test(runVersion(chromePath));
|
|
110
|
+
} catch {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
68
115
|
/**
|
|
69
116
|
* Read the launches registry. Returns [] if the file is missing or unparseable.
|
|
70
117
|
* @param {string} file
|
|
@@ -146,17 +193,47 @@ async function isLabelConnected({ sendCommand, socketPath, label }) {
|
|
|
146
193
|
}
|
|
147
194
|
|
|
148
195
|
/**
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
* @
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
196
|
+
* Daemon pre-flight for a launch: validate the bridge port, spawn the (non-blocking)
|
|
197
|
+
* daemon, and refuse a duplicate label. Calls `exit(1)` + returns null on failure.
|
|
198
|
+
* @returns {Promise<{ bridgePort: number; socketPath: string } | null>}
|
|
199
|
+
*/
|
|
200
|
+
async function preflightLaunch({ ensureDaemonRunning, getSocketPath, sendCommand, isDaemonRunning, label, error, exit }) {
|
|
201
|
+
// Reason: `dassi launch` reuses the 'default' daemon session, but the session PID
|
|
202
|
+
// doesn't tell us its port. If a non-default DASSI_BRIDGE_PORT is requested while a
|
|
203
|
+
// default daemon is already running, we can't confirm it's on that port — fail clearly.
|
|
204
|
+
const bridgePort = getDaemonBridgePort();
|
|
205
|
+
if (bridgePort !== DEFAULT_BRIDGE_PORT && isDaemonRunning('default')) {
|
|
206
|
+
error(`❌ DASSI_BRIDGE_PORT=${bridgePort} is set, but a daemon is already running for the default session (it may be on a different port). Stop it first, or omit DASSI_BRIDGE_PORT to use the running daemon.`);
|
|
207
|
+
exit(1);
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
// Reason: only SPAWN the daemon (non-blocking) — none has connected yet.
|
|
211
|
+
ensureDaemonRunning('default');
|
|
212
|
+
const socketPath = getSocketPath('default');
|
|
213
|
+
// Reason: a same-label Chrome already running makes waitForProfile falsely succeed.
|
|
214
|
+
if (await isLabelConnected({ sendCommand, socketPath, label })) {
|
|
215
|
+
error(`❌ A profile labelled "${label}" is already connected. Run \`dassi launch --stop ${label}\` first, or use a different --label.`);
|
|
216
|
+
exit(1);
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
return { bridgePort, socketPath };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Launch Chrome with the dev dist loaded under a dedicated profile, wait for the
|
|
224
|
+
* extension to register under `label`, then record it. Auto-detects the load mode
|
|
225
|
+
* (pipe helper for branded Chrome / `--load-extension` for CfT/Chromium).
|
|
226
|
+
* @param {{label:string; dist:string|null; chrome:string|null; profileDir:string|null; loadMode?:string; timeoutMs:number; chromeArgs?:string[]}} opts
|
|
227
|
+
* @param {{ spawn: Function; ensureDaemonRunning: Function; getSocketPath: Function; sendCommand: Function; appDir: string; launchesFile: string; isDaemonRunning?: Function; detectLoadFlag?: Function; helperCommand?: string[]; log?: Function; error?: Function; exit?: Function; mkdir?: Function; kill?: Function }} deps
|
|
228
|
+
* `detectLoadFlag` chooses pipe-vs-flag mode; `helperCommand` is the argv used to spawn the detached pipe-hold helper (both injectable for tests).
|
|
229
|
+
* @returns {Promise<void>} resolves once the launch is recorded (or `deps.exit` is called on failure).
|
|
155
230
|
*/
|
|
156
231
|
export async function handleLaunch(opts, deps) {
|
|
157
232
|
const {
|
|
158
233
|
spawn, ensureDaemonRunning, getSocketPath, sendCommand, appDir, launchesFile,
|
|
159
234
|
isDaemonRunning = isDaemonRunningImpl,
|
|
235
|
+
detectLoadFlag = chromeSupportsLoadFlag,
|
|
236
|
+
helperCommand = [process.execPath, process.argv[1]],
|
|
160
237
|
log = console.log, error = console.error, exit = process.exit,
|
|
161
238
|
mkdir = (d) => mkdirSync(d, { recursive: true }), kill = (pid) => process.kill(pid),
|
|
162
239
|
} = deps;
|
|
@@ -165,65 +242,169 @@ export async function handleLaunch(opts, deps) {
|
|
|
165
242
|
if (!target) return; // resolution failed (exit already called)
|
|
166
243
|
const { distPath, chromePath, profileDir } = target;
|
|
167
244
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
// clearly rather than seed a port the extension can't reach (a silent timeout).
|
|
172
|
-
const bridgePort = getDaemonBridgePort();
|
|
173
|
-
if (bridgePort !== DEFAULT_BRIDGE_PORT && isDaemonRunning('default')) {
|
|
174
|
-
error(`❌ DASSI_BRIDGE_PORT=${bridgePort} is set, but a daemon is already running for the default session (it may be on a different port). Stop it first, or omit DASSI_BRIDGE_PORT to use the running daemon.`);
|
|
175
|
-
return exit(1);
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
// Reason: only SPAWN the daemon (non-blocking). NOT ensureDaemonReady — that waits
|
|
179
|
-
// for the ready file the daemon writes only after an extension connects; none is yet.
|
|
180
|
-
ensureDaemonRunning('default');
|
|
181
|
-
const socketPath = getSocketPath('default');
|
|
182
|
-
|
|
183
|
-
// Reason: a same-label Chrome already running makes waitForProfile falsely succeed
|
|
184
|
-
// (a second launch with the same --user-data-dir hands off to the existing process).
|
|
185
|
-
if (await isLabelConnected({ sendCommand, socketPath, label: opts.label })) {
|
|
186
|
-
error(`❌ A profile labelled "${opts.label}" is already connected. Run \`dassi launch --stop ${opts.label}\` first, or use a different --label.`);
|
|
187
|
-
return exit(1);
|
|
188
|
-
}
|
|
245
|
+
const pre = await preflightLaunch({ ensureDaemonRunning, getSocketPath, sendCommand, isDaemonRunning, label: opts.label, error, exit });
|
|
246
|
+
if (!pre) return; // a guard failed (exit already called)
|
|
247
|
+
const { bridgePort, socketPath } = pre;
|
|
189
248
|
|
|
190
|
-
|
|
249
|
+
// Reason: branded Chrome 137+ disabled --load-extension, so install over the CDP pipe via
|
|
250
|
+
// a persistent helper. CfT/Chromium still honour --load-extension. --load-mode forces it;
|
|
251
|
+
// 'auto' (default) detects from the binary's --version.
|
|
252
|
+
const useFlag = opts.loadMode === 'flag' ? true : opts.loadMode === 'pipe' ? false : detectLoadFlag(chromePath);
|
|
253
|
+
const common = { spawn, kill, sendCommand, socketPath, chromePath, distPath, profileDir, bridgePort, opts, error };
|
|
254
|
+
const pid = useFlag ? await spawnFlagAndConnect(common) : await spawnHelperAndConnect({ ...common, helperCommand });
|
|
191
255
|
if (pid === null) return exit(1);
|
|
192
256
|
|
|
193
|
-
recordLaunch(launchesFile, { label: opts.label, pid, profileDir, startedAt: Date.now() });
|
|
257
|
+
recordLaunch(launchesFile, { label: opts.label, pid, profileDir, mode: useFlag ? 'flag' : 'pipe', startedAt: Date.now() });
|
|
194
258
|
log(`✅ launched "${opts.label}" (pid ${pid}) — drive it with: dassi run "…" --profile ${opts.label}`);
|
|
195
259
|
}
|
|
196
260
|
|
|
197
261
|
/**
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
262
|
+
* Entry for the hidden `dassi __launch-hold` subcommand: reads the DASSI_HOLD_* env vars
|
|
263
|
+
* (set by spawnHelperAndConnect) and runs the pipe-holding helper. Never returns.
|
|
264
|
+
* @param {Record<string,string|undefined>} [env] Environment variables; defaults to process.env.
|
|
265
|
+
* @param {Function} [run] Pipe-hold runner; defaults to runPipeHoldHelper (injectable for tests).
|
|
266
|
+
* @returns {Promise<void>}
|
|
201
267
|
*/
|
|
202
|
-
async function
|
|
203
|
-
|
|
204
|
-
//
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
268
|
+
export async function handleLaunchHold(env = process.env, run = runPipeHoldHelper) {
|
|
269
|
+
let chromeArgs = [];
|
|
270
|
+
// Reason: fail-open to [] — a malformed env var must not strand the helper.
|
|
271
|
+
try { chromeArgs = JSON.parse(env.DASSI_HOLD_CHROME_ARGS ?? '[]'); } catch { process.stderr.write('⚠️ Ignoring malformed DASSI_HOLD_CHROME_ARGS (expected JSON array).\n'); }
|
|
272
|
+
if (!Array.isArray(chromeArgs)) chromeArgs = [];
|
|
273
|
+
await run({
|
|
274
|
+
label: env.DASSI_HOLD_LABEL,
|
|
275
|
+
distPath: env.DASSI_HOLD_DIST,
|
|
276
|
+
profileDir: env.DASSI_HOLD_PROFILE_DIR,
|
|
277
|
+
bridgePort: Number(env.DASSI_HOLD_BRIDGE_PORT),
|
|
278
|
+
chromePath: env.DASSI_HOLD_CHROME,
|
|
279
|
+
chromeArgs,
|
|
208
280
|
});
|
|
209
|
-
|
|
210
|
-
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Route a `launch*` CLI action to its handler. Returns true if it was a launch action
|
|
285
|
+
* (so the caller can `return`), false otherwise. Keeps the launch surface out of dassi.mjs.
|
|
286
|
+
* @param {string} action
|
|
287
|
+
* @param {object} params
|
|
288
|
+
* @param {{ spawn:Function; ensureDaemonRunning:Function; getSocketPath:Function; sendCommand:Function; appDir:string; launchesFile:string; kill?:Function; handleHold?:Function }} deps
|
|
289
|
+
* `handleHold` overrides the `__launch-hold` runner (injectable for tests; defaults to handleLaunchHold).
|
|
290
|
+
* @returns {Promise<boolean>}
|
|
291
|
+
*/
|
|
292
|
+
export async function dispatchLaunch(action, params, deps) {
|
|
293
|
+
if (action === 'launch') { await handleLaunch(params, deps); return true; }
|
|
294
|
+
if (action === 'launch_stop') { handleStop(params, { launchesFile: deps.launchesFile, kill: deps.kill }); return true; }
|
|
295
|
+
if (action === 'launch_hold') { await (deps.handleHold ?? handleLaunchHold)(); return true; }
|
|
296
|
+
return false;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Flag mode (Chrome for Testing / Chromium): detached Chrome with a persistent
|
|
301
|
+
* `--load-extension`. The browser keeps the extension after this CLI exits.
|
|
302
|
+
* @returns {Promise<number|null>} the Chrome pid, or null on spawn/connect failure.
|
|
303
|
+
*/
|
|
304
|
+
async function spawnFlagAndConnect({ spawn, kill, sendCommand, socketPath, chromePath, distPath, profileDir, bridgePort, opts, error }) {
|
|
305
|
+
const child = spawn(chromePath, buildFlagArgs({ distPath, profileDir, label: opts.label, bridgePort, chromeArgs: opts.chromeArgs ?? [] }), { detached: true, stdio: 'ignore' });
|
|
211
306
|
child.on('error', () => {});
|
|
212
307
|
child.unref();
|
|
213
308
|
if (typeof child.pid !== 'number') {
|
|
214
309
|
error('❌ Failed to launch Chrome — check the Chrome path (--chrome).');
|
|
215
310
|
return null;
|
|
216
311
|
}
|
|
217
|
-
|
|
218
312
|
error(`Launching Chrome (pid ${child.pid}) with ${distPath} — waiting for "${opts.label}" to connect…`);
|
|
219
313
|
if (await waitForProfile({ sendCommand, socketPath, label: opts.label, timeoutMs: opts.timeoutMs })) return child.pid;
|
|
220
|
-
|
|
221
|
-
// Reason: kill the detached Chrome we started so a connect-timeout never orphans a browser.
|
|
222
314
|
try { kill(child.pid); } catch { /* already gone */ }
|
|
223
|
-
error(`❌ "${opts.label}" did not connect within ${opts.timeoutMs}ms (Chrome already using ${profileDir},
|
|
315
|
+
error(`❌ "${opts.label}" did not connect within ${opts.timeoutMs}ms (Chrome already using ${profileDir}, or a stale profile).`);
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Pipe mode (branded Chrome): spawn a detached `dassi __launch-hold` helper that holds the
|
|
321
|
+
* CDP pipe open so the loadUnpacked extension persists. The recorded pid is the HELPER's —
|
|
322
|
+
* `dassi launch --stop` SIGTERMs it, and it closes the pipe + kills its Chrome child.
|
|
323
|
+
* @returns {Promise<number|null>} the helper pid, or null on spawn/connect failure.
|
|
324
|
+
*/
|
|
325
|
+
async function spawnHelperAndConnect({ spawn, kill, sendCommand, socketPath, chromePath, distPath, profileDir, bridgePort, opts, error, helperCommand }) {
|
|
326
|
+
const [node, cli] = helperCommand;
|
|
327
|
+
// Reason: pass the helper's config via env (not flags) so it can't collide with the
|
|
328
|
+
// CLI's global --label/--profile parsing.
|
|
329
|
+
const helper = spawn(node, [cli, '__launch-hold'], {
|
|
330
|
+
detached: true,
|
|
331
|
+
stdio: 'ignore',
|
|
332
|
+
env: {
|
|
333
|
+
...process.env,
|
|
334
|
+
DASSI_HOLD_LABEL: opts.label,
|
|
335
|
+
DASSI_HOLD_DIST: distPath,
|
|
336
|
+
DASSI_HOLD_PROFILE_DIR: profileDir,
|
|
337
|
+
DASSI_HOLD_BRIDGE_PORT: String(bridgePort),
|
|
338
|
+
DASSI_HOLD_CHROME: chromePath,
|
|
339
|
+
// Reason: JSON, not space-joined — Chrome args may contain spaces.
|
|
340
|
+
DASSI_HOLD_CHROME_ARGS: JSON.stringify(opts.chromeArgs ?? []),
|
|
341
|
+
},
|
|
342
|
+
});
|
|
343
|
+
helper.on('error', () => {});
|
|
344
|
+
helper.unref();
|
|
345
|
+
if (typeof helper.pid !== 'number') {
|
|
346
|
+
error('❌ Failed to start the launch helper.');
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
error(`Launching Chrome via pipe helper (pid ${helper.pid}) with ${distPath} — waiting for "${opts.label}" to connect…`);
|
|
350
|
+
if (await waitForProfile({ sendCommand, socketPath, label: opts.label, timeoutMs: opts.timeoutMs })) return helper.pid;
|
|
351
|
+
try { kill(helper.pid); } catch { /* already gone */ }
|
|
352
|
+
error(`❌ "${opts.label}" did not connect within ${opts.timeoutMs}ms (extension installed but the bridge didn't register — daemon port mismatch or a stale profile).`);
|
|
224
353
|
return null;
|
|
225
354
|
}
|
|
226
355
|
|
|
356
|
+
/**
|
|
357
|
+
* Long-lived `dassi __launch-hold` helper (a detached child of `dassi launch` on branded
|
|
358
|
+
* Chrome): spawns Chrome with the CDP pipe, installs the dist, signals readiness, then
|
|
359
|
+
* HOLDS the pipe open until killed — a loadUnpacked extension lives only as long as its
|
|
360
|
+
* debugging session. On SIGTERM/SIGINT (from `dassi launch --stop`) or if Chrome exits, it
|
|
361
|
+
* tears down and exits.
|
|
362
|
+
* @param {{ distPath:string; profileDir:string; label:string; bridgePort:number; chromePath:string;
|
|
363
|
+
* chromeArgs?:string[];
|
|
364
|
+
* spawn?:Function; loadExtension?:Function; onReady?:Function; hold?:()=>Promise<void>;
|
|
365
|
+
* error?:Function; exit?:Function }} o
|
|
366
|
+
* @returns {Promise<void>} In production never resolves (hold() is infinite until killed);
|
|
367
|
+
* resolves after calling exit(1) on extension-install failure.
|
|
368
|
+
*/
|
|
369
|
+
export async function runPipeHoldHelper(o) {
|
|
370
|
+
const {
|
|
371
|
+
distPath, profileDir, label, bridgePort, chromePath,
|
|
372
|
+
chromeArgs = [],
|
|
373
|
+
spawn = childSpawn, loadExtension = loadExtensionOverPipe,
|
|
374
|
+
onReady = () => {}, hold = () => new Promise(() => {}),
|
|
375
|
+
error = (m) => process.stderr.write(`${m}\n`), exit = process.exit,
|
|
376
|
+
} = o;
|
|
377
|
+
const child = spawn(chromePath, buildLaunchArgs({ profileDir, chromeArgs }), { stdio: ['ignore', 'ignore', 'ignore', 'pipe', 'pipe'] });
|
|
378
|
+
let dispose = () => {};
|
|
379
|
+
let shuttingDown = false;
|
|
380
|
+
const shutdown = () => {
|
|
381
|
+
if (shuttingDown) return;
|
|
382
|
+
shuttingDown = true;
|
|
383
|
+
try { dispose(); } catch { /* ignore */ }
|
|
384
|
+
try { child.kill('SIGTERM'); } catch { /* already gone */ }
|
|
385
|
+
exit(0);
|
|
386
|
+
};
|
|
387
|
+
// Reason: this helper runs as a detached process whose whole job is to hold the CDP
|
|
388
|
+
// pipe open. SIGTERM is how `dassi launch --stop` tears it down; SIGINT covers Ctrl-C.
|
|
389
|
+
// Without these handlers the Chrome child (and its loaded extension) would be orphaned.
|
|
390
|
+
process.on('SIGTERM', shutdown);
|
|
391
|
+
process.on('SIGINT', shutdown);
|
|
392
|
+
child.on('error', () => {});
|
|
393
|
+
child.on('exit', () => { if (!shuttingDown) exit(0); }); // Chrome died → stop holding
|
|
394
|
+
try {
|
|
395
|
+
({ dispose } = await loadExtension({ child, distPath, label, bridgePort }));
|
|
396
|
+
} catch (err) {
|
|
397
|
+
// Reason: set shuttingDown FIRST so the child.on('exit') guard above can't win the
|
|
398
|
+
// race and call exit(0) — a failed install must surface as exit(1), not false success.
|
|
399
|
+
shuttingDown = true;
|
|
400
|
+
error(`launch-hold: failed to install extension: ${err.message}`);
|
|
401
|
+
try { child.kill('SIGTERM'); } catch { /* already gone */ }
|
|
402
|
+
return exit(1);
|
|
403
|
+
}
|
|
404
|
+
onReady();
|
|
405
|
+
await hold(); // hold the pipe open (draining) until killed
|
|
406
|
+
}
|
|
407
|
+
|
|
227
408
|
/**
|
|
228
409
|
* Resolve the dist dir, Chrome binary, and profile dir for a launch.
|
|
229
410
|
* Calls `exit(1)` and returns null on any failure (extracted to keep
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dassi_ai/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "CLI for the Dassi Chrome extension
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "CLI for the Dassi Chrome extension \u2014 run browser automation from the terminal",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"dassi": "./dassi.mjs"
|
|
@@ -9,11 +9,13 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
"dassi.mjs",
|
|
11
11
|
"dassi-daemon.mjs",
|
|
12
|
+
"daemon-client.mjs",
|
|
12
13
|
"dassi-shared.mjs",
|
|
13
14
|
"tool-commands.mjs",
|
|
14
15
|
"format-response.mjs",
|
|
15
16
|
"group-expansion.mjs",
|
|
16
17
|
"launch.mjs",
|
|
18
|
+
"_launch-pipe.mjs",
|
|
17
19
|
"help-text.mjs",
|
|
18
20
|
".claude-plugin/",
|
|
19
21
|
"skills/",
|
|
@@ -32,11 +32,26 @@ Each accepts `--tab <id>` OR `--group <id>` OR `--group-title <name>`, except wh
|
|
|
32
32
|
| `open [url]` | url? | **Single-target only** (`--tab` required). Opens new tab in current group. |
|
|
33
33
|
| `close` | — | Close tab. Fans out across a group = close all member tabs. |
|
|
34
34
|
|
|
35
|
+
## Dev launch commands (loading a local build for testing)
|
|
36
|
+
|
|
37
|
+
| Command | Args | Notes |
|
|
38
|
+
|---|---|---|
|
|
39
|
+
| `dassi launch` | `--label <name>` (default `dev`), `--dist <path>` (default `extension/dist`), `--chrome <path>`, `--load-mode auto\|pipe\|flag`, `--timeout <ms>` | Open a dedicated Chrome with a locally-built dev dist loaded, registered under `--label`. Then drive it by adding `--profile <label>` to any command. |
|
|
40
|
+
| `dassi launch --stop [label]` / `--stop-all` | label? | Close a launched Chrome (default label `dev`). |
|
|
41
|
+
| `dassi list-profiles` | `--json` | List connected Chrome instances (profiles), by `label`/id. |
|
|
42
|
+
|
|
43
|
+
**How the extension is loaded** (`--load-mode`, default `auto`):
|
|
44
|
+
- **Branded Google Chrome 137+** disabled the `--load-extension` flag (`ERR_BLOCKED_BY_CLIENT`), so launch installs the dist at runtime via the `Extensions.loadUnpacked` CDP command over `--remote-debugging-pipe`. Such an extension is tied to the debugging session, so launch spawns a detached helper that holds the pipe open; `--stop` kills the helper (which closes the pipe + its Chrome).
|
|
45
|
+
- **Chrome for Testing / Chromium** still honour `--load-extension` (persistent) → used directly, no helper.
|
|
46
|
+
- `--load-mode pipe|flag` forces a mode (e.g. `--chrome <cft> --load-mode pipe` exercises the pipe path on Chrome for Testing); `auto` detects from the binary's `--version`.
|
|
47
|
+
- A freshly launched profile is **signed out** — sign in to that Chrome before `dassi run`/agent commands work in it.
|
|
48
|
+
|
|
35
49
|
## Global options
|
|
36
50
|
|
|
37
51
|
| Flag | Effect |
|
|
38
52
|
|---|---|
|
|
39
53
|
| `--session <name>` | Daemon session name (default `default`). Selects which per-session daemon process and Unix socket the CLI connects to. Each distinct `--session` value spawns its own daemon; only one can be running at a time because they all bind the same WebSocket port (see the "Multi-tab dispatch is sequential" note below). |
|
|
54
|
+
| `--profile <label>` (alias `--label`) | Target a specific connected Chrome instance (e.g. one started by `dassi launch --label qa`). Required when multiple profiles are connected. |
|
|
40
55
|
| `--json` | Raw JSON output (in group fan-out: single JSON array of `{tabId, response}` entries). |
|
|
41
56
|
| `--version`, `--help` | Self-explanatory. |
|
|
42
57
|
|