tabctl 0.1.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.
@@ -0,0 +1,23 @@
1
+ {
2
+ "manifest_version": 3,
3
+ "name": "Tab Control",
4
+ "version": "0.1.0",
5
+ "description": "Archive and manage browser tabs with CLI support",
6
+ "permissions": [
7
+ "tabs",
8
+ "tabGroups",
9
+ "scripting",
10
+ "storage",
11
+ "downloads",
12
+ "nativeMessaging",
13
+ "sessions",
14
+ "alarms"
15
+ ],
16
+ "host_permissions": [
17
+ "<all_urls>"
18
+ ],
19
+ "background": {
20
+ "service_worker": "background.js"
21
+ },
22
+ "version_name": "0.1.0-dev.0022fa28.dirty"
23
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "manifest_version": 3,
3
+ "name": "Tab Control",
4
+ "version": "0.1.0",
5
+ "description": "Archive and manage browser tabs with CLI support",
6
+ "permissions": [
7
+ "tabs",
8
+ "tabGroups",
9
+ "scripting",
10
+ "storage",
11
+ "downloads",
12
+ "nativeMessaging",
13
+ "sessions",
14
+ "alarms"
15
+ ],
16
+ "host_permissions": [
17
+ "<all_urls>"
18
+ ],
19
+ "background": {
20
+ "service_worker": "background.js"
21
+ }
22
+ }
package/host/host.js ADDED
@@ -0,0 +1,428 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ var __importDefault = (this && this.__importDefault) || function (mod) {
4
+ return (mod && mod.__esModule) ? mod : { "default": mod };
5
+ };
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ const fs_1 = __importDefault(require("fs"));
8
+ const net_1 = __importDefault(require("net"));
9
+ const crypto_1 = __importDefault(require("crypto"));
10
+ const version_1 = require("../shared/version");
11
+ const config_1 = require("../shared/config");
12
+ const undo_1 = require("./lib/undo");
13
+ let config;
14
+ try {
15
+ config = (0, config_1.resolveConfig)();
16
+ }
17
+ catch (err) {
18
+ process.stderr.write(`[tabctl-host] Fatal: ${err.message}\n`);
19
+ process.exit(1);
20
+ }
21
+ const SOCKET_DIR = config.dataDir;
22
+ const SOCKET_PATH = config.socketPath;
23
+ const UNDO_LOG = config.undoLog;
24
+ const REQUEST_TIMEOUT_MS = 30000;
25
+ const MAX_RESPONSE_BYTES = 20 * 1024 * 1024;
26
+ const HISTORY_LIMIT_DEFAULT = 20;
27
+ const RETENTION_DAYS = 30;
28
+ const pending = new Map();
29
+ const analyses = new Map();
30
+ const UNDO_ACTIONS = new Set([
31
+ "archive",
32
+ "close",
33
+ "group-update",
34
+ "group-ungroup",
35
+ "group-assign",
36
+ "move-tab",
37
+ "move-group",
38
+ "merge-window",
39
+ ]);
40
+ const LOCAL_ACTIONS = new Set(["history", "undo", "version"]);
41
+ function log(...args) {
42
+ process.stderr.write(`[tabctl-host] ${args.join(" ")}\n`);
43
+ }
44
+ function ensureDir() {
45
+ fs_1.default.mkdirSync(SOCKET_DIR, { recursive: true, mode: 0o700 });
46
+ }
47
+ function createId(prefix) {
48
+ return `${prefix}-${Date.now()}-${crypto_1.default.randomBytes(4).toString("hex")}`;
49
+ }
50
+ function sendNative(message) {
51
+ const json = JSON.stringify(message);
52
+ const length = Buffer.byteLength(json);
53
+ const buffer = Buffer.alloc(4 + length);
54
+ buffer.writeUInt32LE(length, 0);
55
+ buffer.write(json, 4);
56
+ process.stdout.write(buffer);
57
+ }
58
+ let nativeBuffer = Buffer.alloc(0);
59
+ process.stdin.on("data", (chunk) => {
60
+ nativeBuffer = Buffer.concat([nativeBuffer, chunk]);
61
+ while (nativeBuffer.length >= 4) {
62
+ const length = nativeBuffer.readUInt32LE(0);
63
+ if (nativeBuffer.length < 4 + length) {
64
+ return;
65
+ }
66
+ const payload = nativeBuffer.slice(4, 4 + length).toString("utf8");
67
+ nativeBuffer = nativeBuffer.slice(4 + length);
68
+ handleNativeMessage(payload);
69
+ }
70
+ });
71
+ process.stdin.on("end", () => {
72
+ log("Extension disconnected, exiting");
73
+ cleanupAndExit(0);
74
+ });
75
+ function handleNativeMessage(payload) {
76
+ let message;
77
+ try {
78
+ message = JSON.parse(payload);
79
+ }
80
+ catch (error) {
81
+ const err = error;
82
+ log("Failed to parse native message", err.message);
83
+ return;
84
+ }
85
+ const messageId = message.id;
86
+ if (!messageId) {
87
+ return;
88
+ }
89
+ const pendingRequest = pending.get(messageId);
90
+ if (!pendingRequest) {
91
+ return;
92
+ }
93
+ if (message.progress) {
94
+ refreshTimeout(pendingRequest, messageId);
95
+ respond(pendingRequest.socket, {
96
+ ok: true,
97
+ action: pendingRequest.action,
98
+ requestId: messageId,
99
+ progress: true,
100
+ component: "host",
101
+ version: version_1.VERSION,
102
+ data: message.data || {},
103
+ });
104
+ return;
105
+ }
106
+ const messageData = message.data || {};
107
+ const extensionVersion = typeof messageData.version === "string" ? messageData.version : null;
108
+ const extensionComponent = typeof messageData.component === "string" ? messageData.component : null;
109
+ if (extensionVersion && extensionVersion !== version_1.VERSION) {
110
+ log(`Version mismatch: host ${version_1.VERSION}, extension ${extensionVersion}`);
111
+ }
112
+ clearTimeout(pendingRequest.timeout);
113
+ pending.delete(messageId);
114
+ if (!message.ok) {
115
+ respond(pendingRequest.socket, {
116
+ ok: false,
117
+ action: pendingRequest.action,
118
+ requestId: messageId,
119
+ component: "host",
120
+ version: version_1.VERSION,
121
+ error: message.error || { message: "Unknown error" },
122
+ });
123
+ return;
124
+ }
125
+ if (pendingRequest.action === "analyze") {
126
+ const analysisId = createId("analysis");
127
+ analyses.set(analysisId, {
128
+ createdAt: Date.now(),
129
+ data: messageData,
130
+ });
131
+ respond(pendingRequest.socket, {
132
+ ok: true,
133
+ action: "analyze",
134
+ requestId: messageId,
135
+ component: "host",
136
+ version: version_1.VERSION,
137
+ data: {
138
+ ...messageData,
139
+ extensionVersion,
140
+ extensionComponent,
141
+ hostBaseVersion: version_1.BASE_VERSION,
142
+ hostGitSha: version_1.GIT_SHA,
143
+ hostDirty: version_1.DIRTY,
144
+ analysisId,
145
+ },
146
+ });
147
+ return;
148
+ }
149
+ if (UNDO_ACTIONS.has(pendingRequest.action)) {
150
+ const record = {
151
+ txid: pendingRequest.txid,
152
+ createdAt: Date.now(),
153
+ action: pendingRequest.action,
154
+ summary: messageData.summary || {},
155
+ undo: messageData.undo || null,
156
+ };
157
+ if (record.undo) {
158
+ (0, undo_1.appendUndoRecord)(UNDO_LOG, record);
159
+ }
160
+ respond(pendingRequest.socket, {
161
+ ok: true,
162
+ action: pendingRequest.action,
163
+ requestId: messageId,
164
+ component: "host",
165
+ version: version_1.VERSION,
166
+ data: {
167
+ ...messageData,
168
+ extensionVersion,
169
+ extensionComponent,
170
+ hostBaseVersion: version_1.BASE_VERSION,
171
+ hostGitSha: version_1.GIT_SHA,
172
+ hostDirty: version_1.DIRTY,
173
+ txid: pendingRequest.txid,
174
+ },
175
+ });
176
+ return;
177
+ }
178
+ respond(pendingRequest.socket, {
179
+ ok: true,
180
+ action: pendingRequest.action,
181
+ requestId: messageId,
182
+ component: "host",
183
+ version: version_1.VERSION,
184
+ data: {
185
+ ...messageData,
186
+ extensionVersion,
187
+ extensionComponent,
188
+ hostBaseVersion: version_1.BASE_VERSION,
189
+ hostGitSha: version_1.GIT_SHA,
190
+ hostDirty: version_1.DIRTY,
191
+ },
192
+ });
193
+ }
194
+ function respond(socket, payload) {
195
+ const serialized = JSON.stringify(payload);
196
+ if (Buffer.byteLength(serialized, "utf8") > MAX_RESPONSE_BYTES) {
197
+ socket.write(`${JSON.stringify({
198
+ ok: false,
199
+ action: payload.action,
200
+ requestId: payload.requestId,
201
+ component: "host",
202
+ version: version_1.VERSION,
203
+ error: { message: "Response too large", hint: "Reduce scope or use --out to write files." },
204
+ })}\n`);
205
+ return;
206
+ }
207
+ socket.write(`${serialized}\n`);
208
+ }
209
+ function refreshTimeout(pendingRequest, requestId) {
210
+ clearTimeout(pendingRequest.timeout);
211
+ pendingRequest.timeout = setTimeout(() => {
212
+ pending.delete(requestId);
213
+ respond(pendingRequest.socket, {
214
+ ok: false,
215
+ action: pendingRequest.action,
216
+ requestId,
217
+ component: "host",
218
+ version: version_1.VERSION,
219
+ error: { message: "Request timed out" },
220
+ });
221
+ }, REQUEST_TIMEOUT_MS);
222
+ }
223
+ function forwardToExtension(socket, request, overrides = {}) {
224
+ const requestId = request.id || createId("req");
225
+ const txid = overrides.txid || null;
226
+ const params = { ...(request.params || {}) };
227
+ if (txid) {
228
+ params.txid = txid;
229
+ }
230
+ if (!LOCAL_ACTIONS.has(request.action)) {
231
+ params.client = {
232
+ component: "host",
233
+ version: version_1.VERSION,
234
+ };
235
+ }
236
+ pending.set(requestId, {
237
+ socket,
238
+ action: request.action,
239
+ txid,
240
+ timeout: setTimeout(() => {
241
+ pending.delete(requestId);
242
+ respond(socket, {
243
+ ok: false,
244
+ action: request.action,
245
+ requestId,
246
+ component: "host",
247
+ version: version_1.VERSION,
248
+ error: { message: "Request timed out" },
249
+ });
250
+ }, REQUEST_TIMEOUT_MS),
251
+ });
252
+ sendNative({ id: requestId, action: request.action, params });
253
+ }
254
+ function handleCliRequest(socket, request) {
255
+ if (!request || typeof request !== "object") {
256
+ respond(socket, { ok: false, error: { message: "Invalid request" } });
257
+ return;
258
+ }
259
+ const action = request.action;
260
+ if (!action) {
261
+ respond(socket, { ok: false, error: { message: "Missing action" } });
262
+ return;
263
+ }
264
+ if (action === "history") {
265
+ const limit = Number.isFinite(request.params?.limit)
266
+ ? Number(request.params?.limit)
267
+ : HISTORY_LIMIT_DEFAULT;
268
+ const records = (0, undo_1.readUndoRecords)(UNDO_LOG);
269
+ const filtered = (0, undo_1.filterByRetention)(records, RETENTION_DAYS);
270
+ respond(socket, {
271
+ ok: true,
272
+ action,
273
+ requestId: request.id || null,
274
+ component: "host",
275
+ version: version_1.VERSION,
276
+ data: filtered.slice(-limit),
277
+ });
278
+ return;
279
+ }
280
+ if (action === "version") {
281
+ respond(socket, {
282
+ ok: true,
283
+ action,
284
+ requestId: request.id || null,
285
+ component: "host",
286
+ version: version_1.VERSION,
287
+ data: {
288
+ version: version_1.VERSION,
289
+ baseVersion: version_1.BASE_VERSION,
290
+ gitSha: version_1.GIT_SHA,
291
+ dirty: version_1.DIRTY,
292
+ component: "host",
293
+ },
294
+ });
295
+ return;
296
+ }
297
+ if (action === "undo") {
298
+ const txid = request.params?.txid;
299
+ const latest = request.params?.latest === true;
300
+ if (!txid && !latest) {
301
+ respond(socket, {
302
+ ok: false,
303
+ action,
304
+ component: "host",
305
+ version: version_1.VERSION,
306
+ error: {
307
+ message: "Missing txid",
308
+ hint: "Use tabctl history --json to find a txid, or run tabctl undo --latest",
309
+ },
310
+ });
311
+ return;
312
+ }
313
+ const record = txid
314
+ ? (0, undo_1.findUndoRecord)(UNDO_LOG, txid, RETENTION_DAYS)
315
+ : (0, undo_1.findLatestUndoRecord)(UNDO_LOG, RETENTION_DAYS);
316
+ if (!record) {
317
+ respond(socket, { ok: false, action, component: "host", version: version_1.VERSION, error: { message: "Undo record not found" } });
318
+ return;
319
+ }
320
+ forwardToExtension(socket, {
321
+ id: request.id,
322
+ action: "undo",
323
+ params: { record },
324
+ });
325
+ return;
326
+ }
327
+ if (action === "close" && request.params?.mode === "apply") {
328
+ const analysisId = request.params.analysisId;
329
+ const analysis = analysisId ? analyses.get(analysisId) : undefined;
330
+ if (!analysis) {
331
+ respond(socket, { ok: false, action, component: "host", version: version_1.VERSION, error: { message: "Unknown analysisId" } });
332
+ return;
333
+ }
334
+ const candidates = analysis.data.candidates || [];
335
+ const tabIds = candidates.map((candidate) => candidate.tabId).filter(Boolean);
336
+ const expectedUrls = {};
337
+ for (const candidate of candidates) {
338
+ if (candidate.tabId) {
339
+ expectedUrls[String(candidate.tabId)] = candidate.url;
340
+ }
341
+ }
342
+ if (!tabIds.length) {
343
+ respond(socket, {
344
+ ok: true,
345
+ action,
346
+ requestId: request.id || null,
347
+ component: "host",
348
+ version: version_1.VERSION,
349
+ data: {
350
+ txid: null,
351
+ summary: { closedTabs: 0, skippedTabs: 0 },
352
+ skipped: [],
353
+ },
354
+ });
355
+ return;
356
+ }
357
+ const txid = createId("tx");
358
+ forwardToExtension(socket, {
359
+ id: request.id,
360
+ action: "close",
361
+ params: {
362
+ mode: "apply",
363
+ tabIds,
364
+ expectedUrls,
365
+ },
366
+ }, { txid });
367
+ return;
368
+ }
369
+ if (UNDO_ACTIONS.has(action)) {
370
+ const txid = createId("tx");
371
+ forwardToExtension(socket, request, { txid });
372
+ return;
373
+ }
374
+ forwardToExtension(socket, request);
375
+ }
376
+ function startSocketServer() {
377
+ ensureDir();
378
+ if (fs_1.default.existsSync(SOCKET_PATH)) {
379
+ fs_1.default.unlinkSync(SOCKET_PATH);
380
+ }
381
+ const server = net_1.default.createServer((socket) => {
382
+ socket.setEncoding("utf8");
383
+ let buffer = "";
384
+ socket.on("data", (data) => {
385
+ buffer += data;
386
+ let index;
387
+ while ((index = buffer.indexOf("\n")) >= 0) {
388
+ const line = buffer.slice(0, index).trim();
389
+ buffer = buffer.slice(index + 1);
390
+ if (!line) {
391
+ continue;
392
+ }
393
+ let request;
394
+ try {
395
+ request = JSON.parse(line);
396
+ }
397
+ catch {
398
+ respond(socket, { ok: false, error: { message: "Invalid JSON" } });
399
+ continue;
400
+ }
401
+ handleCliRequest(socket, request);
402
+ }
403
+ });
404
+ socket.on("error", (error) => {
405
+ log("CLI socket error", error.message);
406
+ });
407
+ });
408
+ server.listen(SOCKET_PATH, () => {
409
+ fs_1.default.chmodSync(SOCKET_PATH, 0o600);
410
+ log(`Listening on ${SOCKET_PATH}`);
411
+ });
412
+ return server;
413
+ }
414
+ function cleanupAndExit(code) {
415
+ try {
416
+ if (fs_1.default.existsSync(SOCKET_PATH)) {
417
+ fs_1.default.unlinkSync(SOCKET_PATH);
418
+ }
419
+ }
420
+ catch {
421
+ // ignore
422
+ }
423
+ process.exit(code);
424
+ }
425
+ const server = startSocketServer();
426
+ process.on("SIGINT", () => cleanupAndExit(0));
427
+ process.on("SIGTERM", () => cleanupAndExit(0));
428
+ server.on("close", () => cleanupAndExit(0));
package/host/host.sh ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
5
+ exec node "$ROOT/host.js"
@@ -0,0 +1,60 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.appendUndoRecord = appendUndoRecord;
7
+ exports.readUndoRecords = readUndoRecords;
8
+ exports.filterByRetention = filterByRetention;
9
+ exports.findUndoRecord = findUndoRecord;
10
+ exports.findLatestUndoRecord = findLatestUndoRecord;
11
+ const fs_1 = __importDefault(require("fs"));
12
+ const path_1 = __importDefault(require("path"));
13
+ const DEFAULT_RETENTION_DAYS = 30;
14
+ function appendUndoRecord(filePath, record) {
15
+ const dir = path_1.default.dirname(filePath);
16
+ fs_1.default.mkdirSync(dir, { recursive: true, mode: 0o700 });
17
+ fs_1.default.appendFileSync(filePath, `${JSON.stringify(record)}\n`, "utf8");
18
+ }
19
+ function readUndoRecords(filePath) {
20
+ try {
21
+ const content = fs_1.default.readFileSync(filePath, "utf8");
22
+ const lines = content.split("\n").filter(Boolean);
23
+ const records = [];
24
+ for (const line of lines) {
25
+ try {
26
+ records.push(JSON.parse(line));
27
+ }
28
+ catch {
29
+ // ignore malformed lines
30
+ }
31
+ }
32
+ return records;
33
+ }
34
+ catch {
35
+ return [];
36
+ }
37
+ }
38
+ function filterByRetention(records, retentionDays = DEFAULT_RETENTION_DAYS, now = Date.now()) {
39
+ const cutoff = now - retentionDays * 24 * 60 * 60 * 1000;
40
+ return records.filter((record) => {
41
+ const createdAt = record.createdAt;
42
+ return !createdAt || createdAt >= cutoff;
43
+ });
44
+ }
45
+ function findUndoRecord(filePath, txid, retentionDays = DEFAULT_RETENTION_DAYS, now = Date.now()) {
46
+ const records = filterByRetention(readUndoRecords(filePath), retentionDays, now);
47
+ for (let i = records.length - 1; i >= 0; i -= 1) {
48
+ if (records[i].txid === txid) {
49
+ return records[i];
50
+ }
51
+ }
52
+ return null;
53
+ }
54
+ function findLatestUndoRecord(filePath, retentionDays = DEFAULT_RETENTION_DAYS, now = Date.now()) {
55
+ const records = filterByRetention(readUndoRecords(filePath), retentionDays, now);
56
+ if (!records.length) {
57
+ return null;
58
+ }
59
+ return records[records.length - 1] || null;
60
+ }
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "tabctl",
3
+ "version": "0.1.0",
4
+ "description": "CLI tool to manage and analyze browser tabs",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/ekroon/tabctl"
9
+ },
10
+ "keywords": [
11
+ "browser",
12
+ "tabs",
13
+ "cli",
14
+ "edge",
15
+ "chrome"
16
+ ],
17
+ "engines": {
18
+ "node": ">=20"
19
+ },
20
+ "bin": {
21
+ "tabctl": "cli/tabctl.js"
22
+ },
23
+ "files": [
24
+ "cli",
25
+ "host",
26
+ "shared",
27
+ "extension"
28
+ ],
29
+ "scripts": {
30
+ "build": "node scripts/gen-version.js && tsc -p tsconfig.json && node scripts/sync-build.js",
31
+ "bump:major": "node scripts/bump-version.js major",
32
+ "bump:minor": "node scripts/bump-version.js minor",
33
+ "bump:patch": "node scripts/bump-version.js patch",
34
+ "test": "npm run build && node --test tests/unit/*.js",
35
+ "test:unit": "npm run build && node --test tests/unit/*.js",
36
+ "test:integration": "node build/scripts/integration-test.js"
37
+ },
38
+ "devDependencies": {
39
+ "@types/chrome": "^0.0.277",
40
+ "@types/node": "^24",
41
+ "typescript": "^5.4.5"
42
+ }
43
+ }
@@ -0,0 +1,111 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.resetConfig = resetConfig;
7
+ exports.expandEnvVars = expandEnvVars;
8
+ exports.resolveConfig = resolveConfig;
9
+ const os_1 = __importDefault(require("os"));
10
+ const path_1 = __importDefault(require("path"));
11
+ const fs_1 = __importDefault(require("fs"));
12
+ let cached;
13
+ function resetConfig() {
14
+ cached = undefined;
15
+ }
16
+ function expandEnvVars(value) {
17
+ return value.replace(/\$\{(\w+)\}|\$(\w+)/g, (match, braced, bare) => {
18
+ const varName = braced || bare;
19
+ return process.env[varName] ?? match;
20
+ });
21
+ }
22
+ function resolveConfig(profileName) {
23
+ // Use cache only for no-arg calls (legacy mode)
24
+ if (!profileName && cached)
25
+ return cached;
26
+ // Config dir resolution
27
+ const configDir = process.env.TABCTL_CONFIG_DIR
28
+ || path_1.default.join(process.env.XDG_CONFIG_HOME || path_1.default.join(os_1.default.homedir(), ".config"), "tabctl");
29
+ // Read optional config.json
30
+ let fileConfig = {};
31
+ try {
32
+ const raw = fs_1.default.readFileSync(path_1.default.join(configDir, "config.json"), "utf-8");
33
+ fileConfig = JSON.parse(raw);
34
+ }
35
+ catch {
36
+ // missing or malformed — treat as empty
37
+ }
38
+ // Data dir resolution
39
+ let dataDir;
40
+ if (typeof fileConfig.dataDir === "string" && fileConfig.dataDir) {
41
+ dataDir = expandEnvVars(fileConfig.dataDir);
42
+ if (!path_1.default.isAbsolute(dataDir)) {
43
+ throw new Error(`dataDir in config.json must be an absolute path (got: ${dataDir}). Use $HOME or full paths.`);
44
+ }
45
+ }
46
+ else if (process.env.TABCTL_CONFIG_DIR) {
47
+ dataDir = path_1.default.join(configDir, "data");
48
+ }
49
+ else {
50
+ dataDir = path_1.default.join(process.env.XDG_STATE_HOME || path_1.default.join(os_1.default.homedir(), ".local", "state"), "tabctl");
51
+ }
52
+ const baseDataDir = dataDir;
53
+ // Profile resolution (read profiles.json inline to avoid circular import)
54
+ const explicitProfile = profileName;
55
+ const effectiveProfile = profileName || process.env.TABCTL_PROFILE;
56
+ let activeProfileName;
57
+ if (effectiveProfile) {
58
+ try {
59
+ const raw = fs_1.default.readFileSync(path_1.default.join(configDir, "profiles.json"), "utf-8");
60
+ const registry = JSON.parse(raw);
61
+ const profile = registry.profiles[effectiveProfile];
62
+ if (profile) {
63
+ dataDir = profile.dataDir;
64
+ activeProfileName = effectiveProfile;
65
+ }
66
+ else if (explicitProfile) {
67
+ throw new Error(`Profile "${explicitProfile}" not found in profiles.json`);
68
+ }
69
+ }
70
+ catch (err) {
71
+ // Re-throw profile-not-found errors
72
+ if (err instanceof Error && err.message.includes("not found in profiles.json")) {
73
+ throw err;
74
+ }
75
+ // If profiles.json is missing/malformed and profile was explicitly requested, error
76
+ if (explicitProfile) {
77
+ throw new Error(`Profile "${explicitProfile}" not found: no profiles.json`);
78
+ }
79
+ // Otherwise (env var only), silently fall through to legacy mode
80
+ }
81
+ }
82
+ else {
83
+ // No explicit profile — check for a default in profiles.json
84
+ try {
85
+ const raw = fs_1.default.readFileSync(path_1.default.join(configDir, "profiles.json"), "utf-8");
86
+ const registry = JSON.parse(raw);
87
+ if (registry.default && registry.profiles[registry.default]) {
88
+ dataDir = registry.profiles[registry.default].dataDir;
89
+ activeProfileName = registry.default;
90
+ }
91
+ }
92
+ catch {
93
+ // No profiles.json — legacy mode
94
+ }
95
+ }
96
+ const result = {
97
+ configDir,
98
+ dataDir,
99
+ baseDataDir,
100
+ socketPath: process.env.TABCTL_SOCKET || path_1.default.join(dataDir, "tabctl.sock"),
101
+ undoLog: path_1.default.join(dataDir, "undo.jsonl"),
102
+ wrapperDir: dataDir,
103
+ policyPath: path_1.default.join(configDir, "policy.json"),
104
+ activeProfileName,
105
+ };
106
+ // Only cache no-arg calls
107
+ if (!profileName) {
108
+ cached = result;
109
+ }
110
+ return result;
111
+ }