tabctl 0.5.2 → 0.6.0-alpha.1

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.
Files changed (42) hide show
  1. package/README.md +138 -20
  2. package/dist/extension/background.js +2 -0
  3. package/dist/extension/manifest.json +2 -2
  4. package/package.json +13 -5
  5. package/dist/cli/lib/args.js +0 -141
  6. package/dist/cli/lib/client.js +0 -83
  7. package/dist/cli/lib/commands/doctor.js +0 -134
  8. package/dist/cli/lib/commands/index.js +0 -51
  9. package/dist/cli/lib/commands/list.js +0 -159
  10. package/dist/cli/lib/commands/meta.js +0 -229
  11. package/dist/cli/lib/commands/params-groups.js +0 -48
  12. package/dist/cli/lib/commands/params-move.js +0 -44
  13. package/dist/cli/lib/commands/params.js +0 -314
  14. package/dist/cli/lib/commands/profile.js +0 -91
  15. package/dist/cli/lib/commands/setup.js +0 -283
  16. package/dist/cli/lib/constants.js +0 -30
  17. package/dist/cli/lib/help.js +0 -205
  18. package/dist/cli/lib/options-commands.js +0 -274
  19. package/dist/cli/lib/options-groups.js +0 -41
  20. package/dist/cli/lib/options.js +0 -125
  21. package/dist/cli/lib/output.js +0 -147
  22. package/dist/cli/lib/pagination.js +0 -55
  23. package/dist/cli/lib/policy-filter.js +0 -202
  24. package/dist/cli/lib/policy.js +0 -91
  25. package/dist/cli/lib/report.js +0 -61
  26. package/dist/cli/lib/response.js +0 -235
  27. package/dist/cli/lib/scope.js +0 -250
  28. package/dist/cli/lib/snapshot.js +0 -216
  29. package/dist/cli/lib/types.js +0 -2
  30. package/dist/cli/tabctl.js +0 -475
  31. package/dist/host/host.bundle.js +0 -670
  32. package/dist/host/host.js +0 -143
  33. package/dist/host/host.sh +0 -5
  34. package/dist/host/launcher/go.mod +0 -3
  35. package/dist/host/launcher/main.go +0 -109
  36. package/dist/host/lib/handlers.js +0 -327
  37. package/dist/host/lib/undo.js +0 -60
  38. package/dist/shared/config.js +0 -134
  39. package/dist/shared/extension-sync.js +0 -170
  40. package/dist/shared/profiles.js +0 -78
  41. package/dist/shared/version.js +0 -8
  42. package/dist/shared/wrapper-health.js +0 -132
package/README.md CHANGED
@@ -1,28 +1,89 @@
1
1
  # tabctl
2
2
 
3
- `tabctl` is a command-line tool that gives you terminal control over your browser tabs. List, search, group, move, close, deduplicate, inspect, and report on tabs without leaving your terminal — across Chrome and Edge.
3
+ Every open tab is a thread you forgot to pull. Tabctl finds them all.
4
4
 
5
- It works through a lightweight local stack: the CLI talks to a native messaging host, which proxies requests to a browser extension. A policy file can protect pinned tabs or specific groups from automated actions, and every mutation is undoable.
5
+ A command-line instrument for browser tab orchestration list, search, group, archive, close, undo wired into Edge or Chrome through a native messaging bridge. Built for humans who hoard tabs and the AI agents who clean up after them.
6
6
 
7
- This repo contains:
8
- - Chrome/Edge extension (tab/group inspection + actions)
9
- - Native messaging host (Node)
10
- - CLI (`tabctl`) for on-demand workflows
7
+ ## Install
8
+
9
+ ```bash
10
+ mise use -g github:ekroon/tabctl # install the tabctl binary
11
+ tabctl setup --browser edge # or: --browser chrome
12
+ # Load the extension: edge://extensions → Developer mode → Load unpacked → paste: ~/.local/state/tabctl/extension/
13
+ tabctl ping
14
+ ```
15
+
16
+ If it pings back, the wire is live. You're connected.
17
+
18
+ ### Alternative: build from source
19
+
20
+ ```bash
21
+ cargo install --path rust/crates/tabctl
22
+ ```
23
+
24
+ > **Legacy:** `npm install -g tabctl` still works for the Node.js-based distribution but is no longer the primary install method. No Node.js or Go is required at runtime — the single `tabctl` binary handles everything.
25
+
26
+ ## Agent Skill
27
+
28
+ Give your coding agent eyes into the browser. One command and it learns the protocol.
29
+
30
+ ```bash
31
+ tabctl skill
32
+ # or: npx skills add https://github.com/ekroon/tabctl --skill tabctl -a opencode -a github-copilot -a claude-code
33
+ ```
34
+
35
+ ## Safety
36
+
37
+ Nothing leaves your machine. No cloud. No telemetry. Just a socket between your terminal and your browser, quiet as rain on neon.
38
+
39
+ Every mutation is undoable — `tabctl undo` rewinds closes, archives, and group changes like they never happened. A configurable policy layer shields pinned tabs and protected domains from accidental destruction. You pull the trigger; tabctl keeps the safety on until you mean it.
40
+
41
+ ## What You Can Say
42
+
43
+ When tabctl is installed as a skill, your agent sees what you see. Just talk to it.
44
+
45
+ > *"Which of my tabs can I close?"*
46
+ > The agent scans for duplicates, stale pages, and tabs you haven't touched in days — then offers to clean house.
47
+
48
+ > *"Are any of my open tabs relevant to my note on Project Helios?"*
49
+ > When connected to Obsidian, your agent cross-references every open tab against your notes and surfaces the ones that matter.
50
+
51
+ > *"I just finished researching service mesh architectures. Organize what I found."*
52
+ > Groups your tabs by theme, extracts key URLs, and drops a summary into your notes — before you forget what you were looking at.
53
+
54
+ > *"Where's that AWS pricing page I had open somewhere?"*
55
+ > The agent searches your open tabs and groups by title and URL — and brings it back into focus.
56
+
57
+ > *"Pull every error message from my open Sentry tabs into a markdown table."*
58
+ > The agent reads each tab, extracts what you need, and formats it — no copy-paste, no context switching.
59
+
60
+ > *"Group everything by project. You know which ones."*
61
+ > Your agent infers context from URLs, titles, and your workspace — then sorts ninety tabs into five groups with names that actually make sense.
62
+
63
+ ---
11
64
 
12
- The host only runs while the browser is open and the extension is connected.
65
+ `tabctl` is a single Rust binary that serves as both the CLI and the native messaging host. The CLI sends commands over a Unix socket (or named pipe on Windows) to the host, which proxies them to the browser extension via native messaging. The `tabctl host` subcommand is the native messaging entry point — invoked automatically by the browser, not manually.
66
+
67
+ This repo contains:
68
+ - Chrome/Edge extension (`src/extension/`, the only TypeScript component)
69
+ - Rust workspace (`rust/crates/*`) — single `tabctl` binary for CLI + host + shared runtime
70
+ - Node packaging/build scripts for distribution (legacy)
13
71
 
14
72
  ## Quick Start
15
73
 
16
74
  ### 1. Build and install
17
75
 
76
+ ```bash
77
+ cargo install --path rust/crates/tabctl # puts tabctl on your PATH
78
+ ```
79
+
80
+ For development with the full build pipeline (extension + Rust):
81
+
18
82
  ```bash
19
83
  npm install
20
84
  npm run build
21
- npm link # puts tabctl on your PATH
22
85
  ```
23
86
 
24
- If you haven't run `npm link`, you can always use `node ./cli/tabctl.js` instead of `tabctl`.
25
-
26
87
  ### 2. Set up your browser
27
88
 
28
89
  Run setup — it syncs the extension, tells you where to load it, and auto-derives the extension ID:
@@ -37,8 +98,16 @@ This will:
37
98
  2. Print the path (and copy it to your clipboard)
38
99
  3. Ask you to load it as an unpacked extension in `chrome://extensions`
39
100
  4. Auto-derive the extension ID from the installed path
101
+ 5. Download the version-pinned release extension asset (`tabctl-extension.zip` + `.sha256`) into the tabctl data directory for offline/manual recovery
102
+
103
+ Optional setup release overrides:
104
+ - Flags: `--release-repo`, `--release-tag` (or `--release-version`), `--release-asset`, `--skip-extension-download`
105
+ - Env vars: `TABCTL_RELEASE_REPO`, `TABCTL_RELEASE_TAG`, `TABCTL_RELEASE_ASSET`, `TABCTL_SETUP_FETCH_EXTENSION=0`
106
+ - Precedence: flags override env vars, then built-in defaults; if download fails, setup continues and includes warning details in setup output.
40
107
 
41
108
  > **Edge?** Use `--browser edge` and load from `edge://extensions` instead.
109
+ >
110
+ > **Windows:** setup verifies connectivity after writing setup artifacts and checks the runtime extension ID reported by the browser. Connectivity failures and runtime extension ID mismatches exit non-zero and print manual recovery steps (including expected vs runtime IDs).
42
111
 
43
112
  If you need to override the auto-derived ID (e.g. for a custom extension path):
44
113
 
@@ -159,6 +228,28 @@ See [CLI.md](CLI.md#configuration) for full details.
159
228
  - Socket: `<dataDir>/tabctl.sock` (default: `~/.local/state/tabctl/tabctl.sock`)
160
229
  - Undo log: `<dataDir>/undo.jsonl` (default: `~/.local/state/tabctl/undo.jsonl`)
161
230
  - Profile registry: `<configDir>/profiles.json`
231
+ - WSL TCP port file: `<dataDir>/tcp-port` (written by the Windows host)
232
+
233
+ ## Windows + WSL transport
234
+
235
+ On Windows, the host exposes a dual endpoint model:
236
+ - Windows native clients use a named pipe endpoint (`\\.\pipe\tabctl-<hash>`).
237
+ - WSL/Linux clients use `tcp://127.0.0.1:<port>`, with the host writing `<dataDir>/tcp-port`.
238
+
239
+ WSL endpoint discovery (CLI):
240
+ 1. `TABCTL_SOCKET` (explicit endpoint); if this is a pipe endpoint in WSL, CLI still prefers discovered TCP.
241
+ 2. `TABCTL_TCP_PORT` (forces `127.0.0.1:<port>`).
242
+ 3. `tcp-port` file discovery from resolved data dir (and equivalent `/mnt/c/Users/*/.../tabctl/.../tcp-port` locations).
243
+ 4. Fallback: `tcp://127.0.0.1:38000`.
244
+
245
+ Relevant knobs: `TABCTL_SOCKET`, `TABCTL_TCP_PORT`, `TABCTL_PROFILE`, `TABCTL_DATA_DIR`, `TABCTL_STATE_DIR`, `TABCTL_CONFIG_DIR`.
246
+
247
+ ## Troubleshooting (setup/ping on Windows + WSL)
248
+
249
+ - `tabctl setup` fails with `Windows setup verification failed`: check `data.verification.reason` in JSON output (`ping-timeout`, `socket-not-found`, `socket-refused`, `ping-not-ok`, `extension-id-mismatch`), then follow printed manual steps.
250
+ - Runtime ID mismatch (`extension-id-mismatch`): compare expected vs runtime IDs from setup output, then rerun setup with the runtime ID shown by `edge://extensions` / `chrome://extensions`:
251
+ - `tabctl setup --browser <edge|chrome> --extension-id <runtime-id>`
252
+ - `tabctl ping` returns connect errors (`ENOENT`, `ECONNREFUSED`, timeout): ensure extension is loaded and active, rerun `tabctl setup`, and in WSL verify `TABCTL_TCP_PORT` or `<dataDir>/tcp-port` matches a listening localhost port.
162
253
 
163
254
  ## Multi-Browser Setup
164
255
 
@@ -211,21 +302,29 @@ Policy is shared across all profiles.
211
302
 
212
303
  ## Development
213
304
 
214
- ### TypeScript workflow
215
- Source lives in `src/` and compiles to `build/`, then syncs to the runtime locations:
216
- - `src/extension/background.ts` -> `extension/background.js`
217
- - `src/host/host.ts` -> `host/host.js`
218
- - `src/cli/tabctl.ts` -> `cli/tabctl.js`
219
- - `src/tests/unit/*.ts` -> `tests/unit/*.js`
305
+ ### Build workflow
306
+ The single `tabctl` binary is built from the Rust workspace (`rust/`). TypeScript is limited to the browser extension boundary (`src/extension/`). No Node.js or Go is required at runtime.
220
307
 
221
- Build and test:
308
+ Build and verify:
222
309
 
223
310
  ```bash
224
- npm install
225
- npm run build
226
- npm test
311
+ cargo build --release -p tabctl # build the binary
312
+ npm install && npm run build # full pipeline (extension + Rust)
313
+ npm test # unit tests
314
+ ```
315
+
316
+ Rust-only validation:
317
+ ```bash
318
+ npm run rust:verify
319
+ ```
320
+
321
+ Integration script (currently Rust-suite parity in CI/local):
322
+ ```bash
323
+ npm run test:integration
227
324
  ```
228
325
 
326
+ WSL CI validates the WSL->Windows invocation bridge (`test.yml` `wsl` job) with phases: `prerequisites`, `diagnostics`, `build_and_unit`, `setup_validation`, `windows_invocation`, `integration`. Runtime/build execution is delegated to Windows commands (`cmd.exe`/`powershell.exe`), so WSL-local Rust compilation is not required.
327
+
229
328
  ### Versioning
230
329
  The base version lives in `package.json` and is embedded into the CLI, host, and extension at build time.
231
330
 
@@ -234,6 +333,25 @@ Commands:
234
333
  npm run bump:patch
235
334
  npm run bump:minor
236
335
  npm run bump:major
336
+ npm run bump:alpha
337
+ npm run bump:rc
338
+ npm run bump:stable
339
+ ```
340
+
341
+ Pre-release staging flow:
342
+ - `bump:alpha` creates/increments `x.y.z-alpha.N`
343
+ - `bump:rc` promotes alpha to `x.y.z-rc.1` (or increments RC)
344
+ - `bump:stable` drops the prerelease suffix for final stable publish
345
+
346
+ Release publishing (`.github/workflows/publish.yml`) enforces:
347
+ - Git tag must match `package.json` version (`v<version>`)
348
+ - prerelease tags publish to `alpha`/`rc`; stable publishes to `latest`
349
+ - `npm run build` and `npm test` must pass before publish
350
+ - release assets include `tabctl-extension.zip` plus `tabctl-extension.zip.sha256`
351
+
352
+ Fetch the extension asset from a release with:
353
+ ```bash
354
+ tabctl extension-fetch --version 0.5.3
237
355
  ```
238
356
 
239
357
  Local builds default to a dev version when a `.git` directory is present, appending the short SHA.
@@ -3408,6 +3408,7 @@
3408
3408
  case "ping":
3409
3409
  return {
3410
3410
  now: Date.now(),
3411
+ runtimeId: chrome.runtime.id,
3411
3412
  version: VERSION_INFO.version,
3412
3413
  baseVersion: VERSION_INFO.baseVersion,
3413
3414
  gitSha: VERSION_INFO.gitSha,
@@ -3416,6 +3417,7 @@
3416
3417
  };
3417
3418
  case "version":
3418
3419
  return {
3420
+ runtimeId: chrome.runtime.id,
3419
3421
  version: VERSION_INFO.version,
3420
3422
  baseVersion: VERSION_INFO.baseVersion,
3421
3423
  gitSha: VERSION_INFO.gitSha,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Tab Control",
4
- "version": "0.5.2",
4
+ "version": "0.6.0-alpha.1",
5
5
  "description": "Archive and manage browser tabs with CLI support",
6
6
  "permissions": [
7
7
  "tabs",
@@ -19,5 +19,5 @@
19
19
  "background": {
20
20
  "service_worker": "background.js"
21
21
  },
22
- "version_name": "0.5.2"
22
+ "version_name": "0.6.0-alpha.1"
23
23
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tabctl",
3
- "version": "0.5.2",
3
+ "version": "0.6.0-alpha.1",
4
4
  "description": "CLI tool to manage and analyze browser tabs",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -27,13 +27,21 @@
27
27
  "dist/extension"
28
28
  ],
29
29
  "scripts": {
30
- "build": "node scripts/gen-version.js && tsc -p tsconfig.json && node scripts/copy-artifacts.js && node scripts/build-launcher.js && node scripts/bundle-extension.js",
30
+ "build": "node scripts/gen-version.js && tsc -p tsconfig.json && node scripts/copy-artifacts.js && node scripts/bundle-extension.js && cargo build --manifest-path rust/Cargo.toml --workspace",
31
+ "build:launcher": "node scripts/build-launcher.js",
31
32
  "bump:major": "node scripts/bump-version.js major",
32
33
  "bump:minor": "node scripts/bump-version.js minor",
33
34
  "bump:patch": "node scripts/bump-version.js patch",
34
- "test": "npm run build && node --test --test-timeout=5000 dist/tests/unit/*.js && node dist/scripts/integration-test.js",
35
- "test:unit": "npm run build && node --test --test-timeout=5000 dist/tests/unit/*.js",
36
- "test:integration": "node dist/scripts/integration-test.js",
35
+ "bump:alpha": "node scripts/bump-version.js alpha",
36
+ "check:targets": "bash scripts/check-targets.sh",
37
+ "bump:rc": "node scripts/bump-version.js rc",
38
+ "bump:stable": "node scripts/bump-version.js stable",
39
+ "rust:check": "cargo check --manifest-path rust/Cargo.toml --workspace --all-targets",
40
+ "rust:test": "cargo test --manifest-path rust/Cargo.toml --workspace --all-targets",
41
+ "rust:verify": "cargo fmt --manifest-path rust/Cargo.toml --all -- --check && cargo clippy --manifest-path rust/Cargo.toml --workspace --all-targets -- -D warnings && npm run rust:test",
42
+ "test": "npm run build && npm run rust:verify",
43
+ "test:unit": "npm run rust:test",
44
+ "test:integration": "npm run rust:test",
37
45
  "clean": "node -e \"fs.rmSync('dist',{recursive:true,force:true})\" ",
38
46
  "prepare": "git rev-parse --git-dir >/dev/null 2>&1 && git config core.hooksPath .githooks || true"
39
47
  },
@@ -1,141 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.normalizeGroupColor = normalizeGroupColor;
4
- exports.normalizeSignals = normalizeSignals;
5
- exports.validateSignals = validateSignals;
6
- exports.parseArgs = parseArgs;
7
- const constants_1 = require("./constants");
8
- const options_1 = require("./options");
9
- const output_1 = require("./output");
10
- function normalizeGroupColor(value) {
11
- if (typeof value !== "string") {
12
- return undefined;
13
- }
14
- const trimmed = value.trim().toLowerCase();
15
- if (!trimmed) {
16
- return undefined;
17
- }
18
- if (!constants_1.GROUP_COLORS.has(trimmed)) {
19
- (0, output_1.errorOut)(`Invalid color: ${value}. Use one of: ${Array.from(constants_1.GROUP_COLORS).join(", ")}`);
20
- }
21
- return trimmed;
22
- }
23
- function normalizeSignals(value) {
24
- if (!Array.isArray(value)) {
25
- return [];
26
- }
27
- return value.map((signal) => String(signal).trim()).filter(Boolean);
28
- }
29
- function validateSignals(signals) {
30
- for (const signal of signals) {
31
- if (!constants_1.SUPPORTED_SIGNAL_SET.has(signal)) {
32
- (0, output_1.errorOut)(`Unknown signal: ${signal}. Use one of: ${constants_1.SUPPORTED_SIGNALS.join(", ")}`);
33
- }
34
- }
35
- }
36
- function normalizeCommand(value) {
37
- if (!value) {
38
- return value;
39
- }
40
- if (value === "groups" || value === "group") {
41
- return "group-list";
42
- }
43
- const meta = options_1.COMMANDS[value];
44
- if (!meta?.aliases || meta.aliases.length === 0) {
45
- return value;
46
- }
47
- return meta.aliases[0] ?? value;
48
- }
49
- function parseArgs(argv) {
50
- const args = [...argv];
51
- let command;
52
- const options = { _: [] };
53
- const warnings = [];
54
- const pendingFlags = [];
55
- const allowedFlags = (0, options_1.getAllowedFlags)();
56
- const booleanFlags = (0, options_1.getBooleanFlags)();
57
- while (args.length > 0) {
58
- const arg = args.shift();
59
- if (!arg.startsWith("--")) {
60
- if (!command) {
61
- command = normalizeCommand(arg);
62
- if (command) {
63
- const commandAllowedFlags = (0, options_1.getCommandAllowedFlags)(command);
64
- for (const pending of pendingFlags) {
65
- if (!commandAllowedFlags.has(pending)) {
66
- warnings.push(`--${pending} is not supported by ${command}`);
67
- }
68
- }
69
- }
70
- continue;
71
- }
72
- options._.push(arg);
73
- continue;
74
- }
75
- const key = arg.slice(2);
76
- if (!allowedFlags.has(key)) {
77
- if (key === "format") {
78
- (0, output_1.errorOut)("Unknown option: --format");
79
- }
80
- (0, output_1.errorOut)(`Unknown option: --${key}`);
81
- }
82
- if (command) {
83
- const commandAllowedFlags = (0, options_1.getCommandAllowedFlags)(command);
84
- if (!commandAllowedFlags.has(key)) {
85
- warnings.push(`--${key} is not supported by ${command}`);
86
- }
87
- }
88
- else {
89
- pendingFlags.push(key);
90
- }
91
- // Boolean flags (no value needed)
92
- if (booleanFlags.has(key)) {
93
- options[key] = true;
94
- continue;
95
- }
96
- // Value required
97
- const value = args.shift();
98
- if (value == null) {
99
- (0, output_1.errorOut)(`Missing value for --${key}`);
100
- }
101
- // Repeatable flags (accumulate into arrays)
102
- if (key === "signal") {
103
- if (!options.signal) {
104
- options.signal = [];
105
- }
106
- options.signal.push(value);
107
- continue;
108
- }
109
- if (key === "tab") {
110
- if (!options.tab) {
111
- options.tab = [];
112
- }
113
- options.tab.push(value);
114
- continue;
115
- }
116
- if (key === "agent") {
117
- if (!options.agent) {
118
- options.agent = [];
119
- }
120
- options.agent.push(value);
121
- continue;
122
- }
123
- if (key === "url") {
124
- if (!options.url) {
125
- options.url = [];
126
- }
127
- options.url.push(value);
128
- continue;
129
- }
130
- if (key === "selector") {
131
- if (!options.selector) {
132
- options.selector = [];
133
- }
134
- options.selector.push(value);
135
- continue;
136
- }
137
- // Single value flags
138
- options[key] = value;
139
- }
140
- return { command, options, warnings };
141
- }
@@ -1,83 +0,0 @@
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.createRequestId = createRequestId;
7
- exports.sendRequest = sendRequest;
8
- exports.fetchSnapshot = fetchSnapshot;
9
- exports.sendFireAndForget = sendFireAndForget;
10
- const node_net_1 = __importDefault(require("node:net"));
11
- const constants_1 = require("./constants");
12
- function createRequestId() {
13
- return `req-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`;
14
- }
15
- function sendRequest(payload, onProgress) {
16
- return new Promise((resolve, reject) => {
17
- const { socketPath } = (0, constants_1.resolveConfig)();
18
- const client = node_net_1.default.createConnection(socketPath);
19
- let buffer = "";
20
- client.on("connect", () => {
21
- client.write(`${JSON.stringify(payload)}\n`);
22
- });
23
- client.on("data", (data) => {
24
- buffer += data;
25
- let index;
26
- while ((index = buffer.indexOf("\n")) >= 0) {
27
- const line = buffer.slice(0, index).trim();
28
- buffer = buffer.slice(index + 1);
29
- if (!line) {
30
- continue;
31
- }
32
- let response;
33
- try {
34
- response = JSON.parse(line);
35
- }
36
- catch (error) {
37
- client.end();
38
- client.destroy();
39
- reject(error);
40
- return;
41
- }
42
- if (response.progress && onProgress) {
43
- onProgress(response);
44
- continue;
45
- }
46
- client.end();
47
- client.destroy();
48
- resolve(response);
49
- return;
50
- }
51
- });
52
- client.on("error", (error) => {
53
- reject(error);
54
- });
55
- });
56
- }
57
- async function fetchSnapshot() {
58
- const response = await sendRequest({ id: createRequestId(), action: "list", params: {} });
59
- if (!response.ok) {
60
- return null;
61
- }
62
- return response.data;
63
- }
64
- /** Send a request without waiting for a response (fire-and-forget). */
65
- function sendFireAndForget(payload) {
66
- try {
67
- const { socketPath } = (0, constants_1.resolveConfig)();
68
- const client = node_net_1.default.createConnection(socketPath);
69
- client.on("connect", () => {
70
- client.write(`${JSON.stringify(payload)}\n`);
71
- // Unref after write so Node can exit without waiting for response
72
- client.unref();
73
- const timer = setTimeout(() => { client.end(); client.destroy(); }, 200);
74
- timer.unref();
75
- });
76
- client.on("error", () => {
77
- // Silently ignore — this is best-effort
78
- });
79
- }
80
- catch {
81
- // Silently ignore
82
- }
83
- }
@@ -1,134 +0,0 @@
1
- "use strict";
2
- /**
3
- * Doctor command handler: diagnose and repair profile health.
4
- *
5
- * Checks each profile's wrapper for valid Node/host paths, verifies
6
- * extension sync status, and optionally auto-repairs broken wrappers.
7
- */
8
- var __importDefault = (this && this.__importDefault) || function (mod) {
9
- return (mod && mod.__esModule) ? mod : { "default": mod };
10
- };
11
- Object.defineProperty(exports, "__esModule", { value: true });
12
- exports.runDoctor = runDoctor;
13
- const node_fs_1 = __importDefault(require("node:fs"));
14
- const node_path_1 = __importDefault(require("node:path"));
15
- const config_1 = require("../../../shared/config");
16
- const profiles_1 = require("../../../shared/profiles");
17
- const wrapper_health_1 = require("../../../shared/wrapper-health");
18
- const extension_sync_1 = require("../../../shared/extension-sync");
19
- const setup_1 = require("./setup");
20
- const output_1 = require("../output");
21
- function checkProfile(name, entry, fix) {
22
- const wrapperPath = (0, wrapper_health_1.resolveWrapperPath)(entry.dataDir);
23
- const check = (0, wrapper_health_1.checkWrapper)(wrapperPath);
24
- const issues = [...check.issues];
25
- let fixed = false;
26
- if (fix && !check.ok && check.info) {
27
- const needsNodeFix = !node_fs_1.default.existsSync(check.info.nodePath);
28
- const needsHostFix = !node_fs_1.default.existsSync(check.info.hostPath);
29
- if (needsNodeFix || needsHostFix) {
30
- const newNodePath = needsNodeFix ? process.execPath : check.info.nodePath;
31
- let newHostPath = check.info.hostPath;
32
- if (needsHostFix) {
33
- // Use the stable bundled host path
34
- try {
35
- const config = (0, config_1.resolveConfig)();
36
- newHostPath = (0, extension_sync_1.resolveInstalledHostPath)(config.baseDataDir);
37
- if (!node_fs_1.default.existsSync(newHostPath)) {
38
- issues.push(`Bundled host not found at ${newHostPath} — run: tabctl setup --browser ${entry.browser}`);
39
- }
40
- }
41
- catch {
42
- issues.push("Could not resolve bundled host path");
43
- }
44
- }
45
- try {
46
- (0, setup_1.writeWrapper)(newNodePath, newHostPath, check.info.profileName, node_path_1.default.dirname(wrapperPath));
47
- fixed = true;
48
- // Update issue messages to show they were fixed
49
- const fixedIssues = [];
50
- if (needsNodeFix) {
51
- fixedIssues.push(`Fixed Node path: ${check.info.nodePath} → ${newNodePath}`);
52
- }
53
- if (needsHostFix) {
54
- fixedIssues.push(`Fixed host path: ${check.info.hostPath} → ${newHostPath}`);
55
- }
56
- // Replace original issues with fixed messages
57
- issues.length = 0;
58
- issues.push(...fixedIssues);
59
- }
60
- catch (err) {
61
- issues.push(`Failed to fix wrapper: ${err instanceof Error ? err.message : String(err)}`);
62
- }
63
- }
64
- }
65
- return {
66
- ok: check.ok || fixed,
67
- browser: entry.browser,
68
- dataDir: entry.dataDir,
69
- wrapperPath,
70
- issues,
71
- fixed,
72
- };
73
- }
74
- function runDoctor(options, prettyOutput) {
75
- const fix = options.fix === true;
76
- const config = (0, config_1.resolveConfig)();
77
- const registry = (0, profiles_1.loadProfiles)(config.configDir);
78
- const profileNames = Object.keys(registry.profiles);
79
- if (profileNames.length === 0) {
80
- (0, output_1.errorOut)("No profiles configured. Run: tabctl setup --browser <edge|chrome>");
81
- }
82
- // Check each profile
83
- const profiles = {};
84
- for (const name of profileNames) {
85
- profiles[name] = checkProfile(name, registry.profiles[name], fix);
86
- }
87
- // Check extension sync status
88
- let extensionCheck;
89
- try {
90
- const sync = (0, extension_sync_1.checkExtensionSync)(config.baseDataDir);
91
- extensionCheck = {
92
- ok: !sync.needsSync,
93
- synced: !sync.needsSync,
94
- bundledVersion: sync.bundledVersion,
95
- installedVersion: sync.installedVersion,
96
- };
97
- }
98
- catch {
99
- extensionCheck = {
100
- ok: false,
101
- synced: false,
102
- bundledVersion: null,
103
- installedVersion: null,
104
- };
105
- }
106
- // Summary
107
- const total = profileNames.length;
108
- const healthy = Object.values(profiles).filter(p => p.ok).length;
109
- const broken = total - healthy;
110
- const fixed = Object.values(profiles).filter(p => p.fixed).length;
111
- const allOk = broken === 0 && extensionCheck.ok;
112
- (0, output_1.printJson)({
113
- ok: allOk,
114
- action: "doctor",
115
- data: {
116
- profiles,
117
- extension: extensionCheck,
118
- summary: { total, healthy, broken, fixed },
119
- },
120
- }, prettyOutput);
121
- // Helpful stderr hints
122
- if (!allOk && !fix) {
123
- const brokenNames = Object.entries(profiles)
124
- .filter(([, p]) => !p.ok)
125
- .map(([n]) => n);
126
- if (brokenNames.length > 0) {
127
- process.stderr.write(`\nBroken profiles: ${brokenNames.join(", ")}\n`);
128
- process.stderr.write("Run: tabctl doctor --fix\n\n");
129
- }
130
- }
131
- if (fixed > 0) {
132
- process.stderr.write(`\nFixed ${fixed} profile(s). Verify: tabctl ping\n\n`);
133
- }
134
- }