@dassi_ai/cli 0.4.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/README.md +3 -0
- package/daemon-client.mjs +227 -0
- package/dassi.mjs +32 -216
- package/help-text.mjs +4 -4
- package/launch.mjs +20 -7
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -78,6 +78,9 @@ dassi launch --label qa --dist some/dist # custom label + dist
|
|
|
78
78
|
dassi list-tabs --profile dev # find a tab id in the launched profile
|
|
79
79
|
dassi run "summarize this page" --tab <id> --profile dev
|
|
80
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
|
|
81
84
|
# Note: launch reuses the default daemon (port 18790). To launch on an isolated
|
|
82
85
|
# port (DASSI_BRIDGE_PORT=18791 dassi launch), no default daemon may be running —
|
|
83
86
|
# it errors clearly otherwise, since it can't confirm the running daemon's port.
|
|
@@ -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,21 +4,12 @@
|
|
|
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
|
-
getReadyFile,
|
|
15
|
-
isDaemonRunning,
|
|
16
|
-
parseReadyPayload,
|
|
17
|
-
validateSession,
|
|
18
|
-
DASSI_EXTENSION_ID,
|
|
19
|
-
getAppDir,
|
|
20
|
-
getLaunchesFile,
|
|
21
|
-
} from './dassi-shared.mjs';
|
|
11
|
+
import { getSocketPath, validateSession, getAppDir, getLaunchesFile } from './dassi-shared.mjs';
|
|
12
|
+
import { ensureDaemonRunning, sendCommand, ensureDaemonReady } from './daemon-client.mjs';
|
|
22
13
|
import { dispatchLaunch } from './launch.mjs';
|
|
23
14
|
import { parseToolCommand, requireTarget, parseStrictInt } from './tool-commands.mjs';
|
|
24
15
|
import { runWithGroupExpansion } from './group-expansion.mjs';
|
|
@@ -26,13 +17,12 @@ 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`
|
|
@@ -117,6 +130,7 @@ export function parseCliArgs(argv) {
|
|
|
117
130
|
profileDir: getFlag(args, '--profile-dir') ?? null,
|
|
118
131
|
loadMode,
|
|
119
132
|
timeoutMs: timeoutRaw ? parseStrictInt(timeoutRaw, '--timeout') : 30000,
|
|
133
|
+
chromeArgs,
|
|
120
134
|
});
|
|
121
135
|
}
|
|
122
136
|
|
|
@@ -199,167 +213,6 @@ function consumeFlag(args, flag) {
|
|
|
199
213
|
return true;
|
|
200
214
|
}
|
|
201
215
|
|
|
202
|
-
// ─── Daemon management ────────────────────────────────────────────────────────
|
|
203
|
-
|
|
204
|
-
/**
|
|
205
|
-
* Ensures the daemon is running for the given session.
|
|
206
|
-
* Spawns it as a detached background process if the PID file is missing or stale.
|
|
207
|
-
* @param {string} session
|
|
208
|
-
*/
|
|
209
|
-
export function ensureDaemonRunning(session) {
|
|
210
|
-
if (isDaemonRunning(session)) return;
|
|
211
|
-
|
|
212
|
-
// Reason: detached + unref means the daemon outlives the CLI process
|
|
213
|
-
const daemon = child_process.spawn(process.execPath, [DAEMON_SCRIPT], {
|
|
214
|
-
detached: true,
|
|
215
|
-
stdio: 'ignore',
|
|
216
|
-
env: { ...process.env, DASSI_SESSION: session },
|
|
217
|
-
});
|
|
218
|
-
daemon.unref();
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/**
|
|
222
|
-
* Polls for the ready file until it appears or the timeout elapses.
|
|
223
|
-
* Resolves with the parsed ready payload.
|
|
224
|
-
* @param {string} readyFile
|
|
225
|
-
* @param {number} [timeoutMs]
|
|
226
|
-
* @returns {Promise<{ status: string; [key: string]: unknown }>}
|
|
227
|
-
*/
|
|
228
|
-
export function waitForReady(readyFile, timeoutMs = READY_TIMEOUT_MS) {
|
|
229
|
-
return new Promise((resolve, reject) => {
|
|
230
|
-
const deadline = Date.now() + timeoutMs;
|
|
231
|
-
const check = () => {
|
|
232
|
-
if (fs.existsSync(readyFile)) {
|
|
233
|
-
try {
|
|
234
|
-
resolve(parseReadyPayload(fs.readFileSync(readyFile, 'utf8')));
|
|
235
|
-
return;
|
|
236
|
-
} catch { /* file may be partially written — keep polling */ }
|
|
237
|
-
}
|
|
238
|
-
if (Date.now() >= deadline) {
|
|
239
|
-
const seconds = Math.round(timeoutMs / 1000);
|
|
240
|
-
reject(new Error(`Timed out waiting for Dassi daemon to start (${seconds}s)`));
|
|
241
|
-
return;
|
|
242
|
-
}
|
|
243
|
-
setTimeout(check, READY_POLL_MS);
|
|
244
|
-
};
|
|
245
|
-
check();
|
|
246
|
-
});
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
/**
|
|
250
|
-
* Connects to the daemon Unix socket, sends one NDJSON command, and reads the response.
|
|
251
|
-
* @param {string} socketPath - Unix socket path.
|
|
252
|
-
* @param {object} command - Command object to send (will be JSON-stringified).
|
|
253
|
-
* @returns {Promise<object>} Parsed response object.
|
|
254
|
-
*/
|
|
255
|
-
export function sendCommand(socketPath, command) {
|
|
256
|
-
return new Promise((resolve, reject) => {
|
|
257
|
-
const socket = net.createConnection(socketPath);
|
|
258
|
-
let buffer = '';
|
|
259
|
-
let settled = false;
|
|
260
|
-
|
|
261
|
-
// Reason: guard against double-settlement since 'close' fires after socket.destroy()
|
|
262
|
-
// in the normal data path, and we don't want the close handler to re-resolve
|
|
263
|
-
const settle = (fn, val) => {
|
|
264
|
-
if (settled) return;
|
|
265
|
-
settled = true;
|
|
266
|
-
fn(val);
|
|
267
|
-
};
|
|
268
|
-
|
|
269
|
-
socket.on('connect', () => {
|
|
270
|
-
socket.write(JSON.stringify(command) + '\n');
|
|
271
|
-
});
|
|
272
|
-
|
|
273
|
-
socket.on('data', (chunk) => {
|
|
274
|
-
buffer += chunk.toString();
|
|
275
|
-
const nl = buffer.indexOf('\n');
|
|
276
|
-
if (nl !== -1) {
|
|
277
|
-
const line = buffer.slice(0, nl);
|
|
278
|
-
socket.destroy();
|
|
279
|
-
try { settle(resolve, JSON.parse(line)); }
|
|
280
|
-
catch { settle(reject, new Error(`Invalid JSON from daemon: ${line}`)); }
|
|
281
|
-
}
|
|
282
|
-
});
|
|
283
|
-
|
|
284
|
-
socket.on('error', (err) => settle(reject, err));
|
|
285
|
-
|
|
286
|
-
socket.on('close', () => {
|
|
287
|
-
if (settled) return;
|
|
288
|
-
// Reason: attempt to parse whatever arrived before the socket closed unexpectedly
|
|
289
|
-
if (buffer.trim()) {
|
|
290
|
-
try { settle(resolve, JSON.parse(buffer.trim())); }
|
|
291
|
-
catch { settle(reject, new Error(`Invalid JSON from daemon: ${buffer.trim()}`)); }
|
|
292
|
-
} else {
|
|
293
|
-
settle(reject, new Error('Daemon closed connection without a response'));
|
|
294
|
-
}
|
|
295
|
-
});
|
|
296
|
-
});
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
// ─── Login helpers ────────────────────────────────────────────────────────────
|
|
300
|
-
|
|
301
|
-
/**
|
|
302
|
-
* Defense-in-depth check for `optionsUrl` before handing it to the `open`
|
|
303
|
-
* package. Legit URLs always come from `chrome.runtime.getURL('options.html')`
|
|
304
|
-
* → `chrome-extension://<id>/options.html`. Anything else is unexpected and
|
|
305
|
-
* we should not auto-launch it (the `open` package shells out to the OS URL
|
|
306
|
-
* handler).
|
|
307
|
-
* @param {unknown} optionsUrl
|
|
308
|
-
* @returns {boolean}
|
|
309
|
-
*/
|
|
310
|
-
export function isValidOptionsUrl(optionsUrl) {
|
|
311
|
-
return typeof optionsUrl === 'string' && /^chrome-extension:\/\/[a-z]{32}\//.test(optionsUrl);
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
/**
|
|
315
|
-
* Polls the daemon's status until the user signs in (extension reports
|
|
316
|
-
* authenticated=true) or the LOGIN_TIMEOUT_MS deadline elapses. On entry,
|
|
317
|
-
* best-effort auto-opens the extension's options page (subject to the
|
|
318
|
-
* isValidOptionsUrl guard above).
|
|
319
|
-
* @param {string} socketPath - Path to the daemon Unix socket.
|
|
320
|
-
* @param {string | undefined} optionsUrl - URL of the Dassi options page.
|
|
321
|
-
* @returns {Promise<void>} Resolves on successful login; throws on timeout.
|
|
322
|
-
*/
|
|
323
|
-
export async function waitForLogin(socketPath, optionsUrl) {
|
|
324
|
-
console.error(`⚠️ Dassi is installed but you're not signed in.\n Opening the Dassi settings page...\n`);
|
|
325
|
-
|
|
326
|
-
// Auto-open the options page — best-effort (package may not be installed).
|
|
327
|
-
// Reason: only open URLs the extension would legitimately produce
|
|
328
|
-
// (chrome.runtime.getURL → `chrome-extension://<id>/options.html`).
|
|
329
|
-
// Defense-in-depth: even though optionsUrl is sourced from the daemon's
|
|
330
|
-
// ready file (which lives in our owner-only ~/.dassi/ dir), validating the
|
|
331
|
-
// protocol prevents `open` from launching arbitrary URIs/shell-handlers if
|
|
332
|
-
// the file is ever tampered with.
|
|
333
|
-
if (isValidOptionsUrl(optionsUrl)) {
|
|
334
|
-
try {
|
|
335
|
-
const { default: open } = await import('open');
|
|
336
|
-
await open(String(optionsUrl));
|
|
337
|
-
} catch {
|
|
338
|
-
console.error(` Please open: ${optionsUrl}`);
|
|
339
|
-
}
|
|
340
|
-
} else if (optionsUrl) {
|
|
341
|
-
console.error(` Refusing to auto-open unexpected URL: ${optionsUrl}\n Please open the Dassi settings page manually.`);
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
// Poll the daemon socket every LOGIN_POLL_MS until authenticated
|
|
345
|
-
console.error(' Waiting for sign-in... (Ctrl+C to cancel)');
|
|
346
|
-
const deadline = Date.now() + LOGIN_TIMEOUT_MS;
|
|
347
|
-
let pollIndex = 0;
|
|
348
|
-
while (Date.now() < deadline) {
|
|
349
|
-
await new Promise((r) => setTimeout(r, LOGIN_POLL_MS));
|
|
350
|
-
try {
|
|
351
|
-
// Reason: increment poll index so each status request has a unique id for tracing
|
|
352
|
-
const resp = await sendCommand(socketPath, { id: `poll-auth-${pollIndex++}`, action: 'status' });
|
|
353
|
-
if (resp.success && resp.data?.authenticated) {
|
|
354
|
-
console.error(` ✓ Signed in as ${resp.data.email}\n ✓ Ready\n`);
|
|
355
|
-
return;
|
|
356
|
-
}
|
|
357
|
-
} catch { /* daemon may not be socket-ready yet — keep polling */ }
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
throw new Error('Login timed out. Please sign in and try again.');
|
|
361
|
-
}
|
|
362
|
-
|
|
363
216
|
// ─── Immediate actions ────────────────────────────────────────────────────────
|
|
364
217
|
|
|
365
218
|
// HELP_TEXT lives in ./help-text.mjs (extracted to keep this file within the size limit).
|
|
@@ -381,43 +234,6 @@ function handleImmediateAction(action) {
|
|
|
381
234
|
return false;
|
|
382
235
|
}
|
|
383
236
|
|
|
384
|
-
// ─── Daemon readiness ─────────────────────────────────────────────────────────
|
|
385
|
-
|
|
386
|
-
/**
|
|
387
|
-
* Ensures the daemon is running and ready, handling onboarding if needed.
|
|
388
|
-
* @param {string} session
|
|
389
|
-
* @returns {Promise<string>} The daemon's Unix socket path.
|
|
390
|
-
*/
|
|
391
|
-
async function ensureDaemonReady(session) {
|
|
392
|
-
const socketPath = getSocketPath(session);
|
|
393
|
-
const readyFile = getReadyFile(session);
|
|
394
|
-
|
|
395
|
-
// Reason: delete any stale ready file from a previous run so the new daemon
|
|
396
|
-
// writes a fresh one. Skip if a daemon is already running to avoid racing.
|
|
397
|
-
if (!isDaemonRunning(session)) {
|
|
398
|
-
try { fs.unlinkSync(readyFile); } catch { /* ok if missing */ }
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
ensureDaemonRunning(session);
|
|
402
|
-
|
|
403
|
-
const ready = await waitForReady(readyFile);
|
|
404
|
-
|
|
405
|
-
if (ready.status === 'extension_not_installed') {
|
|
406
|
-
console.error(
|
|
407
|
-
`❌ Dassi extension not detected.\n\n` +
|
|
408
|
-
` Install it from:\n ${CHROME_WEB_STORE_URL}\n\n` +
|
|
409
|
-
` Then run this command again.`
|
|
410
|
-
);
|
|
411
|
-
process.exit(1);
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
if (ready.status === 'needs_login') {
|
|
415
|
-
await waitForLogin(socketPath, ready.optionsUrl ? String(ready.optionsUrl) : undefined);
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
return socketPath;
|
|
419
|
-
}
|
|
420
|
-
|
|
421
237
|
// ─── Entry point ──────────────────────────────────────────────────────────────
|
|
422
238
|
|
|
423
239
|
/**
|
package/help-text.mjs
CHANGED
|
@@ -27,10 +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>] [--chrome <path>]
|
|
31
|
-
'
|
|
32
|
-
'
|
|
33
|
-
' --
|
|
30
|
+
' launch [--label <name>] [--dist <path>] [--chrome <path>]\n' +
|
|
31
|
+
' [--load-mode auto|pipe|flag] [-- <chrome args…>]\n' +
|
|
32
|
+
' Open Chrome with a dev dist loaded\n' +
|
|
33
|
+
' (args after -- go to Chrome, e.g. --headless=new)\n' +
|
|
34
34
|
' launch --stop [label] | --stop-all\n' +
|
|
35
35
|
' Stop a launched Chrome\n\n' +
|
|
36
36
|
'Options:\n' +
|
package/launch.mjs
CHANGED
|
@@ -58,16 +58,17 @@ export function resolveDistPath({ dist = null, cwd = process.cwd(), exists = exi
|
|
|
58
58
|
* `--remote-debugging-pipe` transport (fds 3/4) — not `--remote-debugging-port` —
|
|
59
59
|
* and requires `--enable-unsafe-extension-debugging`. Works on branded Chrome,
|
|
60
60
|
* Chrome for Testing, and Chromium alike.
|
|
61
|
-
* @param {{ profileDir: string }} opts
|
|
61
|
+
* @param {{ profileDir: string; chromeArgs?: string[] }} opts
|
|
62
62
|
* @returns {string[]} Chrome args.
|
|
63
63
|
*/
|
|
64
|
-
export function buildLaunchArgs({ profileDir }) {
|
|
64
|
+
export function buildLaunchArgs({ profileDir, chromeArgs = [] }) {
|
|
65
65
|
return [
|
|
66
66
|
'--remote-debugging-pipe',
|
|
67
67
|
'--enable-unsafe-extension-debugging',
|
|
68
68
|
`--user-data-dir=${profileDir}`,
|
|
69
69
|
'--no-first-run',
|
|
70
70
|
'--no-default-browser-check',
|
|
71
|
+
...chromeArgs,
|
|
71
72
|
];
|
|
72
73
|
}
|
|
73
74
|
|
|
@@ -75,10 +76,10 @@ export function buildLaunchArgs({ profileDir }) {
|
|
|
75
76
|
* Build Chrome args for the legacy `--load-extension` path (Chrome for Testing /
|
|
76
77
|
* Chromium, which still honour it and load the extension *persistently*). The dist
|
|
77
78
|
* is loaded at startup, so the seed options URL can ride along as the startup tab.
|
|
78
|
-
* @param {{ distPath: string; profileDir: string; label: string; bridgePort: number }} opts
|
|
79
|
+
* @param {{ distPath: string; profileDir: string; label: string; bridgePort: number; chromeArgs?: string[] }} opts
|
|
79
80
|
* @returns {string[]} Chrome args (the final entry is the seed URL to open).
|
|
80
81
|
*/
|
|
81
|
-
export function buildFlagArgs({ distPath, profileDir, label, bridgePort }) {
|
|
82
|
+
export function buildFlagArgs({ distPath, profileDir, label, bridgePort, chromeArgs = [] }) {
|
|
82
83
|
const seed = `options.html?label=${encodeURIComponent(label)}&bridgePort=${bridgePort}`;
|
|
83
84
|
return [
|
|
84
85
|
`--load-extension=${distPath}`,
|
|
@@ -86,6 +87,9 @@ export function buildFlagArgs({ distPath, profileDir, label, bridgePort }) {
|
|
|
86
87
|
`--user-data-dir=${profileDir}`,
|
|
87
88
|
'--no-first-run',
|
|
88
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,
|
|
89
93
|
`chrome-extension://${DASSI_EXTENSION_ID}/${seed}`,
|
|
90
94
|
];
|
|
91
95
|
}
|
|
@@ -219,7 +223,7 @@ async function preflightLaunch({ ensureDaemonRunning, getSocketPath, sendCommand
|
|
|
219
223
|
* Launch Chrome with the dev dist loaded under a dedicated profile, wait for the
|
|
220
224
|
* extension to register under `label`, then record it. Auto-detects the load mode
|
|
221
225
|
* (pipe helper for branded Chrome / `--load-extension` for CfT/Chromium).
|
|
222
|
-
* @param {{label:string; dist:string|null; chrome:string|null; profileDir:string|null; loadMode?:string; timeoutMs:number}} opts
|
|
226
|
+
* @param {{label:string; dist:string|null; chrome:string|null; profileDir:string|null; loadMode?:string; timeoutMs:number; chromeArgs?:string[]}} opts
|
|
223
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
|
|
224
228
|
* `detectLoadFlag` chooses pipe-vs-flag mode; `helperCommand` is the argv used to spawn the detached pipe-hold helper (both injectable for tests).
|
|
225
229
|
* @returns {Promise<void>} resolves once the launch is recorded (or `deps.exit` is called on failure).
|
|
@@ -262,12 +266,17 @@ export async function handleLaunch(opts, deps) {
|
|
|
262
266
|
* @returns {Promise<void>}
|
|
263
267
|
*/
|
|
264
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 = [];
|
|
265
273
|
await run({
|
|
266
274
|
label: env.DASSI_HOLD_LABEL,
|
|
267
275
|
distPath: env.DASSI_HOLD_DIST,
|
|
268
276
|
profileDir: env.DASSI_HOLD_PROFILE_DIR,
|
|
269
277
|
bridgePort: Number(env.DASSI_HOLD_BRIDGE_PORT),
|
|
270
278
|
chromePath: env.DASSI_HOLD_CHROME,
|
|
279
|
+
chromeArgs,
|
|
271
280
|
});
|
|
272
281
|
}
|
|
273
282
|
|
|
@@ -293,7 +302,7 @@ export async function dispatchLaunch(action, params, deps) {
|
|
|
293
302
|
* @returns {Promise<number|null>} the Chrome pid, or null on spawn/connect failure.
|
|
294
303
|
*/
|
|
295
304
|
async function spawnFlagAndConnect({ spawn, kill, sendCommand, socketPath, chromePath, distPath, profileDir, bridgePort, opts, error }) {
|
|
296
|
-
const child = spawn(chromePath, buildFlagArgs({ distPath, profileDir, label: opts.label, bridgePort }), { detached: true, stdio: 'ignore' });
|
|
305
|
+
const child = spawn(chromePath, buildFlagArgs({ distPath, profileDir, label: opts.label, bridgePort, chromeArgs: opts.chromeArgs ?? [] }), { detached: true, stdio: 'ignore' });
|
|
297
306
|
child.on('error', () => {});
|
|
298
307
|
child.unref();
|
|
299
308
|
if (typeof child.pid !== 'number') {
|
|
@@ -327,6 +336,8 @@ async function spawnHelperAndConnect({ spawn, kill, sendCommand, socketPath, chr
|
|
|
327
336
|
DASSI_HOLD_PROFILE_DIR: profileDir,
|
|
328
337
|
DASSI_HOLD_BRIDGE_PORT: String(bridgePort),
|
|
329
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 ?? []),
|
|
330
341
|
},
|
|
331
342
|
});
|
|
332
343
|
helper.on('error', () => {});
|
|
@@ -349,6 +360,7 @@ async function spawnHelperAndConnect({ spawn, kill, sendCommand, socketPath, chr
|
|
|
349
360
|
* debugging session. On SIGTERM/SIGINT (from `dassi launch --stop`) or if Chrome exits, it
|
|
350
361
|
* tears down and exits.
|
|
351
362
|
* @param {{ distPath:string; profileDir:string; label:string; bridgePort:number; chromePath:string;
|
|
363
|
+
* chromeArgs?:string[];
|
|
352
364
|
* spawn?:Function; loadExtension?:Function; onReady?:Function; hold?:()=>Promise<void>;
|
|
353
365
|
* error?:Function; exit?:Function }} o
|
|
354
366
|
* @returns {Promise<void>} In production never resolves (hold() is infinite until killed);
|
|
@@ -357,11 +369,12 @@ async function spawnHelperAndConnect({ spawn, kill, sendCommand, socketPath, chr
|
|
|
357
369
|
export async function runPipeHoldHelper(o) {
|
|
358
370
|
const {
|
|
359
371
|
distPath, profileDir, label, bridgePort, chromePath,
|
|
372
|
+
chromeArgs = [],
|
|
360
373
|
spawn = childSpawn, loadExtension = loadExtensionOverPipe,
|
|
361
374
|
onReady = () => {}, hold = () => new Promise(() => {}),
|
|
362
375
|
error = (m) => process.stderr.write(`${m}\n`), exit = process.exit,
|
|
363
376
|
} = o;
|
|
364
|
-
const child = spawn(chromePath, buildLaunchArgs({ profileDir }), { stdio: ['ignore', 'ignore', 'ignore', 'pipe', 'pipe'] });
|
|
377
|
+
const child = spawn(chromePath, buildLaunchArgs({ profileDir, chromeArgs }), { stdio: ['ignore', 'ignore', 'ignore', 'pipe', 'pipe'] });
|
|
365
378
|
let dispose = () => {};
|
|
366
379
|
let shuttingDown = false;
|
|
367
380
|
const shutdown = () => {
|
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,6 +9,7 @@
|
|
|
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",
|