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 +1 -1
- package/dist/cli/lib/client.js +21 -0
- package/dist/cli/lib/commands/meta.js +4 -1
- package/dist/cli/lib/commands/setup.js +107 -22
- package/dist/cli/lib/options-commands.js +3 -0
- package/dist/cli/lib/output.js +36 -1
- package/dist/cli/tabctl.js +4 -0
- package/dist/extension/background.js +3 -0
- package/dist/extension/manifest.json +2 -2
- package/dist/host/host.bundle.js +667 -0
- package/dist/host/host.js +22 -3
- package/dist/host/launcher/go.mod +3 -0
- package/dist/host/launcher/main.go +109 -0
- package/dist/shared/config.js +26 -3
- package/dist/shared/extension-sync.js +56 -2
- package/dist/shared/version.js +2 -2
- package/package.json +6 -3
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
|
|
30
|
+
<!-- test: "setup explicit --extension-id overrides auto-derived ID" -->
|
|
31
31
|
```bash
|
|
32
32
|
tabctl setup --browser chrome
|
|
33
33
|
```
|
package/dist/cli/lib/client.js
CHANGED
|
@@ -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
|
-
|
|
134
|
-
|
|
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
|
-
|
|
137
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
//
|
|
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
|
|
205
|
-
|
|
206
|
-
|
|
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
|
|
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
|
package/dist/cli/lib/output.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/cli/tabctl.js
CHANGED
|
@@ -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.
|
|
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.
|
|
22
|
+
"version_name": "0.3.1"
|
|
23
23
|
}
|