@mehmoodqureshi/chrome-mcp 0.5.1 → 0.6.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.
@@ -30,5 +30,12 @@ export type PolicyVerdict = {
30
30
  * verdict; the caller throws its own error type on `{ ok: false }`.
31
31
  */
32
32
  export declare function evaluatePolicy(url: string, method: WireMethod, policy: WirePolicy): PolicyVerdict;
33
+ /**
34
+ * A plain-English, actionable message for a blocked domain. Tells the user what
35
+ * happened, why it's blocked (safety, not a bug), which sites ARE allowed, and
36
+ * the exact one-line change to permit this one — so a non-technical user is never
37
+ * left at a dead end. Never includes the token or any other secret.
38
+ */
39
+ export declare function blockedDomainMessage(method: string, host: string, policy: WirePolicy): string;
33
40
  /** A wire policy that allows nothing — the safe default when none was delivered. */
34
41
  export declare const DENY_ALL_WIRE_POLICY: WirePolicy;
@@ -17,6 +17,7 @@ exports.isUrlGated = isUrlGated;
17
17
  exports.hostOf = hostOf;
18
18
  exports.isDomainAllowed = isDomainAllowed;
19
19
  exports.evaluatePolicy = evaluatePolicy;
20
+ exports.blockedDomainMessage = blockedDomainMessage;
20
21
  // ---------------------------------------------------------------------------
21
22
  // Method classification
22
23
  // ---------------------------------------------------------------------------
@@ -131,14 +132,27 @@ function evaluatePolicy(url, method, policy) {
131
132
  return { ok: true };
132
133
  if (!isDomainAllowed(url, policy)) {
133
134
  const host = hostOf(url) || url;
134
- return {
135
- ok: false,
136
- reason: `"${method}" denied: ${host} is not in the domain allowlist. ` +
137
- `Add it to allowDomains, or pass --unsafe-all-domains.`,
138
- };
135
+ return { ok: false, reason: blockedDomainMessage(method, host, policy) };
139
136
  }
140
137
  return { ok: true };
141
138
  }
139
+ /**
140
+ * A plain-English, actionable message for a blocked domain. Tells the user what
141
+ * happened, why it's blocked (safety, not a bug), which sites ARE allowed, and
142
+ * the exact one-line change to permit this one — so a non-technical user is never
143
+ * left at a dead end. Never includes the token or any other secret.
144
+ */
145
+ function blockedDomainMessage(method, host, policy) {
146
+ const allowed = policy.allowDomains.filter((d) => d !== '*');
147
+ const allowedLine = allowed.length
148
+ ? `Currently allowed: ${allowed.join(', ')}.`
149
+ : `Right now no sites are allowed.`;
150
+ return (`Blocked: "${method}" can't run on ${host} because it isn't on this browser tool's allowed-sites list. ` +
151
+ `This is a safety limit (the tool drives your real, logged-in browser, so it only touches sites you've approved) — not an error. ` +
152
+ `${allowedLine} ` +
153
+ `To allow ${host}, add it to the chrome-mcp settings as: --allow-domain "${host}" (or "*.${host}" to include subdomains), ` +
154
+ `then restart/reconnect. To allow every site (less safe), use --unsafe-all-domains.`);
155
+ }
142
156
  /** A wire policy that allows nothing — the safe default when none was delivered. */
143
157
  exports.DENY_ALL_WIRE_POLICY = {
144
158
  allowDomains: [],
@@ -48,6 +48,10 @@ export interface HelloFrame extends BaseFrame {
48
48
  version: string;
49
49
  chrome: string;
50
50
  };
51
+ /** Routing label: which profile this browser pairs as. Absent/empty → "default".
52
+ * NOT a security boundary (the token is) — it selects which connection slot the
53
+ * server routes commands to, so several browsers can stay paired at once. */
54
+ profile?: string;
51
55
  }
52
56
  /**
53
57
  * The wire-serializable subset of the server's policy, delivered in `welcome` so
@@ -6,3 +6,42 @@
6
6
  /** Create (if needed) and return the data dir, 0700 so only the user can read it. */
7
7
  export declare function ensureDataDir(dir?: string): string;
8
8
  export declare function handshakePath(dir: string): string;
9
+ /**
10
+ * One-time move of the pre-0.6 flat layout into the `default` profile's
11
+ * workspace: `<dataDir>/cdp-profile` → `profiles/default/cdp-profile` (logins)
12
+ * and `<dataDir>/downloads` → `profiles/default/tasks/default/downloads`.
13
+ *
14
+ * Idempotent and conservative: a leg is migrated only when the legacy dir exists
15
+ * AND its target does not, so it runs at most once and never clobbers a profile
16
+ * the user has already populated. Must be called BEFORE {@link ensureWorkspace},
17
+ * which would otherwise create the (empty) target and block the rename. Returns
18
+ * a human-readable description of each move performed.
19
+ */
20
+ export declare function migrateLegacyLayout(dataDir: string): string[];
21
+ export interface Workspace {
22
+ /** The data dir this workspace lives under — needed to switch profile/task at runtime. */
23
+ dataDir: string;
24
+ profile: string;
25
+ task: string;
26
+ /** `profiles/<profile>/` — passed to the CDP executor as its userDataDir. */
27
+ profileDir: string;
28
+ /** `profiles/<profile>/tasks/<task>/` — per-run artifact root. */
29
+ taskDir: string;
30
+ /** `profiles/<profile>/tasks/<task>/downloads` — captured files for this run. */
31
+ downloadDir: string;
32
+ /** `profiles/<profile>/tasks/<task>/results` — extracted text/markdown/links. */
33
+ resultsDir: string;
34
+ /** `profiles/<profile>/tasks/<task>/screenshots` — PNGs captured during the run. */
35
+ screenshotsDir: string;
36
+ /** `profiles/<profile>/tasks/<task>/history.jsonl` — append-only action log. */
37
+ historyPath: string;
38
+ }
39
+ /**
40
+ * Create (0700) the profile + task directories and stamp the task with a
41
+ * `meta.json`, returning the resolved paths. The CDP profile (identity: cookies
42
+ * & logins) and the downloads (per-run artifacts) live under here so distinct
43
+ * identities and distinct runs never collide. `createdAt` is preserved across
44
+ * restarts so a resumed task keeps its original timestamp; the meta write is
45
+ * best-effort and never fatal.
46
+ */
47
+ export declare function ensureWorkspace(dataDir: string, profile: string, task: string, meta?: Record<string, unknown>): Workspace;
@@ -7,6 +7,8 @@
7
7
  Object.defineProperty(exports, "__esModule", { value: true });
8
8
  exports.ensureDataDir = ensureDataDir;
9
9
  exports.handshakePath = handshakePath;
10
+ exports.migrateLegacyLayout = migrateLegacyLayout;
11
+ exports.ensureWorkspace = ensureWorkspace;
10
12
  const node_fs_1 = require("node:fs");
11
13
  const node_path_1 = require("node:path");
12
14
  const config_1 = require("../config");
@@ -19,4 +21,68 @@ function ensureDataDir(dir) {
19
21
  function handshakePath(dir) {
20
22
  return (0, node_path_1.join)(dir, 'handshake.json');
21
23
  }
24
+ /**
25
+ * One-time move of the pre-0.6 flat layout into the `default` profile's
26
+ * workspace: `<dataDir>/cdp-profile` → `profiles/default/cdp-profile` (logins)
27
+ * and `<dataDir>/downloads` → `profiles/default/tasks/default/downloads`.
28
+ *
29
+ * Idempotent and conservative: a leg is migrated only when the legacy dir exists
30
+ * AND its target does not, so it runs at most once and never clobbers a profile
31
+ * the user has already populated. Must be called BEFORE {@link ensureWorkspace},
32
+ * which would otherwise create the (empty) target and block the rename. Returns
33
+ * a human-readable description of each move performed.
34
+ */
35
+ function migrateLegacyLayout(dataDir) {
36
+ const moved = [];
37
+ const legs = [
38
+ { from: (0, node_path_1.join)(dataDir, 'cdp-profile'), to: (0, node_path_1.join)((0, config_1.resolveProfileDir)(dataDir, 'default'), 'cdp-profile') },
39
+ { from: (0, node_path_1.join)(dataDir, 'downloads'), to: (0, node_path_1.join)((0, config_1.resolveTaskDir)(dataDir, 'default', 'default'), 'downloads') },
40
+ ];
41
+ for (const { from, to } of legs) {
42
+ if (!(0, node_fs_1.existsSync)(from) || (0, node_fs_1.existsSync)(to))
43
+ continue;
44
+ try {
45
+ (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(to), { recursive: true, mode: 0o700 });
46
+ (0, node_fs_1.renameSync)(from, to);
47
+ moved.push(`${from} → ${to}`);
48
+ }
49
+ catch {
50
+ /* best effort: a failed move leaves the legacy dir untouched and usable */
51
+ }
52
+ }
53
+ return moved;
54
+ }
55
+ /**
56
+ * Create (0700) the profile + task directories and stamp the task with a
57
+ * `meta.json`, returning the resolved paths. The CDP profile (identity: cookies
58
+ * & logins) and the downloads (per-run artifacts) live under here so distinct
59
+ * identities and distinct runs never collide. `createdAt` is preserved across
60
+ * restarts so a resumed task keeps its original timestamp; the meta write is
61
+ * best-effort and never fatal.
62
+ */
63
+ function ensureWorkspace(dataDir, profile, task, meta = {}) {
64
+ const profileDir = (0, config_1.resolveProfileDir)(dataDir, profile);
65
+ const taskDir = (0, config_1.resolveTaskDir)(dataDir, profile, task);
66
+ const downloadDir = (0, node_path_1.join)(taskDir, 'downloads');
67
+ const resultsDir = (0, node_path_1.join)(taskDir, 'results');
68
+ const screenshotsDir = (0, node_path_1.join)(taskDir, 'screenshots');
69
+ const historyPath = (0, node_path_1.join)(taskDir, 'history.jsonl');
70
+ // Create the three artifact buckets up front (0700) so every capture path can
71
+ // assume its directory exists.
72
+ for (const d of [downloadDir, resultsDir, screenshotsDir]) {
73
+ (0, node_fs_1.mkdirSync)(d, { recursive: true, mode: 0o700 });
74
+ }
75
+ const metaPath = (0, node_path_1.join)(taskDir, 'meta.json');
76
+ try {
77
+ let createdAt;
78
+ if ((0, node_fs_1.existsSync)(metaPath)) {
79
+ createdAt = JSON.parse((0, node_fs_1.readFileSync)(metaPath, 'utf8')).createdAt;
80
+ }
81
+ (0, node_fs_1.writeFileSync)(metaPath, JSON.stringify({ ...meta, profile, task, createdAt: createdAt ?? meta.createdAt }, null, 2), { mode: 0o600 });
82
+ }
83
+ catch {
84
+ /* non-fatal: a missing meta.json never blocks the server */
85
+ }
86
+ return { dataDir, profile, task, profileDir, taskDir, downloadDir, resultsDir, screenshotsDir, historyPath };
87
+ }
22
88
  //# sourceMappingURL=datadir.js.map
@@ -34,24 +34,39 @@ export interface BridgeOptions {
34
34
  export declare class BridgeServer {
35
35
  private readonly opts;
36
36
  private wss;
37
- private active;
37
+ /** Profile routing key → its live connection. Multiple browsers stay paired at
38
+ * once; a command is routed to the connection for its target profile. A new
39
+ * hello for the SAME profile supersedes that profile's connection only. */
40
+ private conns;
38
41
  private boundPort;
39
42
  private readonly heartbeatMs;
40
43
  constructor(opts: BridgeOptions);
41
44
  /** Bind and start listening. Returns the actual port (useful with port 0). */
42
45
  start(): Promise<number>;
46
+ /** One bind attempt. Resolves with a listening server or rejects with the listen error. */
47
+ private listenOnce;
43
48
  stop(): Promise<void>;
44
49
  get port(): number;
50
+ /** True when ANY browser is paired (used as the selector's cheap gate). */
45
51
  hasActiveExtension(): boolean;
46
- /** Send a command to the active extension, or reject if none is connected. */
52
+ /** True when the given profile has a live connection. */
53
+ hasConnection(profile: string): boolean;
54
+ /** Profiles with a live connection right now. */
55
+ connectedProfiles(): string[];
56
+ /**
57
+ * Send a command to the connection for `opts.profile` (default "default").
58
+ * Rejects with an actionable message if that profile has no live browser.
59
+ */
47
60
  sendCommand(method: WireMethod, params: Record<string, unknown>, opts?: {
48
61
  tabId?: string;
49
62
  timeoutMs?: number;
63
+ profile?: string;
50
64
  }): Promise<unknown>;
65
+ private noPairMessage;
51
66
  status(): {
52
67
  extensionConnected: boolean;
53
68
  port: number;
54
- sessionId: string | null;
69
+ connectedProfiles: string[];
55
70
  };
56
71
  private handleConnection;
57
72
  private reject;
@@ -20,14 +20,45 @@ const policy_1 = require("../../shared/policy");
20
20
  const types_1 = require("../executor/types");
21
21
  const connection_1 = require("./connection");
22
22
  const auth_1 = require("./auth");
23
+ const config_1 = require("../config");
24
+ /** The routing label for a hello with no/blank profile — the back-compat default. */
25
+ const DEFAULT_PROFILE = 'default';
26
+ /** Reduce a hello's profile label to a safe routing key; blank/invalid → "default". */
27
+ function routeKey(profile) {
28
+ if (!profile || !profile.trim())
29
+ return DEFAULT_PROFILE;
30
+ try {
31
+ return (0, config_1.sanitizeName)(profile, 'profile');
32
+ }
33
+ catch {
34
+ return DEFAULT_PROFILE;
35
+ }
36
+ }
23
37
  const HELLO_TIMEOUT_MS = 5_000;
24
38
  const DEFAULT_HEARTBEAT_MS = 15_000;
25
39
  /** Max pre-auth frames a socket may send before a valid hello (anti-idle-hold). */
26
40
  const MAX_PREAUTH_FRAMES = 10;
41
+ /** How long to wait for a just-replaced instance to release a fixed port before giving up. */
42
+ const PORT_WAIT_MS = 4_000;
43
+ /** Pause between port-bind retries while waiting for the old listener to exit. */
44
+ const PORT_RETRY_MS = 250;
45
+ const delay = (ms) => new Promise((r) => setTimeout(r, ms));
46
+ /** A friendly, actionable message for the rare case the port stays busy past PORT_WAIT_MS. */
47
+ function portBusyMessage(host, port) {
48
+ return (`Couldn't start: another program is already using ${host}:${port}.\n` +
49
+ `This is almost always a previous chrome-mcp that didn't shut down. To fix it:\n` +
50
+ ` 1. Fully quit/restart your MCP host (e.g. Claude Code), or\n` +
51
+ ` 2. Stop the leftover process, then reconnect:\n` +
52
+ ` macOS/Linux: lsof -nP -iTCP:${port} -sTCP:LISTEN then kill <PID>\n` +
53
+ ` 3. Or run chrome-mcp with a different port: --port <number>`);
54
+ }
27
55
  class BridgeServer {
28
56
  opts;
29
57
  wss = null;
30
- active = null;
58
+ /** Profile routing key → its live connection. Multiple browsers stay paired at
59
+ * once; a command is routed to the connection for its target profile. A new
60
+ * hello for the SAME profile supersedes that profile's connection only. */
61
+ conns = new Map();
31
62
  boundPort = 0;
32
63
  heartbeatMs;
33
64
  constructor(opts) {
@@ -38,21 +69,63 @@ class BridgeServer {
38
69
  async start() {
39
70
  if (this.wss)
40
71
  return this.boundPort;
41
- const wss = new ws_1.WebSocketServer({ host: this.opts.host ?? protocol_1.BRIDGE_HOST, port: this.opts.port ?? 0 });
42
- wss.on('connection', (ws) => this.handleConnection(ws));
43
- await new Promise((resolve, reject) => {
44
- wss.once('listening', resolve);
45
- wss.once('error', reject);
72
+ const host = this.opts.host ?? protocol_1.BRIDGE_HOST;
73
+ const port = this.opts.port ?? 0;
74
+ // A fixed port can be briefly held by a just-replaced instance of ourselves
75
+ // (e.g. on a host "Reconnect"). Rather than crash with a cryptic EADDRINUSE,
76
+ // wait-and-retry for a few seconds so the old process can release it; only if
77
+ // it never frees up do we surface a plain-English, actionable error.
78
+ const deadline = Date.now() + PORT_WAIT_MS;
79
+ for (let attempt = 1;; attempt++) {
80
+ try {
81
+ const wss = await this.listenOnce(host, port);
82
+ const addr = wss.address();
83
+ this.boundPort = typeof addr === 'object' && addr ? addr.port : port;
84
+ this.wss = wss;
85
+ this.log(`bridge listening on ${host}:${this.boundPort}`);
86
+ return this.boundPort;
87
+ }
88
+ catch (err) {
89
+ const inUse = err?.code === 'EADDRINUSE';
90
+ if (!inUse || port === 0 || Date.now() >= deadline) {
91
+ if (inUse)
92
+ throw new Error(portBusyMessage(host, port));
93
+ throw err;
94
+ }
95
+ if (attempt === 1)
96
+ this.log(`port ${host}:${port} busy — waiting for the previous instance to release it…`);
97
+ await delay(PORT_RETRY_MS);
98
+ }
99
+ }
100
+ }
101
+ /** One bind attempt. Resolves with a listening server or rejects with the listen error. */
102
+ listenOnce(host, port) {
103
+ return new Promise((resolve, reject) => {
104
+ const wss = new ws_1.WebSocketServer({ host, port });
105
+ const onError = (err) => {
106
+ wss.off('listening', onListening);
107
+ // Close so the failed server doesn't linger and leak a handle on retry.
108
+ try {
109
+ wss.close();
110
+ }
111
+ catch {
112
+ /* ignore */
113
+ }
114
+ reject(err);
115
+ };
116
+ const onListening = () => {
117
+ wss.off('error', onError);
118
+ wss.on('connection', (ws) => this.handleConnection(ws));
119
+ resolve(wss);
120
+ };
121
+ wss.once('error', onError);
122
+ wss.once('listening', onListening);
46
123
  });
47
- const addr = wss.address();
48
- this.boundPort = typeof addr === 'object' && addr ? addr.port : (this.opts.port ?? 0);
49
- this.wss = wss;
50
- this.log(`bridge listening on ${this.opts.host ?? protocol_1.BRIDGE_HOST}:${this.boundPort}`);
51
- return this.boundPort;
52
124
  }
53
125
  async stop() {
54
- this.active?.close(1001, 'server stopping');
55
- this.active = null;
126
+ for (const conn of this.conns.values())
127
+ conn.close(1001, 'server stopping');
128
+ this.conns.clear();
56
129
  const wss = this.wss;
57
130
  this.wss = null;
58
131
  if (wss)
@@ -61,21 +134,48 @@ class BridgeServer {
61
134
  get port() {
62
135
  return this.boundPort;
63
136
  }
137
+ /** True when ANY browser is paired (used as the selector's cheap gate). */
64
138
  hasActiveExtension() {
65
- return this.active?.isOpen() ?? false;
139
+ for (const conn of this.conns.values())
140
+ if (conn.isOpen())
141
+ return true;
142
+ return false;
66
143
  }
67
- /** Send a command to the active extension, or reject if none is connected. */
144
+ /** True when the given profile has a live connection. */
145
+ hasConnection(profile) {
146
+ const conn = this.conns.get(routeKey(profile));
147
+ return !!conn && conn.isOpen();
148
+ }
149
+ /** Profiles with a live connection right now. */
150
+ connectedProfiles() {
151
+ const out = [];
152
+ for (const [profile, conn] of this.conns)
153
+ if (conn.isOpen())
154
+ out.push(profile);
155
+ return out;
156
+ }
157
+ /**
158
+ * Send a command to the connection for `opts.profile` (default "default").
159
+ * Rejects with an actionable message if that profile has no live browser.
160
+ */
68
161
  async sendCommand(method, params, opts) {
69
- if (!this.active || !this.active.isOpen()) {
70
- throw new types_1.ExecutorError('EXTENSION_DISCONNECTED', 'no extension is paired');
162
+ const profile = routeKey(opts?.profile);
163
+ const conn = this.conns.get(profile);
164
+ if (!conn || !conn.isOpen()) {
165
+ throw new types_1.ExecutorError('EXTENSION_DISCONNECTED', this.noPairMessage(profile));
71
166
  }
72
- return this.active.sendCommand(method, params, opts);
167
+ return conn.sendCommand(method, params, opts);
168
+ }
169
+ noPairMessage(profile) {
170
+ return (`No browser is paired for profile "${profile}". In that Chrome's chrome-mcp ` +
171
+ `extension Options, set Port ${this.boundPort}, paste the token, set Profile to ` +
172
+ `"${profile}", and Save — then it joins without disturbing your other profiles.`);
73
173
  }
74
174
  status() {
75
175
  return {
76
176
  extensionConnected: this.hasActiveExtension(),
77
177
  port: this.boundPort,
78
- sessionId: this.active?.sessionId ?? null,
178
+ connectedProfiles: this.connectedProfiles(),
79
179
  };
80
180
  }
81
181
  // -- internals ----------------------------------------------------------
@@ -119,11 +219,11 @@ class BridgeServer {
119
219
  this.reject(ws, 'bad_token');
120
220
  return;
121
221
  }
122
- // Authenticated. Hand the socket to an ExtensionConnection.
222
+ // Authenticated. Hand the socket to an ExtensionConnection under its profile.
123
223
  authed = true;
124
224
  clearTimeout(helloTimer);
125
225
  ws.off('message', onMessage);
126
- this.promote(ws, frame.ext ?? { id: 'unknown', version: '0', chrome: '0' });
226
+ this.promote(ws, frame.ext ?? { id: 'unknown', version: '0', chrome: '0' }, routeKey(frame.profile));
127
227
  };
128
228
  ws.on('message', onMessage);
129
229
  ws.on('error', () => {
@@ -140,12 +240,14 @@ class BridgeServer {
140
240
  /* ignore */
141
241
  }
142
242
  }
143
- promote(ws, ext) {
243
+ promote(ws, ext, profile) {
144
244
  const sessionId = (0, node_crypto_1.randomUUID)();
145
- if (this.active && this.active.isOpen()) {
146
- const prev = this.active;
245
+ // Supersede only the SAME profile's connection (a re-pair). Other profiles
246
+ // keep their live connections, so several browsers stay paired at once.
247
+ const prev = this.conns.get(profile);
248
+ if (prev && prev.isOpen()) {
147
249
  const differentId = prev.extId !== ext.id;
148
- this.log(`extension "${ext.id}" superseded active connection "${prev.extId}"` +
250
+ this.log(`extension "${ext.id}" superseded profile "${profile}" connection "${prev.extId}"` +
149
251
  (differentId ? ' (DIFFERENT id — possible hijack; surfaced to status)' : ''));
150
252
  try {
151
253
  this.opts.onDisplacement?.({ oldExtId: prev.extId, newExtId: ext.id, differentId });
@@ -164,11 +266,12 @@ class BridgeServer {
164
266
  onEvent: this.opts.onEvent,
165
267
  onLog: (m) => this.log(m),
166
268
  onClose: () => {
167
- if (this.active?.sessionId === sessionId)
168
- this.active = null;
269
+ // Only clear if a newer re-pair hasn't already replaced this slot.
270
+ if (this.conns.get(profile)?.sessionId === sessionId)
271
+ this.conns.delete(profile);
169
272
  },
170
273
  });
171
- this.active = conn;
274
+ this.conns.set(profile, conn);
172
275
  const welcome = {
173
276
  type: 'welcome',
174
277
  v: protocol_1.PROTOCOL_VERSION,
@@ -178,7 +281,7 @@ class BridgeServer {
178
281
  policy: this.opts.policy ?? policy_1.DENY_ALL_WIRE_POLICY,
179
282
  };
180
283
  this.send(ws, welcome);
181
- this.log(`extension paired (session ${sessionId}, id "${ext.id}")`);
284
+ this.log(`extension paired (profile "${profile}", session ${sessionId}, id "${ext.id}")`);
182
285
  }
183
286
  send(ws, frame) {
184
287
  try {
@@ -0,0 +1,44 @@
1
+ /**
2
+ * src/bridge/tasks.ts — listing and garbage-collection over the per-profile
3
+ * task workspaces written by `ensureWorkspace` (datadir.ts).
4
+ *
5
+ * A task is `profiles/<profile>/tasks/<task>/`, carrying a `meta.json` and a
6
+ * `downloads/` bucket. These helpers walk that tree read-only (listTasks) or
7
+ * prune it (gcTasks). `now` is threaded in rather than read from the clock so
8
+ * GC is deterministic under test.
9
+ */
10
+ export interface TaskInfo {
11
+ profile: string;
12
+ task: string;
13
+ /** Absolute path to `profiles/<profile>/tasks/<task>/`. */
14
+ dir: string;
15
+ /** ISO timestamp from meta.json, falling back to the dir's mtime. */
16
+ createdAt: string;
17
+ /** Total bytes under the task dir (downloads + meta.json; excludes the profile). */
18
+ bytes: number;
19
+ /** File count in `downloads/`. */
20
+ downloads: number;
21
+ }
22
+ /** Enumerate every task across every profile, newest first. */
23
+ export declare function listTasks(dataDir: string): TaskInfo[];
24
+ export interface GcOptions {
25
+ /** Remove tasks created more than this many days ago. */
26
+ olderThanDays?: number;
27
+ /** Always retain the newest N tasks (per scope), regardless of age. */
28
+ keep?: number;
29
+ /** Limit to a single profile; otherwise all profiles. */
30
+ profile?: string;
31
+ /** Compute what would be removed without deleting anything. */
32
+ dryRun?: boolean;
33
+ }
34
+ export interface GcResult {
35
+ removed: TaskInfo[];
36
+ freedBytes: number;
37
+ }
38
+ /**
39
+ * Prune task workspaces. A task is removed when it is NOT among the newest
40
+ * `keep` (if set) AND is older than `olderThanDays` (if set). At least one of
41
+ * `keep`/`olderThanDays` must be provided — the caller is responsible for
42
+ * refusing an unbounded sweep. `dryRun` reports the selection without deleting.
43
+ */
44
+ export declare function gcTasks(dataDir: string, opts: GcOptions, now: number): GcResult;
@@ -0,0 +1,131 @@
1
+ "use strict";
2
+ /**
3
+ * src/bridge/tasks.ts — listing and garbage-collection over the per-profile
4
+ * task workspaces written by `ensureWorkspace` (datadir.ts).
5
+ *
6
+ * A task is `profiles/<profile>/tasks/<task>/`, carrying a `meta.json` and a
7
+ * `downloads/` bucket. These helpers walk that tree read-only (listTasks) or
8
+ * prune it (gcTasks). `now` is threaded in rather than read from the clock so
9
+ * GC is deterministic under test.
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.listTasks = listTasks;
13
+ exports.gcTasks = gcTasks;
14
+ const node_fs_1 = require("node:fs");
15
+ const node_path_1 = require("node:path");
16
+ function subdirs(dir) {
17
+ try {
18
+ return (0, node_fs_1.readdirSync)(dir, { withFileTypes: true })
19
+ .filter((e) => e.isDirectory())
20
+ .map((e) => e.name);
21
+ }
22
+ catch {
23
+ return [];
24
+ }
25
+ }
26
+ function dirSize(dir) {
27
+ let total = 0;
28
+ let entries;
29
+ try {
30
+ entries = (0, node_fs_1.readdirSync)(dir, { withFileTypes: true });
31
+ }
32
+ catch {
33
+ return 0;
34
+ }
35
+ for (const e of entries) {
36
+ const p = (0, node_path_1.join)(dir, e.name);
37
+ if (e.isDirectory())
38
+ total += dirSize(p);
39
+ else {
40
+ try {
41
+ total += (0, node_fs_1.statSync)(p).size;
42
+ }
43
+ catch {
44
+ /* vanished mid-walk */
45
+ }
46
+ }
47
+ }
48
+ return total;
49
+ }
50
+ function countFiles(dir) {
51
+ try {
52
+ return (0, node_fs_1.readdirSync)(dir, { withFileTypes: true }).filter((e) => e.isFile()).length;
53
+ }
54
+ catch {
55
+ return 0;
56
+ }
57
+ }
58
+ function readCreatedAt(taskDir) {
59
+ try {
60
+ const meta = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(taskDir, 'meta.json'), 'utf8'));
61
+ if (typeof meta.createdAt === 'string')
62
+ return meta.createdAt;
63
+ }
64
+ catch {
65
+ /* fall through to mtime */
66
+ }
67
+ try {
68
+ return (0, node_fs_1.statSync)(taskDir).mtime.toISOString();
69
+ }
70
+ catch {
71
+ return '';
72
+ }
73
+ }
74
+ /** Enumerate every task across every profile, newest first. */
75
+ function listTasks(dataDir) {
76
+ const profilesRoot = (0, node_path_1.join)(dataDir, 'profiles');
77
+ if (!(0, node_fs_1.existsSync)(profilesRoot))
78
+ return [];
79
+ const out = [];
80
+ for (const profile of subdirs(profilesRoot)) {
81
+ const tasksRoot = (0, node_path_1.join)(profilesRoot, profile, 'tasks');
82
+ for (const task of subdirs(tasksRoot)) {
83
+ const dir = (0, node_path_1.join)(tasksRoot, task);
84
+ out.push({
85
+ profile,
86
+ task,
87
+ dir,
88
+ createdAt: readCreatedAt(dir),
89
+ bytes: dirSize(dir),
90
+ downloads: countFiles((0, node_path_1.join)(dir, 'downloads')),
91
+ });
92
+ }
93
+ }
94
+ return out.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
95
+ }
96
+ /**
97
+ * Prune task workspaces. A task is removed when it is NOT among the newest
98
+ * `keep` (if set) AND is older than `olderThanDays` (if set). At least one of
99
+ * `keep`/`olderThanDays` must be provided — the caller is responsible for
100
+ * refusing an unbounded sweep. `dryRun` reports the selection without deleting.
101
+ */
102
+ function gcTasks(dataDir, opts, now) {
103
+ if (opts.olderThanDays === undefined && opts.keep === undefined) {
104
+ throw new Error('gcTasks requires olderThanDays or keep (refusing to remove every task)');
105
+ }
106
+ const scoped = listTasks(dataDir).filter((t) => !opts.profile || t.profile === opts.profile);
107
+ const protectedDirs = new Set(opts.keep === undefined ? [] : scoped.slice(0, opts.keep).map((t) => t.dir));
108
+ const ageCutoffMs = (opts.olderThanDays ?? 0) * 86_400_000;
109
+ const removed = scoped.filter((t) => {
110
+ if (protectedDirs.has(t.dir))
111
+ return false;
112
+ if (opts.olderThanDays !== undefined) {
113
+ const created = Date.parse(t.createdAt);
114
+ if (Number.isNaN(created) || now - created <= ageCutoffMs)
115
+ return false;
116
+ }
117
+ return true;
118
+ });
119
+ if (!opts.dryRun) {
120
+ for (const t of removed) {
121
+ try {
122
+ (0, node_fs_1.rmSync)(t.dir, { recursive: true, force: true });
123
+ }
124
+ catch {
125
+ /* best effort */
126
+ }
127
+ }
128
+ }
129
+ return { removed, freedBytes: removed.reduce((sum, t) => sum + t.bytes, 0) };
130
+ }
131
+ //# sourceMappingURL=tasks.js.map