tabctl 0.2.1 → 0.3.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.
package/README.md CHANGED
@@ -27,7 +27,7 @@ If you haven't run `npm link`, you can always use `node ./cli/tabctl.js` instead
27
27
 
28
28
  Run the interactive setup — it syncs the extension, tells you where to load it, and prompts for the extension ID:
29
29
 
30
- <!-- test: "setup interactive mode reads extension-id from stdin" -->
30
+ <!-- test: "setup explicit --extension-id overrides auto-derived ID" -->
31
31
  ```bash
32
32
  tabctl setup --browser chrome
33
33
  ```
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.createRequestId = createRequestId;
7
7
  exports.sendRequest = sendRequest;
8
8
  exports.fetchSnapshot = fetchSnapshot;
9
+ exports.sendFireAndForget = sendFireAndForget;
9
10
  const net_1 = __importDefault(require("net"));
10
11
  const constants_1 = require("./constants");
11
12
  function createRequestId() {
@@ -60,3 +61,23 @@ async function fetchSnapshot() {
60
61
  }
61
62
  return response.data;
62
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 = 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
+ }
@@ -47,7 +47,7 @@ function resolveSkillTargetDir(globalInstall) {
47
47
  return path_1.default.join(resolveProjectRoot(), ".opencode", "skills", constants_1.SKILL_NAME);
48
48
  }
49
49
  function runSkillsCli(args) {
50
- const result = (0, node_child_process_1.spawnSync)("npx", ["skills", ...args], { stdio: "pipe" });
50
+ const result = (0, node_child_process_1.spawnSync)("npx", ["skills", ...args], { stdio: "pipe", shell: process.platform === "win32" });
51
51
  if (result.error) {
52
52
  (0, output_1.errorOut)(`Failed to run skills CLI: ${result.error.message}`);
53
53
  }
@@ -166,6 +166,7 @@ async function runHistory(options, prettyOutput) {
166
166
  dirty: constants_1.DIRTY,
167
167
  },
168
168
  });
169
+ (0, output_1.emitVersionWarnings)(response, "history");
169
170
  (0, output_1.printJson)(response, prettyOutput);
170
171
  if (!response.ok) {
171
172
  process.exit(1);
@@ -198,6 +199,7 @@ async function runUndo(options, prettyOutput) {
198
199
  dirty: constants_1.DIRTY,
199
200
  },
200
201
  });
202
+ (0, output_1.emitVersionWarnings)(response, "undo");
201
203
  (0, output_1.printJson)(response, prettyOutput);
202
204
  if (!response.ok) {
203
205
  process.exit(1);
@@ -219,6 +221,7 @@ async function runPing(prettyOutput) {
219
221
  dirty: constants_1.DIRTY,
220
222
  },
221
223
  });
224
+ (0, output_1.emitVersionWarnings)(response, "ping");
222
225
  (0, output_1.printJson)(response, prettyOutput);
223
226
  if (!response.ok) {
224
227
  process.exit(1);
@@ -130,34 +130,102 @@ function resolveNodePath(options) {
130
130
  if (!path_1.default.isAbsolute(value)) {
131
131
  (0, output_1.errorOut)(`Node path must be absolute: ${value}`);
132
132
  }
133
- try {
134
- fs_1.default.accessSync(value, fs_1.default.constants.X_OK);
133
+ if (process.platform !== "win32") {
134
+ try {
135
+ fs_1.default.accessSync(value, fs_1.default.constants.X_OK);
136
+ }
137
+ catch {
138
+ (0, output_1.errorOut)(`Node binary not executable: ${value}`);
139
+ }
135
140
  }
136
- catch {
137
- (0, output_1.errorOut)(`Node binary not executable: ${value}`);
141
+ else {
142
+ try {
143
+ fs_1.default.accessSync(value, fs_1.default.constants.R_OK);
144
+ }
145
+ catch {
146
+ (0, output_1.errorOut)(`Node binary not found: ${value}`);
147
+ }
138
148
  }
139
149
  return value;
140
150
  }
141
- function resolveHostPath() {
142
- const root = path_1.default.resolve(__dirname, "../../..");
143
- const hostPath = path_1.default.join(root, "host", "host.js");
144
- if (!fs_1.default.existsSync(hostPath)) {
145
- (0, output_1.errorOut)(`Host script not found at ${hostPath}. Run: npm run build`);
151
+ function resolveHostPath(dataDir) {
152
+ // Sync host bundle to stable path so wrapper survives npm upgrades
153
+ try {
154
+ const result = (0, extension_sync_1.syncHost)(dataDir);
155
+ return result.hostPath;
156
+ }
157
+ catch (err) {
158
+ const detail = err instanceof Error ? err.message : String(err);
159
+ (0, output_1.errorOut)(`Failed to resolve native host. Make sure the CLI is built (run: npm run build). Details: ${detail}`);
146
160
  }
147
- return hostPath;
148
161
  }
149
162
  function resolveManifestDir(browser) {
150
163
  const home = os_1.default.homedir();
151
164
  if (!home) {
152
165
  (0, output_1.errorOut)("Home directory not found.");
153
166
  }
167
+ if (process.platform === "win32") {
168
+ // Windows: registry-based is preferred, but file-based works with --user-data-dir.
169
+ // For system-wide, we point to the per-user NativeMessagingHosts under LOCALAPPDATA.
170
+ const base = process.env.LOCALAPPDATA || path_1.default.join(home, "AppData", "Local");
171
+ if (browser === "edge") {
172
+ return path_1.default.join(base, "Microsoft", "Edge", "User Data", "NativeMessagingHosts");
173
+ }
174
+ return path_1.default.join(base, "Google", "Chrome", "User Data", "NativeMessagingHosts");
175
+ }
176
+ if (process.platform === "linux") {
177
+ if (browser === "edge") {
178
+ return path_1.default.join(home, ".config", "microsoft-edge", "NativeMessagingHosts");
179
+ }
180
+ return path_1.default.join(home, ".config", "google-chrome", "NativeMessagingHosts");
181
+ }
182
+ // macOS
154
183
  if (browser === "edge") {
155
184
  return path_1.default.join(home, "Library", "Application Support", "Microsoft Edge", "NativeMessagingHosts");
156
185
  }
157
186
  return path_1.default.join(home, "Library", "Application Support", "Google", "Chrome", "NativeMessagingHosts");
158
187
  }
159
188
  function writeWrapper(nodePath, hostPath, profileName, wrapperDir) {
160
- fs_1.default.mkdirSync(wrapperDir, { recursive: true, mode: 0o700 });
189
+ fs_1.default.mkdirSync(wrapperDir, { recursive: true });
190
+ if (process.platform !== "win32") {
191
+ try {
192
+ fs_1.default.chmodSync(wrapperDir, 0o700);
193
+ }
194
+ catch { /* ignore */ }
195
+ }
196
+ if (process.platform === "win32") {
197
+ // Prefer the Go launcher binary from the platform package.
198
+ // Falls back to a .cmd wrapper if unavailable (dev/testing only —
199
+ // .cmd wrappers don't work for Chrome native messaging).
200
+ let exeSrc;
201
+ try {
202
+ exeSrc = require.resolve("tabctl-win32-x64/tabctl-host.exe");
203
+ }
204
+ catch {
205
+ // Not installed
206
+ }
207
+ if (exeSrc) {
208
+ const exeDst = path_1.default.join(wrapperDir, "tabctl-host.exe");
209
+ fs_1.default.copyFileSync(exeSrc, exeDst);
210
+ const cfgLines = [nodePath, hostPath];
211
+ if (profileName) {
212
+ cfgLines.push(`TABCTL_PROFILE=${profileName}`);
213
+ }
214
+ cfgLines.push("");
215
+ fs_1.default.writeFileSync(path_1.default.join(wrapperDir, "host-launcher.cfg"), cfgLines.join("\r\n"), "utf8");
216
+ return exeDst;
217
+ }
218
+ // Fallback: .cmd wrapper (won't work with Chrome native messaging)
219
+ const wrapperPath = path_1.default.join(wrapperDir, "tabctl-host.cmd");
220
+ const lines = ["@echo off"];
221
+ if (profileName) {
222
+ lines.push(`set TABCTL_PROFILE=${profileName}`);
223
+ }
224
+ lines.push(`"${nodePath}" "${hostPath}" %*`);
225
+ lines.push("");
226
+ fs_1.default.writeFileSync(wrapperPath, lines.join("\r\n"), "utf8");
227
+ return wrapperPath;
228
+ }
161
229
  const wrapperPath = path_1.default.join(wrapperDir, "tabctl-host.sh");
162
230
  const escapedNode = nodePath.replace(/"/g, "\\\"");
163
231
  const escapedHost = hostPath.replace(/"/g, "\\\"");
@@ -176,17 +244,14 @@ function writeWrapper(nodePath, hostPath, profileName, wrapperDir) {
176
244
  return wrapperPath;
177
245
  }
178
246
  async function runSetup(options, prettyOutput) {
179
- if (process.platform !== "darwin") {
180
- (0, output_1.errorOut)("tabctl setup is only supported on macOS.");
181
- }
182
247
  const browser = resolveBrowser(options.browser);
183
248
  if (!browser) {
184
249
  (0, output_1.errorOut)("Missing or invalid --browser (edge|chrome)");
185
250
  }
186
251
  const nodePath = resolveNodePath(options);
187
- const hostPath = resolveHostPath();
188
- // Sync extension to stable path (before extensionId so interactive mode can show it)
252
+ // Sync extension + host to stable paths (before extensionId so interactive mode can show it)
189
253
  const config = (0, constants_1.resolveConfig)();
254
+ const hostPath = resolveHostPath(config.baseDataDir);
190
255
  let extensionSync;
191
256
  try {
192
257
  extensionSync = (0, extension_sync_1.syncExtension)(config.baseDataDir);
@@ -194,16 +259,36 @@ async function runSetup(options, prettyOutput) {
194
259
  catch {
195
260
  extensionSync = null;
196
261
  }
197
- // Resolve extension ID: non-interactive if provided, interactive otherwise
262
+ // Resolve extension ID: explicit flag, derived from install path, or interactive prompt
198
263
  let extensionId = resolveExtensionId(options, false);
199
264
  if (!extensionId) {
200
- // Interactive mode
265
+ // Auto-derive from the installed extension path (Chromium uses SHA256 of the path)
266
+ const installedDir = (0, extension_sync_1.resolveInstalledExtensionDir)(config.baseDataDir);
267
+ if (fs_1.default.existsSync(path_1.default.join(installedDir, "manifest.json"))) {
268
+ extensionId = (0, extension_sync_1.deriveExtensionId)(installedDir);
269
+ process.stderr.write(`Extension ID derived from: ${installedDir}\n`);
270
+ }
271
+ }
272
+ if (!extensionId) {
273
+ // Interactive mode: sync hadn't happened or path doesn't exist
201
274
  if (extensionSync?.extensionDir) {
202
275
  process.stderr.write(`\nExtension synced to: ${extensionSync.extensionDir}\n`);
203
276
  try {
204
- const pbcopy = (0, node_child_process_1.spawn)("pbcopy", { stdio: ["pipe", "ignore", "ignore"] });
205
- pbcopy.stdin.end(extensionSync.extensionDir);
206
- pbcopy.on("exit", (code) => {
277
+ const clipArgs = [];
278
+ let clipCmd;
279
+ if (process.platform === "darwin") {
280
+ clipCmd = "pbcopy";
281
+ }
282
+ else if (process.platform === "win32") {
283
+ clipCmd = "clip";
284
+ }
285
+ else {
286
+ clipCmd = "xclip";
287
+ clipArgs.push("-selection", "clipboard");
288
+ }
289
+ const clip = (0, node_child_process_1.spawn)(clipCmd, clipArgs, { stdio: ["pipe", "ignore", "ignore"] });
290
+ clip.stdin.end(extensionSync.extensionDir);
291
+ clip.on("exit", (code) => {
207
292
  if (code === 0)
208
293
  process.stderr.write("(Path copied to clipboard)\n");
209
294
  });
@@ -226,7 +311,7 @@ async function runSetup(options, prettyOutput) {
226
311
  }
227
312
  // Profile data dir (use baseDataDir to avoid nesting under another profile)
228
313
  const profileDataDir = path_1.default.join(config.baseDataDir, "profiles", profileName);
229
- fs_1.default.mkdirSync(profileDataDir, { recursive: true, mode: 0o700 });
314
+ fs_1.default.mkdirSync(profileDataDir, { recursive: true });
230
315
  // Write profile-specific wrapper
231
316
  const wrapperPath = writeWrapper(nodePath, hostPath, profileName, profileDataDir);
232
317
  // Resolve manifest directory: custom user-data-dir or system-wide
@@ -253,4 +253,7 @@ exports.COMMANDS = {
253
253
  ping: {
254
254
  description: "Test connection to browser extension",
255
255
  },
256
+ reload: {
257
+ description: "Reload the browser extension (internal, used for upgrades)",
258
+ },
256
259
  };
@@ -6,6 +6,10 @@ exports.setupStdoutErrorHandling = setupStdoutErrorHandling;
6
6
  exports.emitVersionWarnings = emitVersionWarnings;
7
7
  const version_1 = require("../../shared/version");
8
8
  const profiles_1 = require("../../shared/profiles");
9
+ const extension_sync_1 = require("../../shared/extension-sync");
10
+ const config_1 = require("../../shared/config");
11
+ const client_1 = require("./client");
12
+ const client_2 = require("./client");
9
13
  function printJson(payload, pretty = true) {
10
14
  try {
11
15
  const active = (0, profiles_1.getActiveProfile)();
@@ -21,6 +25,20 @@ function printJson(payload, pretty = true) {
21
25
  process.stdout.write(`${output}\n`);
22
26
  }
23
27
  function errorOut(message) {
28
+ // On ENOENT (socket missing), try syncing host + extension before showing error
29
+ if (message.includes("ENOENT")) {
30
+ try {
31
+ const config = (0, config_1.resolveConfig)();
32
+ const hostResult = (0, extension_sync_1.syncHost)(config.baseDataDir);
33
+ const extResult = (0, extension_sync_1.syncExtension)(config.baseDataDir);
34
+ if (hostResult.synced || extResult.synced) {
35
+ process.stderr.write(`[tabctl] synced host and extension to ${config.baseDataDir}\n`);
36
+ }
37
+ }
38
+ catch {
39
+ // Sync is best-effort
40
+ }
41
+ }
24
42
  const hints = {
25
43
  "Unknown option: --format": "Use --json for JSON output. --format is only for report.",
26
44
  "ENOENT": "Native host not running. Ensure the browser extension is loaded and active. If you recently upgraded, run: tabctl setup",
@@ -45,8 +63,25 @@ function setupStdoutErrorHandling() {
45
63
  }
46
64
  function emitVersionWarnings(response, fallbackAction) {
47
65
  const hostVersion = typeof response.version === "string" ? response.version : null;
66
+ // CLI ↔ host version mismatch: auto-upgrade (sync files + trigger reload)
48
67
  if (hostVersion && hostVersion !== version_1.VERSION) {
49
- process.stderr.write(`[tabctl] version mismatch: cli ${version_1.VERSION}, host ${hostVersion}. Run: tabctl setup\n`);
68
+ try {
69
+ const config = (0, config_1.resolveConfig)();
70
+ const hostResult = (0, extension_sync_1.syncHost)(config.baseDataDir);
71
+ const extResult = (0, extension_sync_1.syncExtension)(config.baseDataDir);
72
+ const anySynced = hostResult.synced || extResult.synced;
73
+ // Send reload if we synced new files OR if the running host is stale
74
+ if (anySynced) {
75
+ process.stderr.write(`[tabctl] upgraded: ${hostVersion} → ${version_1.BASE_VERSION}. Reloading extension...\n`);
76
+ }
77
+ else {
78
+ process.stderr.write(`[tabctl] host is stale (${hostVersion}), reloading extension...\n`);
79
+ }
80
+ (0, client_1.sendFireAndForget)({ id: (0, client_2.createRequestId)(), action: "reload", params: {} });
81
+ }
82
+ catch {
83
+ process.stderr.write(`[tabctl] version mismatch: cli ${version_1.VERSION}, host ${hostVersion}. Run: tabctl setup\n`);
84
+ }
50
85
  }
51
86
  const data = response.data;
52
87
  const extensionVersion = data && typeof data.extensionVersion === "string" ? data.extensionVersion : null;
@@ -270,6 +270,10 @@ async function main() {
270
270
  action = "screenshot";
271
271
  params = (0, commands_2.buildScreenshotParams)(options);
272
272
  break;
273
+ case "reload":
274
+ action = "reload";
275
+ params = {};
276
+ break;
273
277
  default:
274
278
  (0, output_1.errorOut)(`Unknown command: ${command}`);
275
279
  }
@@ -3165,6 +3165,9 @@
3165
3165
  return await screenshot.screenshotTabs(params, requestId, deps);
3166
3166
  case "undo":
3167
3167
  return await undoHandlers.undoTransaction(params, deps);
3168
+ case "reload":
3169
+ setTimeout(() => chrome.runtime.reload(), 100);
3170
+ return { reloading: true };
3168
3171
  default:
3169
3172
  throw new Error(`Unknown action: ${action}`);
3170
3173
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Tab Control",
4
- "version": "0.2.1",
4
+ "version": "0.3.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.2.1"
22
+ "version_name": "0.3.1"
23
23
  }