tabctl 0.4.0 → 0.5.2

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
@@ -59,7 +59,7 @@ tabctl list # see your open tabs
59
59
 
60
60
  ## Commands
61
61
 
62
- <!-- test: "list sends list action", "analyze passes tab ids and github options", "inspect passes signal options", "close without confirm fails", "report format md returns markdown content", "undo sends undo action with txid" -->
62
+ <!-- test: "list sends list action", "analyze passes tab ids and progress option", "inspect passes signal options", "close without confirm fails", "report format md returns markdown content", "undo sends undo action with txid" -->
63
63
  | Command | Description |
64
64
  |---------|-------------|
65
65
  | `tabctl list` | List open tabs and groups |
@@ -255,7 +255,7 @@ Notes:
255
255
  - `list` and `group-list` paginate by default (limit 100); use `--limit`, `--offset`, or `--no-page`.
256
256
  - Use `--group-id -1` or `--ungrouped` to target ungrouped tabs.
257
257
  - `--selector` implies `--signal selector`.
258
- - Unknown inspect signals are rejected (valid: `page-meta`, `github-state`, `selector`).
258
+ - Unknown inspect signals are rejected (valid: `page-meta`, `selector`).
259
259
  - Selector `attr` supports `href-url`/`src-url` to return absolute http(s) URLs.
260
260
  - `screenshot --out` writes per-tab folders into the target directory.
261
261
  - `tabctl undo` accepts a positional txid, `--txid`, or `--latest`.
@@ -0,0 +1,134 @@
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
+ }
@@ -4,10 +4,13 @@
4
4
  * Re-exports all command handlers and parameter builders.
5
5
  */
6
6
  Object.defineProperty(exports, "__esModule", { value: true });
7
- exports.buildUndoParams = exports.buildHistoryParams = exports.buildScreenshotParams = exports.buildReportParams = exports.buildCloseParams = exports.buildArchiveParams = exports.buildMergeWindowParams = exports.buildMoveGroupParams = exports.buildMoveTabParams = exports.buildGroupGatherParams = exports.buildGroupAssignParams = exports.buildGroupUngroupParams = exports.buildGroupUpdateParams = exports.buildOpenParams = exports.buildRefreshParams = exports.buildFocusParams = exports.buildInspectParams = exports.buildAnalyzeParams = exports.runProfileRemove = exports.runProfileSwitch = exports.runProfileShow = exports.runProfileList = exports.runGroupList = exports.runList = exports.runPing = exports.runUndo = exports.runHistory = exports.runPolicy = exports.runVersion = exports.runSkillInstall = exports.runSetup = void 0;
7
+ exports.buildUndoParams = exports.buildHistoryParams = exports.buildScreenshotParams = exports.buildReportParams = exports.buildCloseParams = exports.buildArchiveParams = exports.buildMergeWindowParams = exports.buildMoveGroupParams = exports.buildMoveTabParams = exports.buildGroupGatherParams = exports.buildGroupAssignParams = exports.buildGroupUngroupParams = exports.buildGroupUpdateParams = exports.buildOpenParams = exports.buildRefreshParams = exports.buildFocusParams = exports.buildInspectParams = exports.buildAnalyzeParams = exports.runProfileRemove = exports.runProfileSwitch = exports.runProfileShow = exports.runProfileList = exports.runGroupList = exports.runList = exports.runPing = exports.runUndo = exports.runHistory = exports.runPolicy = exports.runVersion = exports.runSkillInstall = exports.runDoctor = exports.runSetup = void 0;
8
8
  // Setup command
9
9
  var setup_1 = require("./setup");
10
10
  Object.defineProperty(exports, "runSetup", { enumerable: true, get: function () { return setup_1.runSetup; } });
11
+ // Doctor command
12
+ var doctor_1 = require("./doctor");
13
+ Object.defineProperty(exports, "runDoctor", { enumerable: true, get: function () { return doctor_1.runDoctor; } });
11
14
  // Meta commands (version, ping, skill, policy, history, undo)
12
15
  var meta_1 = require("./meta");
13
16
  Object.defineProperty(exports, "runSkillInstall", { enumerable: true, get: function () { return meta_1.runSkillInstall; } });
@@ -46,14 +46,11 @@ function buildAnalyzeParams(options) {
46
46
  const { tabIds, groupTitle, groupId, windowId, all } = buildScopeFromOptions(options);
47
47
  return {
48
48
  staleDays: options["stale-days"] ? Number(options["stale-days"]) : undefined,
49
- checkGitHub: Boolean(options.github),
50
49
  tabIds,
51
50
  groupTitle,
52
51
  groupId,
53
52
  windowId,
54
53
  all,
55
- githubConcurrency: options["github-concurrency"] ? Number(options["github-concurrency"]) : undefined,
56
- githubTimeoutMs: options["github-timeout-ms"] ? Number(options["github-timeout-ms"]) : undefined,
57
54
  progress: Boolean(options.progress),
58
55
  };
59
56
  }
@@ -78,7 +78,7 @@ function resolveNodePath(options) {
78
78
  function resolveHostPath(dataDir) {
79
79
  // Sync host bundle to stable path so wrapper survives npm upgrades
80
80
  try {
81
- const result = (0, extension_sync_1.syncHost)(dataDir);
81
+ const result = (0, extension_sync_1.syncHost)(dataDir, { force: true });
82
82
  return result.hostPath;
83
83
  }
84
84
  catch (err) {
@@ -181,7 +181,7 @@ function runSetup(options, prettyOutput) {
181
181
  const hostPath = resolveHostPath(config.baseDataDir);
182
182
  let extensionSync;
183
183
  try {
184
- extensionSync = (0, extension_sync_1.syncExtension)(config.baseDataDir);
184
+ extensionSync = (0, extension_sync_1.syncExtension)(config.baseDataDir, { force: true });
185
185
  }
186
186
  catch {
187
187
  extensionSync = null;
@@ -26,5 +26,5 @@ exports.GROUP_COLORS = new Set([
26
26
  exports.DEFAULT_PAGE_LIMIT = 100;
27
27
  exports.SKILL_NAME = "tabctl";
28
28
  exports.SKILL_REPO = process.env.TABCTL_SKILL_REPO || "https://github.com/ekroon/tabctl";
29
- exports.SUPPORTED_SIGNALS = ["page-meta", "github-state", "selector"];
29
+ exports.SUPPORTED_SIGNALS = ["page-meta", "selector"];
30
30
  exports.SUPPORTED_SIGNAL_SET = new Set(exports.SUPPORTED_SIGNALS);
@@ -256,6 +256,12 @@ exports.COMMANDS = {
256
256
  { flag: "<name>", desc: "Profile name (positional)" },
257
257
  ],
258
258
  },
259
+ doctor: {
260
+ description: "Diagnose and repair profile health",
261
+ options: [
262
+ { flag: "--fix", desc: "Auto-repair broken wrappers" },
263
+ ],
264
+ },
259
265
  version: {
260
266
  description: "Show version information",
261
267
  },
@@ -8,8 +8,10 @@ const version_1 = require("../../shared/version");
8
8
  const profiles_1 = require("../../shared/profiles");
9
9
  const extension_sync_1 = require("../../shared/extension-sync");
10
10
  const config_1 = require("../../shared/config");
11
+ const wrapper_health_1 = require("../../shared/wrapper-health");
11
12
  const client_1 = require("./client");
12
13
  const client_2 = require("./client");
14
+ const setup_1 = require("./commands/setup");
13
15
  function printJson(payload, pretty = true) {
14
16
  try {
15
17
  const active = (0, profiles_1.getActiveProfile)();
@@ -63,27 +65,42 @@ function setupStdoutErrorHandling() {
63
65
  }
64
66
  function emitVersionWarnings(response, fallbackAction) {
65
67
  const hostVersion = typeof response.version === "string" ? response.version : null;
66
- // CLI host version mismatch: auto-upgrade (sync files + trigger reload)
67
- if (hostVersion && hostVersion !== version_1.VERSION) {
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`);
68
+ const data = response.data;
69
+ const hostBaseVersion = data && typeof data.hostBaseVersion === "string" ? data.hostBaseVersion : null;
70
+ const isDevCli = version_1.DEV_BUILD;
71
+ // CLI host BASE_VERSION mismatch: auto-upgrade (sync files + trigger reload).
72
+ // Dev builds never sync — they use whatever host is already installed.
73
+ const effectiveHostBase = hostBaseVersion ?? hostVersion;
74
+ if (effectiveHostBase && effectiveHostBase !== version_1.BASE_VERSION && !isDevCli) {
75
+ // Downgrade: host is newer than CLI warn but don't sync/reload
76
+ if ((0, extension_sync_1.compareBaseVersions)(version_1.BASE_VERSION, effectiveHostBase) < 0) {
77
+ process.stderr.write(`[tabctl] cli (${version_1.BASE_VERSION}) is older than host (${effectiveHostBase}). Consider upgrading: npm install -g tabctl\n`);
78
+ }
79
+ else {
80
+ try {
81
+ const config = (0, config_1.resolveConfig)();
82
+ const hostResult = (0, extension_sync_1.syncHost)(config.baseDataDir);
83
+ const extResult = (0, extension_sync_1.syncExtension)(config.baseDataDir);
84
+ const anySynced = hostResult.synced || extResult.synced;
85
+ if (anySynced) {
86
+ process.stderr.write(`[tabctl] upgraded: ${effectiveHostBase} → ${version_1.BASE_VERSION}. Reloading extension...\n`);
87
+ }
88
+ else {
89
+ process.stderr.write(`[tabctl] host is stale (${effectiveHostBase}), reloading extension...\n`);
90
+ }
91
+ (0, client_1.sendFireAndForget)({ id: (0, client_2.createRequestId)(), action: "reload", params: {} });
92
+ try {
93
+ repairActiveWrapper(config.baseDataDir);
94
+ }
95
+ catch {
96
+ // Wrapper repair is best-effort
97
+ }
76
98
  }
77
- else {
78
- process.stderr.write(`[tabctl] host is stale (${hostVersion}), reloading extension...\n`);
99
+ catch {
100
+ process.stderr.write(`[tabctl] version mismatch: cli ${version_1.BASE_VERSION}, host ${effectiveHostBase}. Run: tabctl setup\n`);
79
101
  }
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
102
  }
85
103
  }
86
- const data = response.data;
87
104
  const extensionVersion = data && typeof data.extensionVersion === "string" ? data.extensionVersion : null;
88
105
  const extensionComponent = data && typeof data.extensionComponent === "string" ? data.extensionComponent : null;
89
106
  if (extensionVersion && hostVersion && extensionVersion !== hostVersion) {
@@ -98,3 +115,33 @@ function emitVersionWarnings(response, fallbackAction) {
98
115
  process.stderr.write("[tabctl] extension version unavailable. Reload the extension in your browser\n");
99
116
  }
100
117
  }
118
+ /**
119
+ * Repair the active profile's wrapper if the Node path is broken.
120
+ * Conservative: only fixes paths that are confirmed missing.
121
+ */
122
+ function repairActiveWrapper(baseDataDir) {
123
+ const active = (0, profiles_1.getActiveProfile)();
124
+ if (!active)
125
+ return;
126
+ const wrapperPath = (0, wrapper_health_1.resolveWrapperPath)(active.profile.dataDir);
127
+ const check = (0, wrapper_health_1.checkWrapper)(wrapperPath);
128
+ if (check.ok || !check.info)
129
+ return;
130
+ const fs = require("node:fs");
131
+ const path = require("node:path");
132
+ const needsNodeFix = !fs.existsSync(check.info.nodePath);
133
+ const needsHostFix = !fs.existsSync(check.info.hostPath);
134
+ if (!needsNodeFix && !needsHostFix)
135
+ return;
136
+ const newNodePath = needsNodeFix ? process.execPath : check.info.nodePath;
137
+ const newHostPath = needsHostFix
138
+ ? (0, extension_sync_1.resolveInstalledHostPath)(baseDataDir)
139
+ : check.info.hostPath;
140
+ (0, setup_1.writeWrapper)(newNodePath, newHostPath, check.info.profileName, path.dirname(wrapperPath));
141
+ if (needsNodeFix) {
142
+ process.stderr.write(`[tabctl] fixed wrapper Node path: ${check.info.nodePath} → ${newNodePath}\n`);
143
+ }
144
+ if (needsHostFix) {
145
+ process.stderr.write(`[tabctl] fixed wrapper host path: ${check.info.hostPath} → ${newHostPath}\n`);
146
+ }
147
+ }
@@ -147,7 +147,7 @@ function applyPolicyFilter(command, params, snapshot, policyContext, policySumma
147
147
  generatedAt,
148
148
  staleDays: params.staleDays || 0,
149
149
  totals: { tabs: 0, analyzed: 0, candidates: 0 },
150
- meta: { durationMs: 0, githubChecked: 0, githubTotal: 0, githubMatched: 0, githubTimeoutMs: params.githubTimeoutMs || 0 },
150
+ meta: { durationMs: 0 },
151
151
  candidates: [],
152
152
  analysisId: null,
153
153
  policy: policySummary,
@@ -74,7 +74,7 @@ function buildDedupeOutput(response, includeStale, closeData, confirmed) {
74
74
  const candidates = Array.isArray(data.candidates) ? data.candidates : [];
75
75
  const planned = candidates.filter((candidate) => {
76
76
  const reasons = Array.isArray(candidate.reasons) ? candidate.reasons : [];
77
- const hasDuplicate = reasons.some((reason) => reason.type === "duplicate" || reason.type === "closed_issue");
77
+ const hasDuplicate = reasons.some((reason) => reason.type === "duplicate");
78
78
  const hasStale = reasons.some((reason) => reason.type === "stale");
79
79
  return hasDuplicate || (includeStale && hasStale);
80
80
  });
@@ -127,7 +127,7 @@ function extractDedupePlan(response, includeStale) {
127
127
  const candidates = Array.isArray(data.candidates) ? data.candidates : [];
128
128
  const planned = candidates.filter((candidate) => {
129
129
  const reasons = Array.isArray(candidate.reasons) ? candidate.reasons : [];
130
- const hasDuplicate = reasons.some((reason) => reason.type === "duplicate" || reason.type === "closed_issue");
130
+ const hasDuplicate = reasons.some((reason) => reason.type === "duplicate");
131
131
  const hasStale = reasons.some((reason) => reason.type === "stale");
132
132
  return hasDuplicate || (includeStale && hasStale);
133
133
  });
@@ -153,6 +153,10 @@ async function main() {
153
153
  await (0, commands_1.runSetup)(options, prettyOutput);
154
154
  return;
155
155
  }
156
+ if (command === "doctor") {
157
+ (0, commands_1.runDoctor)(options, prettyOutput);
158
+ return;
159
+ }
156
160
  if (command === "version") {
157
161
  (0, commands_1.runVersion)(prettyOutput);
158
162
  return;
@@ -400,8 +400,6 @@
400
400
  exports.isScriptableUrl = isScriptableUrl2;
401
401
  exports.delay = delay2;
402
402
  exports.executeWithTimeout = executeWithTimeout2;
403
- exports.isGitHubIssueOrPr = isGitHubIssueOrPr2;
404
- exports.detectGitHubState = detectGitHubState2;
405
403
  exports.extractPageMeta = extractPageMeta2;
406
404
  exports.extractSelectorSignal = extractSelectorSignal2;
407
405
  exports.waitForTabLoad = waitForTabLoad2;
@@ -439,32 +437,6 @@
439
437
  return null;
440
438
  }
441
439
  }
442
- function isGitHubIssueOrPr2(url) {
443
- if (!url) {
444
- return false;
445
- }
446
- return /^https:\/\/github\.com\/[^/]+\/[^/]+\/(issues|pull)\/\d+/.test(url);
447
- }
448
- async function detectGitHubState2(tabId, timeoutMs) {
449
- const result = await executeWithTimeout2(tabId, timeoutMs, () => {
450
- const stateEl = document.querySelector(".gh-header-meta .State") || document.querySelector(".State") || document.querySelector(".js-issue-state");
451
- if (!stateEl) {
452
- return null;
453
- }
454
- const text = (stateEl.textContent || "").trim().toLowerCase();
455
- if (text.includes("merged")) {
456
- return "merged";
457
- }
458
- if (text.includes("closed")) {
459
- return "closed";
460
- }
461
- if (text.includes("open")) {
462
- return "open";
463
- }
464
- return null;
465
- });
466
- return typeof result === "string" ? result : null;
467
- }
468
440
  async function extractPageMeta2(tabId, timeoutMs, descriptionMaxLength) {
469
441
  const result = await executeWithTimeout2(tabId, timeoutMs, () => {
470
442
  const pickContent = (selector) => {
@@ -2193,7 +2165,7 @@
2193
2165
  exports.analyzeTabs = analyzeTabs;
2194
2166
  exports.inspectTabs = inspectTabs;
2195
2167
  var content2 = require_content();
2196
- var { isScriptableUrl: isScriptableUrl2, isGitHubIssueOrPr: isGitHubIssueOrPr2, detectGitHubState: detectGitHubState2, extractPageMeta: extractPageMeta2, extractSelectorSignal: extractSelectorSignal2, waitForSettle: waitForSettle2, waitForTabReady: waitForTabReady2 } = content2;
2168
+ var { isScriptableUrl: isScriptableUrl2, extractPageMeta: extractPageMeta2, extractSelectorSignal: extractSelectorSignal2, waitForSettle: waitForSettle2, waitForTabReady: waitForTabReady2 } = content2;
2197
2169
  var tabs2 = require_tabs();
2198
2170
  var { normalizeUrl: normalizeUrl2 } = tabs2;
2199
2171
  exports.DEFAULT_STALE_DAYS = 30;
@@ -2201,11 +2173,6 @@
2201
2173
  exports.SELECTOR_VALUE_MAX_LENGTH = 500;
2202
2174
  async function analyzeTabs(params, requestId, deps2) {
2203
2175
  const staleDays = Number.isFinite(params.staleDays) ? params.staleDays : exports.DEFAULT_STALE_DAYS;
2204
- const checkGitHub = params.checkGitHub === true;
2205
- const githubConcurrencyRaw = Number(params.githubConcurrency);
2206
- const githubConcurrency = Number.isFinite(githubConcurrencyRaw) && githubConcurrencyRaw > 0 ? Math.min(10, Math.floor(githubConcurrencyRaw)) : 4;
2207
- const githubTimeoutRaw = Number(params.githubTimeoutMs);
2208
- const githubTimeoutMs = Number.isFinite(githubTimeoutRaw) && githubTimeoutRaw > 0 ? Math.floor(githubTimeoutRaw) : 4e3;
2209
2176
  const progressEnabled = params.progress === true;
2210
2177
  const snapshot = await deps2.getTabSnapshot();
2211
2178
  const selection = deps2.selectTabsByScope(snapshot, params);
@@ -2216,9 +2183,6 @@
2216
2183
  const scopeTabs = selectedTabs;
2217
2184
  const now = Date.now();
2218
2185
  const startedAt = Date.now();
2219
- let githubChecked = 0;
2220
- let githubTotal = 0;
2221
- let githubMatched = 0;
2222
2186
  const normalizedMap = /* @__PURE__ */ new Map();
2223
2187
  const duplicates = /* @__PURE__ */ new Map();
2224
2188
  for (const tab of scopeTabs) {
@@ -2257,45 +2221,9 @@
2257
2221
  }
2258
2222
  }
2259
2223
  }
2260
- const githubTabs = checkGitHub ? selectedTabs.filter((tab) => isGitHubIssueOrPr2(tab.url) && isScriptableUrl2(tab.url)) : [];
2261
- githubTotal = githubTabs.length;
2262
- if (checkGitHub && githubTabs.length > 0) {
2263
- let index = 0;
2264
- const total = githubTabs.length;
2265
- const workers = Array.from({ length: Math.min(githubConcurrency, total) }, async () => {
2266
- while (true) {
2267
- const currentIndex = index;
2268
- if (currentIndex >= total) {
2269
- return;
2270
- }
2271
- index += 1;
2272
- const tab = githubTabs[currentIndex];
2273
- const state2 = await detectGitHubState2(tab.tabId, githubTimeoutMs);
2274
- githubChecked += 1;
2275
- if (state2 === "closed" || state2 === "merged") {
2276
- githubMatched += 1;
2277
- addReason(tab, {
2278
- type: "closed_issue",
2279
- detail: `GitHub state: ${state2}`
2280
- });
2281
- }
2282
- if (progressEnabled) {
2283
- deps2.sendProgress(requestId, {
2284
- phase: "github",
2285
- processed: githubChecked,
2286
- total,
2287
- matched: githubMatched,
2288
- tabId: tab.tabId,
2289
- timeoutMs: githubTimeoutMs
2290
- });
2291
- }
2292
- }
2293
- });
2294
- await Promise.all(workers);
2295
- }
2296
2224
  const candidates = Array.from(candidateMap.values()).map((entry) => {
2297
2225
  const reasons = entry.reasons;
2298
- const severity = reasons.some((reason) => reason.type === "duplicate" || reason.type === "closed_issue") ? "high" : "medium";
2226
+ const severity = reasons.some((reason) => reason.type === "duplicate") ? "high" : "medium";
2299
2227
  return {
2300
2228
  tabId: entry.tab.tabId,
2301
2229
  windowId: entry.tab.windowId,
@@ -2316,11 +2244,7 @@
2316
2244
  candidates: candidates.length
2317
2245
  },
2318
2246
  meta: {
2319
- durationMs: Date.now() - startedAt,
2320
- githubChecked,
2321
- githubTotal,
2322
- githubMatched,
2323
- githubTimeoutMs
2247
+ durationMs: Date.now() - startedAt
2324
2248
  },
2325
2249
  candidates
2326
2250
  };
@@ -2377,16 +2301,7 @@
2377
2301
  }));
2378
2302
  const signalDefs = [];
2379
2303
  for (const signalId of signalList) {
2380
- if (signalId === "github-state") {
2381
- signalDefs.push({
2382
- id: signalId,
2383
- match: (tab) => isGitHubIssueOrPr2(tab.url) && isScriptableUrl2(tab.url),
2384
- run: async (tabId) => {
2385
- const state2 = await detectGitHubState2(tabId, signalTimeoutMs);
2386
- return state2 ? { state: state2 } : null;
2387
- }
2388
- });
2389
- } else if (signalId === "page-meta") {
2304
+ if (signalId === "page-meta") {
2390
2305
  signalDefs.push({
2391
2306
  id: signalId,
2392
2307
  match: (tab) => isScriptableUrl2(tab.url),
@@ -3358,7 +3273,7 @@
3358
3273
  var KEEPALIVE_INTERVAL_MINUTES = 1;
3359
3274
  var screenshot = require_screenshot();
3360
3275
  var content = require_content();
3361
- var { delay, executeWithTimeout, isScriptableUrl, isGitHubIssueOrPr, detectGitHubState, extractPageMeta, extractSelectorSignal, waitForTabLoad, waitForDomReady, waitForSettle, waitForTabReady, SETTLE_STABILITY_MS, SETTLE_POLL_INTERVAL_MS } = content;
3276
+ var { delay, executeWithTimeout, isScriptableUrl, extractPageMeta, extractSelectorSignal, waitForTabLoad, waitForDomReady, waitForSettle, waitForTabReady, SETTLE_STABILITY_MS, SETTLE_POLL_INTERVAL_MS } = content;
3362
3277
  var groups = require_groups();
3363
3278
  var tabs = require_tabs();
3364
3279
  var { getMostRecentFocusedWindowId, normalizeTabIndex } = tabs;
@@ -5,8 +5,6 @@ exports.SETTLE_POLL_INTERVAL_MS = exports.SETTLE_STABILITY_MS = void 0;
5
5
  exports.isScriptableUrl = isScriptableUrl;
6
6
  exports.delay = delay;
7
7
  exports.executeWithTimeout = executeWithTimeout;
8
- exports.isGitHubIssueOrPr = isGitHubIssueOrPr;
9
- exports.detectGitHubState = detectGitHubState;
10
8
  exports.extractPageMeta = extractPageMeta;
11
9
  exports.extractSelectorSignal = extractSelectorSignal;
12
10
  exports.waitForTabLoad = waitForTabLoad;
@@ -45,34 +43,6 @@ async function executeWithTimeout(tabId, timeoutMs, func, args = []) {
45
43
  return null;
46
44
  }
47
45
  }
48
- function isGitHubIssueOrPr(url) {
49
- if (!url) {
50
- return false;
51
- }
52
- return /^https:\/\/github\.com\/[^/]+\/[^/]+\/(issues|pull)\/\d+/.test(url);
53
- }
54
- async function detectGitHubState(tabId, timeoutMs) {
55
- const result = await executeWithTimeout(tabId, timeoutMs, () => {
56
- const stateEl = document.querySelector(".gh-header-meta .State") ||
57
- document.querySelector(".State") ||
58
- document.querySelector(".js-issue-state");
59
- if (!stateEl) {
60
- return null;
61
- }
62
- const text = (stateEl.textContent || "").trim().toLowerCase();
63
- if (text.includes("merged")) {
64
- return "merged";
65
- }
66
- if (text.includes("closed")) {
67
- return "closed";
68
- }
69
- if (text.includes("open")) {
70
- return "open";
71
- }
72
- return null;
73
- });
74
- return typeof result === "string" ? result : null;
75
- }
76
46
  async function extractPageMeta(tabId, timeoutMs, descriptionMaxLength) {
77
47
  const result = await executeWithTimeout(tabId, timeoutMs, () => {
78
48
  const pickContent = (selector) => {
@@ -5,7 +5,7 @@ exports.SELECTOR_VALUE_MAX_LENGTH = exports.DESCRIPTION_MAX_LENGTH = exports.DEF
5
5
  exports.analyzeTabs = analyzeTabs;
6
6
  exports.inspectTabs = inspectTabs;
7
7
  const content = require("./content");
8
- const { isScriptableUrl, isGitHubIssueOrPr, detectGitHubState, extractPageMeta, extractSelectorSignal, waitForSettle, waitForTabReady } = content;
8
+ const { isScriptableUrl, extractPageMeta, extractSelectorSignal, waitForSettle, waitForTabReady } = content;
9
9
  const tabs = require("./tabs");
10
10
  const { normalizeUrl } = tabs;
11
11
  exports.DEFAULT_STALE_DAYS = 30;
@@ -13,15 +13,6 @@ exports.DESCRIPTION_MAX_LENGTH = 250;
13
13
  exports.SELECTOR_VALUE_MAX_LENGTH = 500;
14
14
  async function analyzeTabs(params, requestId, deps) {
15
15
  const staleDays = Number.isFinite(params.staleDays) ? params.staleDays : exports.DEFAULT_STALE_DAYS;
16
- const checkGitHub = params.checkGitHub === true;
17
- const githubConcurrencyRaw = Number(params.githubConcurrency);
18
- const githubConcurrency = Number.isFinite(githubConcurrencyRaw) && githubConcurrencyRaw > 0
19
- ? Math.min(10, Math.floor(githubConcurrencyRaw))
20
- : 4;
21
- const githubTimeoutRaw = Number(params.githubTimeoutMs);
22
- const githubTimeoutMs = Number.isFinite(githubTimeoutRaw) && githubTimeoutRaw > 0
23
- ? Math.floor(githubTimeoutRaw)
24
- : 4000;
25
16
  const progressEnabled = params.progress === true;
26
17
  const snapshot = await deps.getTabSnapshot();
27
18
  const selection = deps.selectTabsByScope(snapshot, params);
@@ -32,9 +23,6 @@ async function analyzeTabs(params, requestId, deps) {
32
23
  const scopeTabs = selectedTabs;
33
24
  const now = Date.now();
34
25
  const startedAt = Date.now();
35
- let githubChecked = 0;
36
- let githubTotal = 0;
37
- let githubMatched = 0;
38
26
  const normalizedMap = new Map();
39
27
  const duplicates = new Map();
40
28
  for (const tab of scopeTabs) {
@@ -74,47 +62,9 @@ async function analyzeTabs(params, requestId, deps) {
74
62
  }
75
63
  }
76
64
  }
77
- const githubTabs = checkGitHub
78
- ? selectedTabs.filter((tab) => isGitHubIssueOrPr(tab.url) && isScriptableUrl(tab.url))
79
- : [];
80
- githubTotal = githubTabs.length;
81
- if (checkGitHub && githubTabs.length > 0) {
82
- let index = 0;
83
- const total = githubTabs.length;
84
- const workers = Array.from({ length: Math.min(githubConcurrency, total) }, async () => {
85
- while (true) {
86
- const currentIndex = index;
87
- if (currentIndex >= total) {
88
- return;
89
- }
90
- index += 1;
91
- const tab = githubTabs[currentIndex];
92
- const state = await detectGitHubState(tab.tabId, githubTimeoutMs);
93
- githubChecked += 1;
94
- if (state === "closed" || state === "merged") {
95
- githubMatched += 1;
96
- addReason(tab, {
97
- type: "closed_issue",
98
- detail: `GitHub state: ${state}`,
99
- });
100
- }
101
- if (progressEnabled) {
102
- deps.sendProgress(requestId, {
103
- phase: "github",
104
- processed: githubChecked,
105
- total,
106
- matched: githubMatched,
107
- tabId: tab.tabId,
108
- timeoutMs: githubTimeoutMs,
109
- });
110
- }
111
- }
112
- });
113
- await Promise.all(workers);
114
- }
115
65
  const candidates = Array.from(candidateMap.values()).map((entry) => {
116
66
  const reasons = entry.reasons;
117
- const severity = reasons.some((reason) => reason.type === "duplicate" || reason.type === "closed_issue")
67
+ const severity = reasons.some((reason) => reason.type === "duplicate")
118
68
  ? "high"
119
69
  : "medium";
120
70
  return {
@@ -138,10 +88,6 @@ async function analyzeTabs(params, requestId, deps) {
138
88
  },
139
89
  meta: {
140
90
  durationMs: Date.now() - startedAt,
141
- githubChecked,
142
- githubTotal,
143
- githubMatched,
144
- githubTimeoutMs,
145
91
  },
146
92
  candidates,
147
93
  };
@@ -210,17 +156,7 @@ async function inspectTabs(params, requestId, deps) {
210
156
  }));
211
157
  const signalDefs = [];
212
158
  for (const signalId of signalList) {
213
- if (signalId === "github-state") {
214
- signalDefs.push({
215
- id: signalId,
216
- match: (tab) => isGitHubIssueOrPr(tab.url) && isScriptableUrl(tab.url),
217
- run: async (tabId) => {
218
- const state = await detectGitHubState(tabId, signalTimeoutMs);
219
- return state ? { state } : null;
220
- },
221
- });
222
- }
223
- else if (signalId === "page-meta") {
159
+ if (signalId === "page-meta") {
224
160
  signalDefs.push({
225
161
  id: signalId,
226
162
  match: (tab) => isScriptableUrl(tab.url),
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Tab Control",
4
- "version": "0.4.0",
4
+ "version": "0.5.2",
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.4.0"
22
+ "version_name": "0.5.2"
23
23
  }
@@ -128,11 +128,12 @@ var require_version = __commonJS({
128
128
  "dist/shared/version.js"(exports2) {
129
129
  "use strict";
130
130
  Object.defineProperty(exports2, "__esModule", { value: true });
131
- exports2.DIRTY = exports2.GIT_SHA = exports2.VERSION = exports2.BASE_VERSION = void 0;
132
- exports2.BASE_VERSION = "0.4.0";
133
- exports2.VERSION = "0.4.0";
134
- exports2.GIT_SHA = null;
131
+ exports2.DEV_BUILD = exports2.DIRTY = exports2.GIT_SHA = exports2.VERSION = exports2.BASE_VERSION = void 0;
132
+ exports2.BASE_VERSION = "0.5.2";
133
+ exports2.VERSION = "0.5.2";
134
+ exports2.GIT_SHA = "c90b26ba";
135
135
  exports2.DIRTY = false;
136
+ exports2.DEV_BUILD = false;
136
137
  }
137
138
  });
138
139
 
@@ -11,6 +11,8 @@ exports.resolveInstalledExtensionDir = resolveInstalledExtensionDir;
11
11
  exports.resolveInstalledHostPath = resolveInstalledHostPath;
12
12
  exports.readExtensionVersion = readExtensionVersion;
13
13
  exports.readHostVersion = readHostVersion;
14
+ exports.compareBaseVersions = compareBaseVersions;
15
+ exports.isDevBuild = isDevBuild;
14
16
  exports.syncExtension = syncExtension;
15
17
  exports.syncHost = syncHost;
16
18
  exports.checkExtensionSync = checkExtensionSync;
@@ -18,6 +20,7 @@ const node_path_1 = __importDefault(require("node:path"));
18
20
  const node_fs_1 = __importDefault(require("node:fs"));
19
21
  const node_crypto_1 = __importDefault(require("node:crypto"));
20
22
  const config_1 = require("./config");
23
+ const version_1 = require("./version");
21
24
  exports.EXTENSION_DIR_NAME = "extension";
22
25
  exports.HOST_BUNDLE_NAME = "host.bundle.js";
23
26
  /**
@@ -72,11 +75,44 @@ function readHostVersion(hostPath) {
72
75
  return null;
73
76
  }
74
77
  }
75
- function syncExtension(dataDir) {
78
+ /**
79
+ * Compare two semver versions by their base (major.minor.patch) components.
80
+ * Strips any prerelease/build metadata before comparing.
81
+ * Returns -1 if a < b, 0 if equal, 1 if a > b.
82
+ */
83
+ function compareBaseVersions(a, b) {
84
+ const strip = (v) => v.replace(/[-+].*$/, "");
85
+ const pa = strip(a).split(".").map(Number);
86
+ const pb = strip(b).split(".").map(Number);
87
+ for (let i = 0; i < 3; i++) {
88
+ const va = pa[i] ?? 0;
89
+ const vb = pb[i] ?? 0;
90
+ if (va < vb)
91
+ return -1;
92
+ if (va > vb)
93
+ return 1;
94
+ }
95
+ return 0;
96
+ }
97
+ /** Returns true when the current CLI is a dev build. */
98
+ function isDevBuild() {
99
+ return version_1.DEV_BUILD;
100
+ }
101
+ function syncExtension(dataDir, options) {
76
102
  const bundledDir = resolveBundledExtensionDir();
77
103
  const installedDir = resolveInstalledExtensionDir(dataDir);
78
104
  const bundledVersion = readExtensionVersion(bundledDir);
79
105
  const installedVersion = readExtensionVersion(installedDir);
106
+ if (!options?.force) {
107
+ // Dev builds never overwrite installed files
108
+ if (isDevBuild()) {
109
+ return { synced: false, bundledVersion, installedVersion, extensionDir: installedDir };
110
+ }
111
+ // Downgrade protection: don't replace a newer installed version
112
+ if (bundledVersion && installedVersion && compareBaseVersions(bundledVersion, installedVersion) < 0) {
113
+ return { synced: false, bundledVersion, installedVersion, extensionDir: installedDir };
114
+ }
115
+ }
80
116
  const needsCopy = !node_fs_1.default.existsSync(installedDir) || bundledVersion !== installedVersion;
81
117
  if (needsCopy) {
82
118
  node_fs_1.default.mkdirSync(installedDir, { recursive: true });
@@ -89,11 +125,21 @@ function syncExtension(dataDir) {
89
125
  extensionDir: installedDir,
90
126
  };
91
127
  }
92
- function syncHost(dataDir) {
128
+ function syncHost(dataDir, options) {
93
129
  const bundledPath = resolveBundledHostPath();
94
130
  const installedPath = resolveInstalledHostPath(dataDir);
95
131
  const bundledVersion = readHostVersion(bundledPath);
96
132
  const installedVersion = readHostVersion(installedPath);
133
+ if (!options?.force) {
134
+ // Dev builds never overwrite installed files
135
+ if (isDevBuild()) {
136
+ return { synced: false, bundledVersion, installedVersion, hostPath: installedPath };
137
+ }
138
+ // Downgrade protection: don't replace a newer installed version
139
+ if (bundledVersion && installedVersion && compareBaseVersions(bundledVersion, installedVersion) < 0) {
140
+ return { synced: false, bundledVersion, installedVersion, hostPath: installedPath };
141
+ }
142
+ }
97
143
  const needsCopy = !node_fs_1.default.existsSync(installedPath) || bundledVersion !== installedVersion;
98
144
  if (needsCopy) {
99
145
  node_fs_1.default.mkdirSync(node_path_1.default.dirname(installedPath), { recursive: true });
@@ -1,7 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.DIRTY = exports.GIT_SHA = exports.VERSION = exports.BASE_VERSION = void 0;
4
- exports.BASE_VERSION = "0.4.0";
5
- exports.VERSION = "0.4.0";
6
- exports.GIT_SHA = null;
3
+ exports.DEV_BUILD = exports.DIRTY = exports.GIT_SHA = exports.VERSION = exports.BASE_VERSION = void 0;
4
+ exports.BASE_VERSION = "0.5.2";
5
+ exports.VERSION = "0.5.2";
6
+ exports.GIT_SHA = "c90b26ba";
7
7
  exports.DIRTY = false;
8
+ exports.DEV_BUILD = false;
@@ -0,0 +1,132 @@
1
+ "use strict";
2
+ /**
3
+ * Wrapper health checks — parse, validate, and repair profile wrapper scripts.
4
+ *
5
+ * Wrappers are generated by `tabctl setup` and launch the native messaging host.
6
+ * They hardcode absolute paths to Node and the host bundle, which can break when
7
+ * Node is upgraded (e.g., via mise/nvm). This module detects and fixes stale paths.
8
+ */
9
+ var __importDefault = (this && this.__importDefault) || function (mod) {
10
+ return (mod && mod.__esModule) ? mod : { "default": mod };
11
+ };
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.parseWrapper = parseWrapper;
14
+ exports.checkWrapper = checkWrapper;
15
+ exports.resolveWrapperPath = resolveWrapperPath;
16
+ exports.resolveWrapperTextPath = resolveWrapperTextPath;
17
+ const node_fs_1 = __importDefault(require("node:fs"));
18
+ const node_path_1 = __importDefault(require("node:path"));
19
+ // Unix: exec "nodePath" "hostPath"
20
+ const SH_EXEC_RE = /^exec\s+"([^"]+)"\s+"([^"]+)"$/m;
21
+ // Unix: export TABCTL_PROFILE="name"
22
+ const SH_PROFILE_RE = /^export\s+TABCTL_PROFILE="([^"]+)"$/m;
23
+ // Windows .cmd: "nodePath" "hostPath" %*
24
+ const CMD_EXEC_RE = /^"([^"]+)"\s+"([^"]+)"\s+%\*$/m;
25
+ // Windows .cmd: set TABCTL_PROFILE=name
26
+ const CMD_PROFILE_RE = /^set\s+TABCTL_PROFILE=(.+)$/m;
27
+ // Windows .cfg (Go launcher): line 1 = node, line 2 = host, optional TABCTL_PROFILE=name
28
+ const CFG_PROFILE_RE = /^TABCTL_PROFILE=(.+)$/m;
29
+ /**
30
+ * Parse a wrapper script to extract Node path, host path, and profile name.
31
+ * Supports .sh (Unix), .cmd (Windows fallback), and .cfg (Go launcher) formats.
32
+ */
33
+ function parseWrapper(wrapperPath) {
34
+ try {
35
+ const ext = node_path_1.default.extname(wrapperPath).toLowerCase();
36
+ // .cfg: Go launcher config (host-launcher.cfg next to .exe)
37
+ if (ext === ".cfg") {
38
+ const content = node_fs_1.default.readFileSync(wrapperPath, "utf-8");
39
+ const lines = content.split(/\r?\n/).filter(l => l.trim());
40
+ if (lines.length < 2)
41
+ return null;
42
+ const profileMatch = content.match(CFG_PROFILE_RE);
43
+ return {
44
+ nodePath: lines[0].trim(),
45
+ hostPath: lines[1].trim(),
46
+ profileName: profileMatch ? profileMatch[1].trim() : null,
47
+ };
48
+ }
49
+ // .exe: look for host-launcher.cfg in the same directory
50
+ if (ext === ".exe") {
51
+ const cfgPath = node_path_1.default.join(node_path_1.default.dirname(wrapperPath), "host-launcher.cfg");
52
+ if (node_fs_1.default.existsSync(cfgPath)) {
53
+ return parseWrapper(cfgPath);
54
+ }
55
+ return null;
56
+ }
57
+ const content = node_fs_1.default.readFileSync(wrapperPath, "utf-8");
58
+ // .cmd: Windows batch
59
+ if (ext === ".cmd") {
60
+ const execMatch = content.match(CMD_EXEC_RE);
61
+ if (!execMatch)
62
+ return null;
63
+ const profileMatch = content.match(CMD_PROFILE_RE);
64
+ return {
65
+ nodePath: execMatch[1],
66
+ hostPath: execMatch[2],
67
+ profileName: profileMatch ? profileMatch[1].trim() : null,
68
+ };
69
+ }
70
+ // .sh or extensionless: Unix shell
71
+ const execMatch = content.match(SH_EXEC_RE);
72
+ if (!execMatch)
73
+ return null;
74
+ const profileMatch = content.match(SH_PROFILE_RE);
75
+ return {
76
+ nodePath: execMatch[1],
77
+ hostPath: execMatch[2],
78
+ profileName: profileMatch ? profileMatch[1] : null,
79
+ };
80
+ }
81
+ catch {
82
+ return null;
83
+ }
84
+ }
85
+ /**
86
+ * Check wrapper health: parse it and verify all referenced paths exist.
87
+ */
88
+ function checkWrapper(wrapperPath) {
89
+ const issues = [];
90
+ if (!node_fs_1.default.existsSync(wrapperPath)) {
91
+ return { ok: false, wrapperPath, info: null, issues: [`Wrapper not found: ${wrapperPath}`] };
92
+ }
93
+ const info = parseWrapper(wrapperPath);
94
+ if (!info) {
95
+ return { ok: false, wrapperPath, info: null, issues: [`Could not parse wrapper: ${wrapperPath}`] };
96
+ }
97
+ if (!node_fs_1.default.existsSync(info.nodePath)) {
98
+ issues.push(`Node path not found: ${info.nodePath}`);
99
+ }
100
+ if (!node_fs_1.default.existsSync(info.hostPath)) {
101
+ issues.push(`Host path not found: ${info.hostPath}`);
102
+ }
103
+ return { ok: issues.length === 0, wrapperPath, info, issues };
104
+ }
105
+ /**
106
+ * Resolve the wrapper path for a profile data directory.
107
+ * Returns the path (may not exist yet).
108
+ */
109
+ function resolveWrapperPath(profileDataDir) {
110
+ if (process.platform === "win32") {
111
+ const exePath = node_path_1.default.join(profileDataDir, "tabctl-host.exe");
112
+ if (node_fs_1.default.existsSync(exePath))
113
+ return exePath;
114
+ const cmdPath = node_path_1.default.join(profileDataDir, "tabctl-host.cmd");
115
+ if (node_fs_1.default.existsSync(cmdPath))
116
+ return cmdPath;
117
+ return exePath; // default to .exe
118
+ }
119
+ return node_path_1.default.join(profileDataDir, "tabctl-host.sh");
120
+ }
121
+ /**
122
+ * Resolve the text-editable config path for a wrapper.
123
+ * For .exe wrappers, this is the adjacent host-launcher.cfg.
124
+ * For .sh/.cmd, it's the wrapper itself.
125
+ */
126
+ function resolveWrapperTextPath(wrapperPath) {
127
+ const ext = node_path_1.default.extname(wrapperPath).toLowerCase();
128
+ if (ext === ".exe") {
129
+ return node_path_1.default.join(node_path_1.default.dirname(wrapperPath), "host-launcher.cfg");
130
+ }
131
+ return wrapperPath;
132
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tabctl",
3
- "version": "0.4.0",
3
+ "version": "0.5.2",
4
4
  "description": "CLI tool to manage and analyze browser tabs",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -31,7 +31,7 @@
31
31
  "bump:major": "node scripts/bump-version.js major",
32
32
  "bump:minor": "node scripts/bump-version.js minor",
33
33
  "bump:patch": "node scripts/bump-version.js patch",
34
- "test": "npm run build && node --test --test-timeout=5000 dist/tests/unit/*.js",
34
+ "test": "npm run build && node --test --test-timeout=5000 dist/tests/unit/*.js && node dist/scripts/integration-test.js",
35
35
  "test:unit": "npm run build && node --test --test-timeout=5000 dist/tests/unit/*.js",
36
36
  "test:integration": "node dist/scripts/integration-test.js",
37
37
  "clean": "node -e \"fs.rmSync('dist',{recursive:true,force:true})\" ",