@thinkrun/cli 0.1.27

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 (183) hide show
  1. package/README.md +349 -0
  2. package/dist/bin/thinkrun.d.ts +6 -0
  3. package/dist/bin/thinkrun.d.ts.map +1 -0
  4. package/dist/bin/thinkrun.js +124 -0
  5. package/dist/bin/thinkrun.js.map +1 -0
  6. package/dist/scripts/browse.sh +1107 -0
  7. package/dist/src/adapters/cloud.d.ts +79 -0
  8. package/dist/src/adapters/cloud.d.ts.map +1 -0
  9. package/dist/src/adapters/cloud.js +637 -0
  10. package/dist/src/adapters/cloud.js.map +1 -0
  11. package/dist/src/adapters/index.d.ts +47 -0
  12. package/dist/src/adapters/index.d.ts.map +1 -0
  13. package/dist/src/adapters/index.js +211 -0
  14. package/dist/src/adapters/index.js.map +1 -0
  15. package/dist/src/adapters/local-command-retry.d.ts +12 -0
  16. package/dist/src/adapters/local-command-retry.d.ts.map +1 -0
  17. package/dist/src/adapters/local-command-retry.js +224 -0
  18. package/dist/src/adapters/local-command-retry.js.map +1 -0
  19. package/dist/src/adapters/local.d.ts +136 -0
  20. package/dist/src/adapters/local.d.ts.map +1 -0
  21. package/dist/src/adapters/local.js +1273 -0
  22. package/dist/src/adapters/local.js.map +1 -0
  23. package/dist/src/adapters/types.d.ts +45 -0
  24. package/dist/src/adapters/types.d.ts.map +1 -0
  25. package/dist/src/adapters/types.js +6 -0
  26. package/dist/src/adapters/types.js.map +1 -0
  27. package/dist/src/commands/actions.d.ts +135 -0
  28. package/dist/src/commands/actions.d.ts.map +1 -0
  29. package/dist/src/commands/actions.js +2207 -0
  30. package/dist/src/commands/actions.js.map +1 -0
  31. package/dist/src/commands/agent-init.d.ts +16 -0
  32. package/dist/src/commands/agent-init.d.ts.map +1 -0
  33. package/dist/src/commands/agent-init.js +222 -0
  34. package/dist/src/commands/agent-init.js.map +1 -0
  35. package/dist/src/commands/analyze.d.ts +11 -0
  36. package/dist/src/commands/analyze.d.ts.map +1 -0
  37. package/dist/src/commands/analyze.js +238 -0
  38. package/dist/src/commands/analyze.js.map +1 -0
  39. package/dist/src/commands/cache.d.ts +6 -0
  40. package/dist/src/commands/cache.d.ts.map +1 -0
  41. package/dist/src/commands/cache.js +147 -0
  42. package/dist/src/commands/cache.js.map +1 -0
  43. package/dist/src/commands/cloud.d.ts +6 -0
  44. package/dist/src/commands/cloud.d.ts.map +1 -0
  45. package/dist/src/commands/cloud.js +332 -0
  46. package/dist/src/commands/cloud.js.map +1 -0
  47. package/dist/src/commands/config.d.ts +7 -0
  48. package/dist/src/commands/config.d.ts.map +1 -0
  49. package/dist/src/commands/config.js +208 -0
  50. package/dist/src/commands/config.js.map +1 -0
  51. package/dist/src/commands/doctor.d.ts +127 -0
  52. package/dist/src/commands/doctor.d.ts.map +1 -0
  53. package/dist/src/commands/doctor.js +684 -0
  54. package/dist/src/commands/doctor.js.map +1 -0
  55. package/dist/src/commands/evaluate-helpers.d.ts +6 -0
  56. package/dist/src/commands/evaluate-helpers.d.ts.map +1 -0
  57. package/dist/src/commands/evaluate-helpers.js +13 -0
  58. package/dist/src/commands/evaluate-helpers.js.map +1 -0
  59. package/dist/src/commands/install.d.ts +118 -0
  60. package/dist/src/commands/install.d.ts.map +1 -0
  61. package/dist/src/commands/install.js +975 -0
  62. package/dist/src/commands/install.js.map +1 -0
  63. package/dist/src/commands/release.d.ts +7 -0
  64. package/dist/src/commands/release.d.ts.map +1 -0
  65. package/dist/src/commands/release.js +123 -0
  66. package/dist/src/commands/release.js.map +1 -0
  67. package/dist/src/commands/reset-connection.d.ts +17 -0
  68. package/dist/src/commands/reset-connection.d.ts.map +1 -0
  69. package/dist/src/commands/reset-connection.js +141 -0
  70. package/dist/src/commands/reset-connection.js.map +1 -0
  71. package/dist/src/commands/session-debug.d.ts +23 -0
  72. package/dist/src/commands/session-debug.d.ts.map +1 -0
  73. package/dist/src/commands/session-debug.js +267 -0
  74. package/dist/src/commands/session-debug.js.map +1 -0
  75. package/dist/src/commands/setup.d.ts +53 -0
  76. package/dist/src/commands/setup.d.ts.map +1 -0
  77. package/dist/src/commands/setup.js +249 -0
  78. package/dist/src/commands/setup.js.map +1 -0
  79. package/dist/src/config/store.d.ts +39 -0
  80. package/dist/src/config/store.d.ts.map +1 -0
  81. package/dist/src/config/store.js +290 -0
  82. package/dist/src/config/store.js.map +1 -0
  83. package/dist/src/daemon/access.d.ts +53 -0
  84. package/dist/src/daemon/access.d.ts.map +1 -0
  85. package/dist/src/daemon/access.js +87 -0
  86. package/dist/src/daemon/access.js.map +1 -0
  87. package/dist/src/daemon/bridge-envelope.d.ts +96 -0
  88. package/dist/src/daemon/bridge-envelope.d.ts.map +1 -0
  89. package/dist/src/daemon/bridge-envelope.js +235 -0
  90. package/dist/src/daemon/bridge-envelope.js.map +1 -0
  91. package/dist/src/daemon/utils.d.ts +43 -0
  92. package/dist/src/daemon/utils.d.ts.map +1 -0
  93. package/dist/src/daemon/utils.js +134 -0
  94. package/dist/src/daemon/utils.js.map +1 -0
  95. package/dist/src/errors.d.ts +60 -0
  96. package/dist/src/errors.d.ts.map +1 -0
  97. package/dist/src/errors.js +87 -0
  98. package/dist/src/errors.js.map +1 -0
  99. package/dist/src/local-bridge-timing.d.ts +31 -0
  100. package/dist/src/local-bridge-timing.d.ts.map +1 -0
  101. package/dist/src/local-bridge-timing.js +41 -0
  102. package/dist/src/local-bridge-timing.js.map +1 -0
  103. package/dist/src/obstacle-recovery/classify-script.d.ts +16 -0
  104. package/dist/src/obstacle-recovery/classify-script.d.ts.map +1 -0
  105. package/dist/src/obstacle-recovery/classify-script.js +53 -0
  106. package/dist/src/obstacle-recovery/classify-script.js.map +1 -0
  107. package/dist/src/obstacle-recovery/obstacle-classifier.d.ts +21 -0
  108. package/dist/src/obstacle-recovery/obstacle-classifier.d.ts.map +1 -0
  109. package/dist/src/obstacle-recovery/obstacle-classifier.js +37 -0
  110. package/dist/src/obstacle-recovery/obstacle-classifier.js.map +1 -0
  111. package/dist/src/obstacle-recovery/state-fingerprint.d.ts +26 -0
  112. package/dist/src/obstacle-recovery/state-fingerprint.d.ts.map +1 -0
  113. package/dist/src/obstacle-recovery/state-fingerprint.js +85 -0
  114. package/dist/src/obstacle-recovery/state-fingerprint.js.map +1 -0
  115. package/dist/src/obstacle-recovery/types.d.ts +44 -0
  116. package/dist/src/obstacle-recovery/types.d.ts.map +1 -0
  117. package/dist/src/obstacle-recovery/types.js +16 -0
  118. package/dist/src/obstacle-recovery/types.js.map +1 -0
  119. package/dist/src/output/formatter.d.ts +55 -0
  120. package/dist/src/output/formatter.d.ts.map +1 -0
  121. package/dist/src/output/formatter.js +55 -0
  122. package/dist/src/output/formatter.js.map +1 -0
  123. package/dist/src/output/mode.d.ts +11 -0
  124. package/dist/src/output/mode.d.ts.map +1 -0
  125. package/dist/src/output/mode.js +16 -0
  126. package/dist/src/output/mode.js.map +1 -0
  127. package/dist/src/protected-flow/detector.d.ts +26 -0
  128. package/dist/src/protected-flow/detector.d.ts.map +1 -0
  129. package/dist/src/protected-flow/detector.js +75 -0
  130. package/dist/src/protected-flow/detector.js.map +1 -0
  131. package/dist/src/protected-flow/types.d.ts +24 -0
  132. package/dist/src/protected-flow/types.d.ts.map +1 -0
  133. package/dist/src/protected-flow/types.js +28 -0
  134. package/dist/src/protected-flow/types.js.map +1 -0
  135. package/dist/src/session/agent-identity.d.ts +65 -0
  136. package/dist/src/session/agent-identity.d.ts.map +1 -0
  137. package/dist/src/session/agent-identity.js +133 -0
  138. package/dist/src/session/agent-identity.js.map +1 -0
  139. package/dist/src/session/cli-session-sync.d.ts +72 -0
  140. package/dist/src/session/cli-session-sync.d.ts.map +1 -0
  141. package/dist/src/session/cli-session-sync.js +244 -0
  142. package/dist/src/session/cli-session-sync.js.map +1 -0
  143. package/dist/src/session/context.d.ts +24 -0
  144. package/dist/src/session/context.d.ts.map +1 -0
  145. package/dist/src/session/context.js +165 -0
  146. package/dist/src/session/context.js.map +1 -0
  147. package/dist/src/session/continuity.d.ts +33 -0
  148. package/dist/src/session/continuity.d.ts.map +1 -0
  149. package/dist/src/session/continuity.js +179 -0
  150. package/dist/src/session/continuity.js.map +1 -0
  151. package/dist/src/session/errors.d.ts +9 -0
  152. package/dist/src/session/errors.d.ts.map +1 -0
  153. package/dist/src/session/errors.js +31 -0
  154. package/dist/src/session/errors.js.map +1 -0
  155. package/dist/src/session/local-continuity.d.ts +16 -0
  156. package/dist/src/session/local-continuity.d.ts.map +1 -0
  157. package/dist/src/session/local-continuity.js +146 -0
  158. package/dist/src/session/local-continuity.js.map +1 -0
  159. package/dist/src/session/signal-handler.d.ts +24 -0
  160. package/dist/src/session/signal-handler.d.ts.map +1 -0
  161. package/dist/src/session/signal-handler.js +35 -0
  162. package/dist/src/session/signal-handler.js.map +1 -0
  163. package/dist/src/shared/local-recovery-policy.d.ts +40 -0
  164. package/dist/src/shared/local-recovery-policy.d.ts.map +1 -0
  165. package/dist/src/shared/local-recovery-policy.js +59 -0
  166. package/dist/src/shared/local-recovery-policy.js.map +1 -0
  167. package/dist/src/shared/recovery-state.d.ts +3 -0
  168. package/dist/src/shared/recovery-state.d.ts.map +1 -0
  169. package/dist/src/shared/recovery-state.js +9 -0
  170. package/dist/src/shared/recovery-state.js.map +1 -0
  171. package/dist/src/types.d.ts +131 -0
  172. package/dist/src/types.d.ts.map +1 -0
  173. package/dist/src/types.js +5 -0
  174. package/dist/src/types.js.map +1 -0
  175. package/dist/src/utils.d.ts +50 -0
  176. package/dist/src/utils.d.ts.map +1 -0
  177. package/dist/src/utils.js +147 -0
  178. package/dist/src/utils.js.map +1 -0
  179. package/dist/src/working-location.d.ts +107 -0
  180. package/dist/src/working-location.d.ts.map +1 -0
  181. package/dist/src/working-location.js +651 -0
  182. package/dist/src/working-location.js.map +1 -0
  183. package/package.json +65 -0
@@ -0,0 +1,975 @@
1
+ /**
2
+ * `thinkrun install` — downloads, verifies, and registers the native host binary.
3
+ *
4
+ * Downloads, verifies, and registers the native host binary with better UX:
5
+ * progress bar, coloured output, --force and --check flags.
6
+ */
7
+ import { Command } from 'commander';
8
+ import chalk from 'chalk';
9
+ import { statSync, createWriteStream, chmodSync, existsSync, mkdirSync, renameSync, unlinkSync, readFileSync, } from 'fs';
10
+ import { join, dirname } from 'path';
11
+ import { fileURLToPath } from 'url';
12
+ import { homedir } from 'os';
13
+ import { pipeline } from 'stream/promises';
14
+ import { get as httpGet } from 'http';
15
+ import { get as httpsGet } from 'https';
16
+ import { createHash } from 'crypto';
17
+ import { spawnSync } from 'child_process';
18
+ import { config } from '../config/store.js';
19
+ // ---------------------------------------------------------------------------
20
+ // Constants
21
+ // ---------------------------------------------------------------------------
22
+ const __dirname = dirname(fileURLToPath(import.meta.url));
23
+ function resolvePackageJsonPath() {
24
+ const candidates = [
25
+ // Bun source execution: packages/cli/src/commands -> packages/cli/package.json
26
+ join(__dirname, '..', '..', 'package.json'),
27
+ // Built package: packages/cli/dist/src/commands -> packages/cli/package.json
28
+ join(__dirname, '..', '..', '..', 'package.json'),
29
+ ];
30
+ const found = candidates.find((candidate) => existsSync(candidate));
31
+ if (found)
32
+ return found;
33
+ return candidates[0];
34
+ }
35
+ const PKG_JSON_PATH = resolvePackageJsonPath();
36
+ const REPO = 'dundas/thinkbrowse';
37
+ // Keep the installed native-host binary under the legacy directory during the
38
+ // internal pass. The host ID, binary name, release assets, and local install
39
+ // path remain frozen at ~/.thinkbrowse/bin even though the public CLI surface
40
+ // is now `thinkrun`.
41
+ const INSTALL_DIR = join(homedir(), '.thinkbrowse', 'bin');
42
+ const BINARY_NAME = 'thinkbrowse-host';
43
+ export const DEFAULT_EXTENSION_ID = 'cjjiphmogljacdphamjhfinpmejlhfbn';
44
+ const EXTENSION_ID_ENV = 'THINKRUN_EXTENSION_ID';
45
+ const LEGACY_EXTENSION_ID_ENV = 'THINKBROWSE_EXTENSION_ID';
46
+ const MAX_REDIRECTS = 10;
47
+ const DOWNLOAD_TIMEOUT_MS = 60_000;
48
+ const DOWNLOAD_BASE_URL_OVERRIDE_ENV = 'THINKRUN_INSTALL_BASE_URL';
49
+ const LEGACY_INSTALL_BASE_URL_OVERRIDE_ENV = 'THINKBROWSE_INSTALL_BASE_URL';
50
+ const LEGACY_DOWNLOAD_BASE_URL_OVERRIDE_ENV = 'THINKBROWSE_NATIVE_HOST_BASE_URL';
51
+ const ENABLE_TEST_DOWNLOAD_BASE_URL_ENV = 'THINKRUN_ENABLE_TEST_NATIVE_HOST_BASE_URL';
52
+ const LEGACY_ENABLE_TEST_DOWNLOAD_BASE_URL_ENV = 'THINKBROWSE_ENABLE_TEST_NATIVE_HOST_BASE_URL';
53
+ const LOOPBACK_DOWNLOAD_OVERRIDE_ENV = 'THINKRUN_INSTALL_ALLOW_LOOPBACK_HTTP';
54
+ const LEGACY_LOOPBACK_DOWNLOAD_OVERRIDE_ENV = 'THINKBROWSE_INSTALL_ALLOW_LOOPBACK_HTTP';
55
+ const SAFE_PROFILE_MARKER = /^[A-Za-z0-9 ._-]+$/;
56
+ function markerPath(baseDir, marker) {
57
+ const separator = baseDir.includes('\\') ? '\\' : '/';
58
+ return `${baseDir}${separator}${marker}`;
59
+ }
60
+ export function shouldClearPackagedDefaultPreference(source) {
61
+ return source !== 'registered';
62
+ }
63
+ export function isValidExtensionId(extensionId) {
64
+ return typeof extensionId === 'string' && /^[a-p]{32}$/.test(extensionId);
65
+ }
66
+ function isLoopbackHostname(hostname) {
67
+ return hostname === '127.0.0.1' || hostname === 'localhost' || hostname === '::1' || hostname === '[::1]';
68
+ }
69
+ function isLegacyLoopbackDownloadOverrideEnabled() {
70
+ return process.env[LEGACY_ENABLE_TEST_DOWNLOAD_BASE_URL_ENV] === '1'
71
+ && typeof process.env[LEGACY_DOWNLOAD_BASE_URL_OVERRIDE_ENV] === 'string'
72
+ && process.env[LEGACY_DOWNLOAD_BASE_URL_OVERRIDE_ENV].trim().length > 0;
73
+ }
74
+ function isLoopbackDownloadOverrideEnabled() {
75
+ return process.env[ENABLE_TEST_DOWNLOAD_BASE_URL_ENV] === '1'
76
+ && (typeof process.env[DOWNLOAD_BASE_URL_OVERRIDE_ENV] === 'string'
77
+ || typeof process.env[LEGACY_INSTALL_BASE_URL_OVERRIDE_ENV] === 'string');
78
+ }
79
+ function isLoopbackHttpDownloadAllowed() {
80
+ return process.env[LOOPBACK_DOWNLOAD_OVERRIDE_ENV] === '1'
81
+ || process.env[LEGACY_LOOPBACK_DOWNLOAD_OVERRIDE_ENV] === '1'
82
+ || isLoopbackDownloadOverrideEnabled()
83
+ || isLegacyLoopbackDownloadOverrideEnabled();
84
+ }
85
+ export function resolveExtensionIdFromSources(opts) {
86
+ if (isValidExtensionId(opts.extensionId)) {
87
+ return { extensionId: opts.extensionId, source: 'flag' };
88
+ }
89
+ if (isValidExtensionId(opts.envExtensionId)) {
90
+ return { extensionId: opts.envExtensionId, source: 'env' };
91
+ }
92
+ if (isValidExtensionId(opts.configuredExtensionId)) {
93
+ return { extensionId: opts.configuredExtensionId, source: 'config' };
94
+ }
95
+ return { extensionId: DEFAULT_EXTENSION_ID, source: 'default' };
96
+ }
97
+ export function resolveExtensionId(extensionId) {
98
+ return resolveExtensionIdFromSources({
99
+ extensionId,
100
+ envExtensionId: process.env[EXTENSION_ID_ENV] || process.env[LEGACY_EXTENSION_ID_ENV],
101
+ configuredExtensionId: config.get('extensionId'),
102
+ });
103
+ }
104
+ export function resolveRegistrationExtensionIdFromSources(opts) {
105
+ const resolved = resolveExtensionIdFromSources(opts);
106
+ if (resolved.source !== 'default') {
107
+ return resolved;
108
+ }
109
+ if (opts.preferPackagedDefaultExtensionId) {
110
+ return resolved;
111
+ }
112
+ const registeredExtensionId = isValidExtensionId(opts.registeredExtensionId)
113
+ ? opts.registeredExtensionId
114
+ : undefined;
115
+ const distinctManifestExtensionIds = Array.isArray(opts.manifestExtensionIds)
116
+ ? Array.from(new Set(opts.manifestExtensionIds.filter((value) => isValidExtensionId(value))))
117
+ : [];
118
+ if (opts.manifestParseStatus === 'parsed') {
119
+ if (distinctManifestExtensionIds.length === 1) {
120
+ return {
121
+ extensionId: distinctManifestExtensionIds[0],
122
+ source: 'registered',
123
+ };
124
+ }
125
+ if (registeredExtensionId && distinctManifestExtensionIds.includes(registeredExtensionId)) {
126
+ return {
127
+ extensionId: registeredExtensionId,
128
+ source: 'registered',
129
+ };
130
+ }
131
+ return resolved;
132
+ }
133
+ const registrationNotReliablyInspectable = opts.manifestParseStatus === 'parse_error' ||
134
+ opts.manifestExists === null ||
135
+ opts.manifestExists === false ||
136
+ opts.manifestParseStatus === 'unavailable';
137
+ if (registrationNotReliablyInspectable && registeredExtensionId) {
138
+ return {
139
+ extensionId: registeredExtensionId,
140
+ source: 'registered',
141
+ };
142
+ }
143
+ return resolved;
144
+ }
145
+ export function hasAmbiguousManifestExtensionIds(opts) {
146
+ const resolved = resolveExtensionIdFromSources(opts);
147
+ if (resolved.source !== 'default' || opts.preferPackagedDefaultExtensionId) {
148
+ return false;
149
+ }
150
+ const registeredExtensionId = isValidExtensionId(opts.registeredExtensionId)
151
+ ? opts.registeredExtensionId
152
+ : undefined;
153
+ const distinctManifestExtensionIds = Array.isArray(opts.manifestExtensionIds)
154
+ ? Array.from(new Set(opts.manifestExtensionIds.filter((value) => isValidExtensionId(value))))
155
+ : [];
156
+ return opts.manifestParseStatus === 'parsed'
157
+ && distinctManifestExtensionIds.length > 1
158
+ && (!registeredExtensionId || !distinctManifestExtensionIds.includes(registeredExtensionId));
159
+ }
160
+ export function resolveRegistrationExtensionId(extensionId) {
161
+ return resolveRegistrationExtensionIdFromSources({
162
+ extensionId,
163
+ envExtensionId: process.env[EXTENSION_ID_ENV] || process.env[LEGACY_EXTENSION_ID_ENV],
164
+ configuredExtensionId: config.get('extensionId'),
165
+ registeredExtensionId: config.get('registeredExtensionId'),
166
+ preferPackagedDefaultExtensionId: config.get('preferPackagedDefaultExtensionId'),
167
+ manifestExtensionIds: null,
168
+ manifestParseStatus: 'unavailable',
169
+ manifestExists: null,
170
+ });
171
+ }
172
+ function getManifestDetails(manifestPath) {
173
+ if (!manifestPath || manifestPath === '(registry)' || !existsSync(manifestPath)) {
174
+ return {
175
+ manifestAllowedOrigins: null,
176
+ manifestExtensionIds: null,
177
+ manifestParseStatus: 'unavailable',
178
+ manifestParseError: null,
179
+ };
180
+ }
181
+ try {
182
+ const raw = JSON.parse(readFileSync(manifestPath, 'utf8'));
183
+ const allowedOrigins = Array.isArray(raw.allowed_origins)
184
+ ? raw.allowed_origins.filter((value) => typeof value === 'string')
185
+ : [];
186
+ const manifestExtensionIds = allowedOrigins
187
+ .map((origin) => {
188
+ const match = origin.match(/^chrome-extension:\/\/([a-p]{32})\/$/);
189
+ return match?.[1] ?? null;
190
+ })
191
+ .filter((value) => value !== null);
192
+ return {
193
+ manifestAllowedOrigins: allowedOrigins,
194
+ manifestExtensionIds,
195
+ manifestParseStatus: 'parsed',
196
+ manifestParseError: null,
197
+ };
198
+ }
199
+ catch (error) {
200
+ const message = error instanceof Error ? error.message : String(error);
201
+ return {
202
+ manifestAllowedOrigins: null,
203
+ manifestExtensionIds: null,
204
+ manifestParseStatus: 'parse_error',
205
+ manifestParseError: message,
206
+ };
207
+ }
208
+ }
209
+ // ---------------------------------------------------------------------------
210
+ // Platform detection (Rosetta 2 aware)
211
+ // ---------------------------------------------------------------------------
212
+ function getPlatform() {
213
+ const p = process.platform;
214
+ const a = process.arch;
215
+ if (p === 'darwin') {
216
+ // Use sysctl to detect true hardware arch — process.arch/uname return x64
217
+ // under Rosetta 2. The native host binary runs as its own process, so the
218
+ // arm64 build works on Apple Silicon even when Node/Bun is an x64 binary.
219
+ let hwArch = a;
220
+ try {
221
+ const result = spawnSync('sysctl', ['-n', 'hw.optional.arm64'], {
222
+ encoding: 'utf8',
223
+ timeout: 2000,
224
+ });
225
+ if (result.stdout && result.stdout.trim() === '1')
226
+ hwArch = 'arm64';
227
+ }
228
+ catch { /* fall back to process.arch */ }
229
+ return { os: 'mac', arch: hwArch === 'arm64' ? 'arm64' : 'x64', ext: '' };
230
+ }
231
+ if (p === 'linux' && a === 'arm64')
232
+ return { os: 'linux', arch: 'arm64', ext: '' };
233
+ if (p === 'linux')
234
+ return { os: 'linux', arch: 'x64', ext: '' };
235
+ if (p === 'win32')
236
+ return { os: 'windows', arch: 'x64', ext: '.exe' };
237
+ return null;
238
+ }
239
+ // ---------------------------------------------------------------------------
240
+ // URLs
241
+ // ---------------------------------------------------------------------------
242
+ export function buildBaseUrl(version) {
243
+ const override = process.env[DOWNLOAD_BASE_URL_OVERRIDE_ENV]?.trim()
244
+ || process.env[LEGACY_INSTALL_BASE_URL_OVERRIDE_ENV]?.trim();
245
+ const legacyOverride = process.env[LEGACY_DOWNLOAD_BASE_URL_OVERRIDE_ENV]?.trim();
246
+ const legacyEnabled = process.env[ENABLE_TEST_DOWNLOAD_BASE_URL_ENV] === '1'
247
+ || process.env[LEGACY_ENABLE_TEST_DOWNLOAD_BASE_URL_ENV] === '1';
248
+ if (!override && legacyOverride && !legacyEnabled) {
249
+ process.emitWarning(`${LEGACY_DOWNLOAD_BASE_URL_OVERRIDE_ENV} is deprecated and ignored unless ${LEGACY_ENABLE_TEST_DOWNLOAD_BASE_URL_ENV}=1; prefer ${DOWNLOAD_BASE_URL_OVERRIDE_ENV}.`, { code: 'THINKBROWSE_NATIVE_HOST_BASE_URL_IGNORED' });
250
+ }
251
+ const selectedOverride = override || (legacyEnabled ? legacyOverride : undefined);
252
+ if (selectedOverride === legacyOverride && legacyOverride) {
253
+ process.emitWarning(`${LEGACY_DOWNLOAD_BASE_URL_OVERRIDE_ENV} is deprecated; use ${DOWNLOAD_BASE_URL_OVERRIDE_ENV} with ${LOOPBACK_DOWNLOAD_OVERRIDE_ENV}=1 instead.`, { code: 'THINKBROWSE_NATIVE_HOST_BASE_URL_DEPRECATED' });
254
+ }
255
+ if (selectedOverride) {
256
+ const allowLoopbackHttp = process.env[LOOPBACK_DOWNLOAD_OVERRIDE_ENV] === '1'
257
+ || process.env[LEGACY_LOOPBACK_DOWNLOAD_OVERRIDE_ENV] === '1'
258
+ || legacyEnabled;
259
+ const parsed = assertSecureDownloadUrl(selectedOverride, { allowLoopbackHttp });
260
+ const isLoopbackHost = isLoopbackHostname(parsed.hostname);
261
+ const requiresHttpBypass = parsed.protocol === 'http:';
262
+ if (!isLoopbackHost || (requiresHttpBypass && !allowLoopbackHttp)) {
263
+ throw new Error(`${DOWNLOAD_BASE_URL_OVERRIDE_ENV} is only allowed for explicit loopback fixture installs when ${LOOPBACK_DOWNLOAD_OVERRIDE_ENV}=1`);
264
+ }
265
+ return parsed.toString().replace(/\/$/, '');
266
+ }
267
+ return `https://github.com/${REPO}/releases/download/native-host%2Fv${version}`;
268
+ }
269
+ // ---------------------------------------------------------------------------
270
+ // HTTP helpers (Node built-ins only — no fetch)
271
+ // ---------------------------------------------------------------------------
272
+ export function assertSecureDownloadUrl(url, opts) {
273
+ const parsed = new URL(url);
274
+ const isLoopbackHost = isLoopbackHostname(parsed.hostname);
275
+ const allowLoopbackHttp = opts?.allowLoopbackHttp ?? isLoopbackHttpDownloadAllowed();
276
+ if (parsed.protocol === 'http:' && isLoopbackHost && allowLoopbackHttp) {
277
+ return parsed;
278
+ }
279
+ if (parsed.protocol !== 'https:') {
280
+ throw new Error(`Unsupported protocol: ${parsed.protocol}`);
281
+ }
282
+ return parsed;
283
+ }
284
+ export function downloadToFile(url, dest, onProgress) {
285
+ return new Promise((resolve, reject) => {
286
+ function follow(u, depth) {
287
+ if (depth > MAX_REDIRECTS) {
288
+ reject(new Error('Too many redirects'));
289
+ return;
290
+ }
291
+ let parsedUrl;
292
+ try {
293
+ parsedUrl = assertSecureDownloadUrl(u, { allowLoopbackHttp: isLoopbackHttpDownloadAllowed() });
294
+ }
295
+ catch (error) {
296
+ reject(error);
297
+ return;
298
+ }
299
+ const getter = parsedUrl.protocol === 'http:' ? httpGet : httpsGet;
300
+ const req = getter(parsedUrl, { timeout: DOWNLOAD_TIMEOUT_MS }, (res) => {
301
+ if (res.statusCode === 301 || res.statusCode === 302) {
302
+ res.resume(); // drain body so socket can be reused
303
+ const location = res.headers.location ?? '';
304
+ let parsed;
305
+ try {
306
+ parsed = new URL(location);
307
+ }
308
+ catch {
309
+ try {
310
+ parsed = new URL(location, parsedUrl);
311
+ }
312
+ catch {
313
+ reject(new Error(`Invalid redirect location: ${location}`));
314
+ return;
315
+ }
316
+ }
317
+ follow(parsed.toString(), depth + 1);
318
+ return;
319
+ }
320
+ if (res.statusCode !== 200) {
321
+ res.resume();
322
+ reject(new Error(`HTTP ${res.statusCode} for ${u}`));
323
+ return;
324
+ }
325
+ const total = parseInt(res.headers['content-length'] ?? '0', 10);
326
+ let received = 0;
327
+ if (onProgress) {
328
+ res.on('data', (chunk) => {
329
+ received += chunk.length;
330
+ const pct = total > 0 ? Math.floor((received / total) * 100) : 0;
331
+ onProgress(pct, received, total);
332
+ });
333
+ }
334
+ const out = createWriteStream(dest);
335
+ pipeline(res, out).then(resolve).catch(reject);
336
+ });
337
+ req.on('error', reject);
338
+ req.on('timeout', () => req.destroy(new Error('Download timeout')));
339
+ }
340
+ follow(url, 0);
341
+ });
342
+ }
343
+ export function downloadText(url) {
344
+ return new Promise((resolve, reject) => {
345
+ function follow(u, depth) {
346
+ if (depth > MAX_REDIRECTS) {
347
+ reject(new Error('Too many redirects'));
348
+ return;
349
+ }
350
+ let parsedUrl;
351
+ try {
352
+ parsedUrl = assertSecureDownloadUrl(u, { allowLoopbackHttp: isLoopbackHttpDownloadAllowed() });
353
+ }
354
+ catch (error) {
355
+ reject(error);
356
+ return;
357
+ }
358
+ const getter = parsedUrl.protocol === 'http:' ? httpGet : httpsGet;
359
+ const req = getter(parsedUrl, { timeout: DOWNLOAD_TIMEOUT_MS }, (res) => {
360
+ if (res.statusCode === 301 || res.statusCode === 302) {
361
+ res.resume(); // drain body so socket can be reused
362
+ const location = res.headers.location ?? '';
363
+ let parsed;
364
+ try {
365
+ parsed = new URL(location);
366
+ }
367
+ catch {
368
+ try {
369
+ parsed = new URL(location, parsedUrl);
370
+ }
371
+ catch {
372
+ reject(new Error(`Invalid redirect location: ${location}`));
373
+ return;
374
+ }
375
+ }
376
+ follow(parsed.toString(), depth + 1);
377
+ return;
378
+ }
379
+ if (res.statusCode !== 200) {
380
+ res.resume();
381
+ reject(new Error(`HTTP ${res.statusCode}`));
382
+ return;
383
+ }
384
+ let body = '';
385
+ res.on('data', (chunk) => { body += chunk.toString(); });
386
+ res.on('end', () => resolve(body));
387
+ res.on('error', reject);
388
+ });
389
+ req.on('error', reject);
390
+ req.on('timeout', () => req.destroy(new Error('Download timeout')));
391
+ }
392
+ follow(url, 0);
393
+ });
394
+ }
395
+ // ---------------------------------------------------------------------------
396
+ // Checksum helpers
397
+ // ---------------------------------------------------------------------------
398
+ function sha256file(filePath) {
399
+ return createHash('sha256').update(readFileSync(filePath)).digest('hex');
400
+ }
401
+ function parseChecksum(checksumsTxt, filename) {
402
+ for (const line of checksumsTxt.split('\n')) {
403
+ const parts = line.trim().split(/\s+/);
404
+ if (parts.length === 2 && parts[1].replace(/^\*/, '') === filename)
405
+ return parts[0];
406
+ }
407
+ return null;
408
+ }
409
+ export function canReuseInstalledBinary(opts) {
410
+ return getInstalledBinaryReuseType(opts) !== 'none';
411
+ }
412
+ export function getInstalledBinaryReuseType(opts) {
413
+ if (!opts.isExecutable)
414
+ return 'none';
415
+ if (opts.installedVersion !== opts.expectedVersion)
416
+ return 'none';
417
+ const hasAnyTrustMetadata = typeof opts.storedVersion === 'string' ||
418
+ typeof opts.storedChecksum === 'string';
419
+ const hasTrustedMetadata = opts.storedVersion === opts.expectedVersion &&
420
+ typeof opts.storedChecksum === 'string';
421
+ if (hasAnyTrustMetadata && !hasTrustedMetadata) {
422
+ return 'none';
423
+ }
424
+ if (!hasTrustedMetadata) {
425
+ // Legacy installs from older CLI versions may predate persisted trust metadata.
426
+ // Allow manifest-only repair to reuse a matching executable instead of forcing
427
+ // a network download just to re-register the native host.
428
+ return 'legacy';
429
+ }
430
+ return opts.storedChecksum === opts.currentChecksum ? 'trusted' : 'none';
431
+ }
432
+ function getInstalledBinaryVersion(binaryPath) {
433
+ // `binaryPath` is always the fixed legacy install location selected by
434
+ // installNativeHost/checkNativeHost while the native-host surface is frozen.
435
+ const result = spawnSync(binaryPath, ['--version'], { encoding: 'utf8', timeout: 3000 }); // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process
436
+ if (result.status !== 0) {
437
+ return null;
438
+ }
439
+ return result.stdout?.trim().replace(/^thinkbrowse-host\s+/, '').replace(/^v/, '') || null;
440
+ }
441
+ export function replaceInstalledBinary(downloadPath, destPath) {
442
+ if (process.platform !== 'win32' || !existsSync(destPath)) {
443
+ renameSync(downloadPath, destPath);
444
+ return;
445
+ }
446
+ const backupPath = `${destPath}.bak`;
447
+ if (existsSync(backupPath))
448
+ unlinkSync(backupPath);
449
+ renameSync(destPath, backupPath);
450
+ try {
451
+ renameSync(downloadPath, destPath);
452
+ }
453
+ catch (error) {
454
+ try {
455
+ if (existsSync(backupPath) && !existsSync(destPath)) {
456
+ renameSync(backupPath, destPath);
457
+ }
458
+ }
459
+ catch {
460
+ // Preserve any surviving backup/download artifact for manual recovery.
461
+ }
462
+ throw error;
463
+ }
464
+ if (existsSync(backupPath)) {
465
+ try {
466
+ unlinkSync(backupPath);
467
+ }
468
+ catch { /* ignore best-effort backup cleanup */ }
469
+ }
470
+ }
471
+ // ---------------------------------------------------------------------------
472
+ // Progress bar renderer
473
+ // ---------------------------------------------------------------------------
474
+ function renderProgressBar(pct, received, total, filename) {
475
+ const BAR_WIDTH = 20;
476
+ const filled = Math.floor((pct / 100) * BAR_WIDTH);
477
+ const bar = '='.repeat(filled) + (filled < BAR_WIDTH ? '>' : '') + ' '.repeat(Math.max(0, BAR_WIDTH - filled - 1));
478
+ const receivedMb = (received / 1_048_576).toFixed(1);
479
+ const totalMb = total > 0 ? ` / ${(total / 1_048_576).toFixed(1)} MB` : '';
480
+ const line = ` Downloading ${filename} [${bar}] ${pct}% (${receivedMb} MB${totalMb})`;
481
+ process.stdout.write(`\r${line}`);
482
+ }
483
+ const DEFAULT_PROFILE_MARKERS = ['Local State', 'Default', 'First Run', 'Last Version'];
484
+ function getManifestTargets() {
485
+ if (process.platform === 'darwin') {
486
+ const appSupport = join(homedir(), 'Library', 'Application Support');
487
+ return [
488
+ { browserName: 'Chrome', detectionDir: join(appSupport, 'Google', 'Chrome'), manifestPath: join(appSupport, 'Google', 'Chrome', 'NativeMessagingHosts', 'com.thinkbrowse.host.json'), installPaths: ['/Applications/Google Chrome.app', join(homedir(), 'Applications', 'Google Chrome.app')], profileMarkers: [...DEFAULT_PROFILE_MARKERS] },
489
+ { browserName: 'Chrome Canary', detectionDir: join(appSupport, 'Google', 'Chrome Canary'), manifestPath: join(appSupport, 'Google', 'Chrome Canary', 'NativeMessagingHosts', 'com.thinkbrowse.host.json'), installPaths: ['/Applications/Google Chrome Canary.app', join(homedir(), 'Applications', 'Google Chrome Canary.app')], profileMarkers: [...DEFAULT_PROFILE_MARKERS] },
490
+ { browserName: 'Chromium', detectionDir: join(appSupport, 'Chromium'), manifestPath: join(appSupport, 'Chromium', 'NativeMessagingHosts', 'com.thinkbrowse.host.json'), installPaths: ['/Applications/Chromium.app', join(homedir(), 'Applications', 'Chromium.app')], profileMarkers: [...DEFAULT_PROFILE_MARKERS] },
491
+ { browserName: 'Brave', detectionDir: join(appSupport, 'BraveSoftware', 'Brave-Browser'), manifestPath: join(appSupport, 'BraveSoftware', 'Brave-Browser', 'NativeMessagingHosts', 'com.thinkbrowse.host.json'), installPaths: ['/Applications/Brave Browser.app', join(homedir(), 'Applications', 'Brave Browser.app')], profileMarkers: [...DEFAULT_PROFILE_MARKERS] },
492
+ { browserName: 'Edge', detectionDir: join(appSupport, 'Microsoft Edge'), manifestPath: join(appSupport, 'Microsoft Edge', 'NativeMessagingHosts', 'com.thinkbrowse.host.json'), installPaths: ['/Applications/Microsoft Edge.app', join(homedir(), 'Applications', 'Microsoft Edge.app')], profileMarkers: [...DEFAULT_PROFILE_MARKERS] },
493
+ { browserName: 'Helium', detectionDir: join(appSupport, 'net.imput.helium'), manifestPath: join(appSupport, 'net.imput.helium', 'NativeMessagingHosts', 'com.thinkbrowse.host.json'), installPaths: ['/Applications/Helium.app', join(homedir(), 'Applications', 'Helium.app')], profileMarkers: [...DEFAULT_PROFILE_MARKERS] },
494
+ { browserName: 'Vivaldi', detectionDir: join(appSupport, 'Vivaldi'), manifestPath: join(appSupport, 'Vivaldi', 'NativeMessagingHosts', 'com.thinkbrowse.host.json'), installPaths: ['/Applications/Vivaldi.app', join(homedir(), 'Applications', 'Vivaldi.app')], profileMarkers: [...DEFAULT_PROFILE_MARKERS] },
495
+ { browserName: 'Opera', detectionDir: join(appSupport, 'com.operasoftware.Opera'), manifestPath: join(appSupport, 'com.operasoftware.Opera', 'NativeMessagingHosts', 'com.thinkbrowse.host.json'), installPaths: ['/Applications/Opera.app', join(homedir(), 'Applications', 'Opera.app')], profileMarkers: [...DEFAULT_PROFILE_MARKERS] },
496
+ ];
497
+ }
498
+ if (process.platform === 'linux') {
499
+ const configDir = join(homedir(), '.config');
500
+ return [
501
+ { browserName: 'Chrome', detectionDir: join(configDir, 'google-chrome'), manifestPath: join(configDir, 'google-chrome', 'NativeMessagingHosts', 'com.thinkbrowse.host.json'), installPaths: ['/usr/bin/google-chrome', '/usr/bin/google-chrome-stable'], profileMarkers: [...DEFAULT_PROFILE_MARKERS] },
502
+ { browserName: 'Chromium', detectionDir: join(configDir, 'chromium'), manifestPath: join(configDir, 'chromium', 'NativeMessagingHosts', 'com.thinkbrowse.host.json'), installPaths: ['/usr/bin/chromium', '/usr/bin/chromium-browser', '/snap/bin/chromium'], profileMarkers: [...DEFAULT_PROFILE_MARKERS] },
503
+ { browserName: 'Brave', detectionDir: join(configDir, 'BraveSoftware', 'Brave-Browser'), manifestPath: join(configDir, 'BraveSoftware', 'Brave-Browser', 'NativeMessagingHosts', 'com.thinkbrowse.host.json'), installPaths: ['/usr/bin/brave-browser', '/opt/brave.com/brave/brave-browser'], profileMarkers: [...DEFAULT_PROFILE_MARKERS] },
504
+ { browserName: 'Edge', detectionDir: join(configDir, 'microsoft-edge'), manifestPath: join(configDir, 'microsoft-edge', 'NativeMessagingHosts', 'com.thinkbrowse.host.json'), installPaths: ['/usr/bin/microsoft-edge', '/opt/microsoft/msedge/msedge'], profileMarkers: [...DEFAULT_PROFILE_MARKERS] },
505
+ { browserName: 'Vivaldi', detectionDir: join(configDir, 'vivaldi'), manifestPath: join(configDir, 'vivaldi', 'NativeMessagingHosts', 'com.thinkbrowse.host.json'), installPaths: ['/usr/bin/vivaldi', '/opt/vivaldi/vivaldi'], profileMarkers: [...DEFAULT_PROFILE_MARKERS] },
506
+ ];
507
+ }
508
+ return [];
509
+ }
510
+ function isBrowserInstalled(target) {
511
+ if (target.installPaths && target.installPaths.length > 0) {
512
+ if (target.installPaths.some((path) => existsSync(path))) {
513
+ return true;
514
+ }
515
+ if (!existsSync(target.detectionDir)) {
516
+ return false;
517
+ }
518
+ const markers = target.profileMarkers ?? [];
519
+ if (markers.length === 0) {
520
+ return true;
521
+ }
522
+ return markers.some((marker) => SAFE_PROFILE_MARKER.test(marker) && existsSync(markerPath(target.detectionDir, marker)));
523
+ }
524
+ return existsSync(target.detectionDir);
525
+ }
526
+ export function inspectNativeHostManifest(targets) {
527
+ if (process.platform === 'win32') {
528
+ return {
529
+ manifestExists: null,
530
+ manifestPath: '(registry)',
531
+ manifestAllowedOrigins: null,
532
+ manifestExtensionIds: null,
533
+ manifestParseStatus: 'unavailable',
534
+ manifestParseError: null,
535
+ manifestEntries: null,
536
+ };
537
+ }
538
+ const manifestTargets = targets ?? getManifestTargets();
539
+ const manifestEntries = manifestTargets.map((target) => {
540
+ const browserDetected = isBrowserInstalled(target);
541
+ const manifestExists = existsSync(target.manifestPath);
542
+ const details = manifestExists ? getManifestDetails(target.manifestPath) : getManifestDetails(null);
543
+ return {
544
+ browserName: target.browserName,
545
+ detectionDir: target.detectionDir,
546
+ manifestPath: target.manifestPath,
547
+ browserDetected,
548
+ manifestExists,
549
+ manifestAllowedOrigins: details.manifestAllowedOrigins,
550
+ manifestExtensionIds: details.manifestExtensionIds,
551
+ manifestParseStatus: details.manifestParseStatus,
552
+ manifestParseError: details.manifestParseError,
553
+ relevant: browserDetected,
554
+ };
555
+ });
556
+ const relevantEntries = manifestEntries.filter((entry) => entry.relevant);
557
+ const relevantExistingEntries = relevantEntries.filter((entry) => entry.manifestExists);
558
+ const parsedEntries = relevantExistingEntries.filter((entry) => entry.manifestParseStatus === 'parsed');
559
+ const parseErrors = relevantExistingEntries.filter((entry) => entry.manifestParseStatus === 'parse_error');
560
+ const manifestExists = relevantEntries.length > 0 && relevantEntries.every((entry) => entry.manifestExists);
561
+ const preferredEntries = relevantEntries.length > 0 ? relevantEntries : manifestEntries;
562
+ const preferredExistingEntries = preferredEntries.filter((entry) => entry.manifestExists);
563
+ const manifestPath = manifestExists
564
+ ? preferredExistingEntries.length === 1
565
+ ? preferredExistingEntries[0].manifestPath
566
+ : `${preferredExistingEntries[0].manifestPath} (+${preferredExistingEntries.length - 1} more)`
567
+ : preferredEntries.find((entry) => !entry.manifestExists)?.manifestPath ??
568
+ preferredEntries[0]?.manifestPath ??
569
+ '(no known manifest path)';
570
+ const allowedOrigins = Array.from(new Set(parsedEntries.flatMap((entry) => entry.manifestAllowedOrigins ?? [])));
571
+ const extensionIds = Array.from(new Set(parsedEntries.flatMap((entry) => entry.manifestExtensionIds ?? [])));
572
+ const manifestParseStatus = parseErrors.length > 0
573
+ ? 'parse_error'
574
+ : parsedEntries.length > 0
575
+ ? 'parsed'
576
+ : 'unavailable';
577
+ const manifestParseError = parseErrors.length > 0
578
+ ? parseErrors.map((entry) => `${entry.manifestPath}: ${entry.manifestParseError ?? 'parse error'}`).join('; ')
579
+ : null;
580
+ return {
581
+ manifestExists,
582
+ manifestPath,
583
+ manifestAllowedOrigins: allowedOrigins.length > 0 ? allowedOrigins : null,
584
+ manifestExtensionIds: extensionIds.length > 0 ? extensionIds : null,
585
+ manifestParseStatus,
586
+ manifestParseError,
587
+ manifestEntries,
588
+ };
589
+ }
590
+ // ---------------------------------------------------------------------------
591
+ // Core logic: check existing installation
592
+ // ---------------------------------------------------------------------------
593
+ export async function checkNativeHost(opts = {}) {
594
+ const pkg = JSON.parse(readFileSync(PKG_JSON_PATH, 'utf8'));
595
+ const expectedVersion = pkg.nativeHostVersion ?? '0.0.0';
596
+ const platform = getPlatform();
597
+ const platformLabel = platform ? `${platform.os}-${platform.arch}` : 'unknown';
598
+ const ext = platform?.ext ?? '';
599
+ const binaryPath = join(INSTALL_DIR, BINARY_NAME + ext);
600
+ const manifestState = inspectNativeHostManifest();
601
+ if (!existsSync(binaryPath)) {
602
+ return {
603
+ binaryExists: false,
604
+ binaryPath,
605
+ installedVersion: null,
606
+ expectedVersion,
607
+ versionMatch: false,
608
+ checksumVerified: null,
609
+ manifestExists: manifestState.manifestExists,
610
+ manifestPath: manifestState.manifestPath,
611
+ platform: platformLabel,
612
+ manifestAllowedOrigins: manifestState.manifestAllowedOrigins,
613
+ manifestExtensionIds: manifestState.manifestExtensionIds,
614
+ manifestParseStatus: manifestState.manifestParseStatus,
615
+ manifestParseError: manifestState.manifestParseError,
616
+ manifestEntries: manifestState.manifestEntries,
617
+ };
618
+ }
619
+ // Get installed version
620
+ let installedVersion = null;
621
+ try {
622
+ const result = spawnSync(binaryPath, ['--version'], { encoding: 'utf8', timeout: 3000 });
623
+ const raw = result.stdout?.trim() ?? '';
624
+ // Output is "thinkbrowse-host 1.0.3" — extract just the version number
625
+ installedVersion = raw.replace(/^thinkbrowse-host\s+/, '').replace(/^v/, '') || null;
626
+ }
627
+ catch { /* ignore */ }
628
+ const versionMatch = installedVersion === expectedVersion;
629
+ // Checksum verify — only performed when explicitly requested (--full / install --check)
630
+ // to avoid a remote round-trip on every `thinkrun doctor` call.
631
+ // When versions differ, verify against the installed version's checksums (not expectedVersion)
632
+ // so we report file integrity, not "does the old binary match the new release".
633
+ let checksumVerified = null;
634
+ if (opts.verifyChecksum && platform) {
635
+ const filename = `thinkbrowse-host-${platform.os}-${platform.arch}${platform.ext}`;
636
+ const versionToVerify = installedVersion ?? expectedVersion;
637
+ try {
638
+ const checksumsUrl = `${buildBaseUrl(versionToVerify)}/checksums.txt`;
639
+ const checksumsTxt = await downloadText(checksumsUrl);
640
+ const expected = parseChecksum(checksumsTxt, filename);
641
+ if (expected) {
642
+ checksumVerified = sha256file(binaryPath) === expected;
643
+ }
644
+ }
645
+ catch {
646
+ checksumVerified = null; /* offline or version not in releases */
647
+ }
648
+ }
649
+ return {
650
+ binaryExists: true,
651
+ binaryPath,
652
+ installedVersion,
653
+ expectedVersion,
654
+ versionMatch,
655
+ checksumVerified,
656
+ manifestExists: manifestState.manifestExists,
657
+ manifestPath: manifestState.manifestPath,
658
+ platform: platformLabel,
659
+ manifestAllowedOrigins: manifestState.manifestAllowedOrigins,
660
+ manifestExtensionIds: manifestState.manifestExtensionIds,
661
+ manifestParseStatus: manifestState.manifestParseStatus,
662
+ manifestParseError: manifestState.manifestParseError,
663
+ manifestEntries: manifestState.manifestEntries,
664
+ };
665
+ }
666
+ // ---------------------------------------------------------------------------
667
+ // Core logic: install
668
+ // ---------------------------------------------------------------------------
669
+ export async function installNativeHost(opts) {
670
+ const pkg = JSON.parse(readFileSync(PKG_JSON_PATH, 'utf8'));
671
+ const VERSION = pkg.nativeHostVersion ?? '0.0.0';
672
+ const platform = getPlatform();
673
+ if (!platform) {
674
+ console.log(chalk.yellow(' Unsupported platform — cannot install native host.'));
675
+ return false;
676
+ }
677
+ const platformLabel = `${platform.os}-${platform.arch}`;
678
+ const filename = `thinkbrowse-host-${platformLabel}${platform.ext}`;
679
+ const dest = join(INSTALL_DIR, BINARY_NAME + platform.ext);
680
+ const downloadDest = `${dest}.download`;
681
+ const resolvedExtensionId = opts.resolvedExtensionId ?? resolveRegistrationExtensionId(opts.extensionId);
682
+ let binaryAlreadyInstalled = false;
683
+ let verifiedBinaryChecksum = null;
684
+ let persistVerifiedChecksum = false;
685
+ let legacyInstalledChecksum = null;
686
+ let binaryUrl = '';
687
+ let checksumsUrl = '';
688
+ console.log(chalk.bold(`Installing for ${platformLabel}`));
689
+ if (resolvedExtensionId.source === 'default') {
690
+ console.log(` ${chalk.yellow('!')} Using packaged extension ID ${resolvedExtensionId.extensionId}.`);
691
+ console.log(` If you loaded an unpacked/dev extension, run ${chalk.cyan('thinkrun config set-extension-id <id>')} first.`);
692
+ }
693
+ // --- Skip check (unless --force) ---
694
+ if (!opts.force && existsSync(dest)) {
695
+ try {
696
+ const storedVersion = config.get('nativeHostVersion');
697
+ const storedChecksum = config.get('nativeHostChecksum');
698
+ const currentChecksum = sha256file(dest);
699
+ const isExecutable = platform.ext === '.exe' ||
700
+ (statSync(dest).mode & 0o111) !== 0;
701
+ const trustedChecksumMatch = storedVersion === VERSION &&
702
+ typeof storedChecksum === 'string' &&
703
+ storedChecksum === currentChecksum;
704
+ if (trustedChecksumMatch) {
705
+ // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process
706
+ // `dest` stays in the frozen ~/.thinkbrowse/bin install location during the internal pass.
707
+ const reuseType = getInstalledBinaryReuseType({
708
+ storedVersion,
709
+ storedChecksum,
710
+ currentChecksum,
711
+ installedVersion: getInstalledBinaryVersion(dest),
712
+ expectedVersion: VERSION,
713
+ isExecutable,
714
+ });
715
+ if (reuseType === 'trusted') {
716
+ console.log(` ${chalk.green('✓')} Already installed at v${VERSION} — skipping download.`);
717
+ console.log(` Re-registering manifest for extension ${chalk.cyan(resolvedExtensionId.extensionId)}.`);
718
+ verifiedBinaryChecksum = currentChecksum;
719
+ persistVerifiedChecksum = true;
720
+ binaryAlreadyInstalled = true;
721
+ }
722
+ }
723
+ else if (!storedVersion && !storedChecksum && isExecutable) {
724
+ legacyInstalledChecksum = currentChecksum;
725
+ }
726
+ }
727
+ catch { /* could not check — proceed with install */ }
728
+ }
729
+ if (!binaryAlreadyInstalled) {
730
+ // --- Ensure install dir ---
731
+ if (!existsSync(INSTALL_DIR))
732
+ mkdirSync(INSTALL_DIR, { recursive: true });
733
+ try {
734
+ const baseUrl = buildBaseUrl(VERSION);
735
+ binaryUrl = `${baseUrl}/${filename}`;
736
+ checksumsUrl = `${baseUrl}/checksums.txt`;
737
+ }
738
+ catch (error) {
739
+ const message = error instanceof Error ? error.message : String(error);
740
+ console.log(` ${chalk.red('✗')} Download configuration invalid: ${message}`);
741
+ console.log(` Clear deprecated override env vars or rerun with a loopback fixture override only.`);
742
+ return false;
743
+ }
744
+ // --- Step 1: Download checksums ---
745
+ let checksumsTxt;
746
+ try {
747
+ checksumsTxt = await downloadText(checksumsUrl);
748
+ }
749
+ catch (err) {
750
+ const msg = err instanceof Error ? err.message : String(err);
751
+ console.log(` ${chalk.red('✗')} Could not fetch checksums: ${msg}`);
752
+ console.log(` Run ${chalk.cyan('thinkrun install --force')} to retry`);
753
+ return false;
754
+ }
755
+ if (!binaryAlreadyInstalled) {
756
+ const expectedHash = parseChecksum(checksumsTxt, filename);
757
+ if (!expectedHash) {
758
+ console.log(` ${chalk.red('✗')} No checksum entry for ${filename}`);
759
+ console.log(` Run ${chalk.cyan('thinkrun install --force')} to retry`);
760
+ return false;
761
+ }
762
+ if (legacyInstalledChecksum && legacyInstalledChecksum === expectedHash) {
763
+ // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process
764
+ // `dest` stays in the frozen ~/.thinkbrowse/bin install location during the internal pass.
765
+ const installedVersion = getInstalledBinaryVersion(dest);
766
+ if (installedVersion === VERSION) {
767
+ console.log(` ${chalk.green('✓')} Existing binary matches the published checksum for v${VERSION}.`);
768
+ console.log(` Re-registering manifest for extension ${chalk.cyan(resolvedExtensionId.extensionId)}.`);
769
+ verifiedBinaryChecksum = legacyInstalledChecksum;
770
+ persistVerifiedChecksum = true;
771
+ binaryAlreadyInstalled = true;
772
+ }
773
+ }
774
+ if (!binaryAlreadyInstalled) {
775
+ // --- Step 2: Download binary with progress ---
776
+ try {
777
+ try {
778
+ if (existsSync(downloadDest))
779
+ unlinkSync(downloadDest);
780
+ }
781
+ catch { /* ignore */ }
782
+ await downloadToFile(binaryUrl, downloadDest, (pct, received, total) => {
783
+ renderProgressBar(pct, received, total, filename);
784
+ });
785
+ // Move to new line after progress bar finishes
786
+ process.stdout.write('\n');
787
+ }
788
+ catch (err) {
789
+ process.stdout.write('\n');
790
+ const msg = err instanceof Error ? err.message : String(err);
791
+ console.log(` ${chalk.red('✗')} Download failed: ${msg}`);
792
+ try {
793
+ if (existsSync(downloadDest))
794
+ unlinkSync(downloadDest);
795
+ }
796
+ catch { /* ignore */ }
797
+ console.log(` Run ${chalk.cyan('thinkrun install --force')} to retry`);
798
+ return false;
799
+ }
800
+ // --- Step 3: Verify checksum ---
801
+ const actualHash = sha256file(downloadDest);
802
+ if (actualHash !== expectedHash) {
803
+ console.log(` ${chalk.red('✗')} Checksum mismatch for ${filename}`);
804
+ console.log(` expected: ${expectedHash}`);
805
+ console.log(` actual: ${actualHash}`);
806
+ console.log(' Binary deleted — possible tampering.');
807
+ try {
808
+ unlinkSync(downloadDest);
809
+ }
810
+ catch { /* ignore */ }
811
+ console.log(` Run ${chalk.cyan('thinkrun install --force')} to retry`);
812
+ return false;
813
+ }
814
+ verifiedBinaryChecksum = actualHash;
815
+ persistVerifiedChecksum = true;
816
+ console.log(` ${chalk.green('✓')} Checksum: verified`);
817
+ // --- Step 4: Make executable ---
818
+ if (platform.ext !== '.exe')
819
+ chmodSync(downloadDest, 0o755);
820
+ try {
821
+ replaceInstalledBinary(downloadDest, dest);
822
+ }
823
+ catch (err) {
824
+ const msg = err instanceof Error ? err.message : String(err);
825
+ console.log(` ${chalk.red('✗')} Install failed while replacing the local binary: ${msg}`);
826
+ try {
827
+ if (existsSync(downloadDest))
828
+ unlinkSync(downloadDest);
829
+ }
830
+ catch { /* ignore */ }
831
+ console.log(` Close Chrome/Helium or any running thinkbrowse-host process, then run ${chalk.cyan('thinkrun install --force')} to retry.`);
832
+ return false;
833
+ }
834
+ }
835
+ }
836
+ }
837
+ // --- Step 5: Register native messaging manifest ---
838
+ try {
839
+ const result = spawnSync(dest, ['--install', '--extension-id', resolvedExtensionId.extensionId], {
840
+ encoding: 'utf8',
841
+ timeout: 10000,
842
+ });
843
+ if (result.status === 0) {
844
+ if (persistVerifiedChecksum && verifiedBinaryChecksum) {
845
+ config.set('nativeHostVersion', VERSION);
846
+ config.set('nativeHostChecksum', verifiedBinaryChecksum);
847
+ }
848
+ if (shouldClearPackagedDefaultPreference(resolvedExtensionId.source)) {
849
+ config.delete('preferPackagedDefaultExtensionId');
850
+ }
851
+ console.log(` ${chalk.green('✓')} Manifest: registered for extension ${resolvedExtensionId.extensionId}`);
852
+ }
853
+ else {
854
+ const errOut = result.stderr?.trim() ?? '';
855
+ console.log(` ${chalk.yellow('!')} Manifest registration exited ${result.status}${errOut ? `: ${errOut}` : ''}`);
856
+ return false;
857
+ }
858
+ }
859
+ catch (err) {
860
+ const msg = err instanceof Error ? err.message : String(err);
861
+ console.log(` ${chalk.yellow('!')} Manifest registration failed: ${msg}`);
862
+ return false;
863
+ }
864
+ console.log('');
865
+ console.log(` ${chalk.green('✓')} Native host v${VERSION} installed successfully.`);
866
+ console.log(' Open a Chromium-based browser (e.g. Chrome or Helium) — the ThinkRun extension will start the host automatically.');
867
+ return true;
868
+ }
869
+ // ---------------------------------------------------------------------------
870
+ // Check-only mode
871
+ // ---------------------------------------------------------------------------
872
+ async function runCheckOnly() {
873
+ console.log(chalk.bold('Native Host Check'));
874
+ console.log('');
875
+ const result = await checkNativeHost({ verifyChecksum: true });
876
+ console.log(` Platform: ${result.platform}`);
877
+ console.log('');
878
+ if (!result.binaryExists) {
879
+ console.log(` ${chalk.red('✗')} Binary: not found`);
880
+ console.log(` Run: ${chalk.cyan('thinkrun install')}`);
881
+ return;
882
+ }
883
+ const versionLabel = result.installedVersion
884
+ ? result.versionMatch
885
+ ? `v${result.installedVersion}`
886
+ : `v${result.installedVersion} ${chalk.yellow(`(outdated — expected v${result.expectedVersion})`)}`
887
+ : 'unknown version';
888
+ console.log(` ${result.versionMatch ? chalk.green('✓') : chalk.yellow('!')} Binary: ${result.binaryPath} (${versionLabel})`);
889
+ if (result.checksumVerified === true) {
890
+ console.log(` ${chalk.green('✓')} Checksum: verified`);
891
+ }
892
+ else if (result.checksumVerified === false) {
893
+ console.log(` ${chalk.red('✗')} Checksum: mismatch`);
894
+ }
895
+ else {
896
+ console.log(` ${chalk.gray('-')} Checksum: could not verify (offline?)`);
897
+ }
898
+ const manifestOk = result.manifestExists || process.platform === 'win32';
899
+ if (process.platform === 'win32') {
900
+ console.log(` ${chalk.gray('-')} Manifest: N/A (Windows — registry-based)`);
901
+ }
902
+ else {
903
+ if (result.manifestParseStatus === 'parse_error') {
904
+ console.log(` ${chalk.red('✗')} Manifest: unreadable (${result.manifestParseError ?? 'could not read native messaging manifest'})`);
905
+ console.log(` Run: ${chalk.cyan('thinkrun setup')}`);
906
+ }
907
+ else {
908
+ console.log(` ${manifestOk ? chalk.green('✓') : chalk.red('✗')} Manifest: ${manifestOk ? 'registered' : `not found at ${result.manifestPath}`}`);
909
+ }
910
+ }
911
+ if (!result.versionMatch) {
912
+ console.log('');
913
+ console.log(` Run: ${chalk.cyan('thinkrun install --force')}`);
914
+ }
915
+ }
916
+ // ---------------------------------------------------------------------------
917
+ // Command factory
918
+ // ---------------------------------------------------------------------------
919
+ export function createInstallCommand() {
920
+ return new Command('install')
921
+ .description('Download, verify, and register the native host binary')
922
+ .option('--force', 'reinstall even if already at the correct version')
923
+ .option('--check', 'verify existing binary only (no download)')
924
+ .option('--extension-id <id>', 'register the native host for a specific Chrome extension ID')
925
+ .action(async (opts) => {
926
+ if (opts.check) {
927
+ await runCheckOnly();
928
+ return;
929
+ }
930
+ if (opts.extensionId) {
931
+ if (!isValidExtensionId(opts.extensionId)) {
932
+ console.log(chalk.red('Error: invalid extension ID. Expected 32 lowercase letters in the range a-p.'));
933
+ process.exitCode = 1;
934
+ return;
935
+ }
936
+ }
937
+ const manifestState = inspectNativeHostManifest();
938
+ if (hasAmbiguousManifestExtensionIds({
939
+ extensionId: opts.extensionId,
940
+ envExtensionId: process.env[EXTENSION_ID_ENV] || process.env[LEGACY_EXTENSION_ID_ENV],
941
+ configuredExtensionId: config.get('extensionId'),
942
+ registeredExtensionId: config.get('registeredExtensionId'),
943
+ preferPackagedDefaultExtensionId: config.get('preferPackagedDefaultExtensionId') === true,
944
+ manifestExtensionIds: manifestState.manifestExtensionIds,
945
+ manifestParseStatus: manifestState.manifestParseStatus,
946
+ })) {
947
+ console.log(chalk.red('Error: detected browser manifests disagree on the ThinkRun extension ID.'));
948
+ console.log(` Run ${chalk.cyan('thinkrun config set-extension-id <id>')} or pass ${chalk.cyan('--extension-id <id>')}, then rerun ${chalk.cyan('thinkrun install')}.`);
949
+ process.exitCode = 1;
950
+ return;
951
+ }
952
+ const resolvedExtensionId = resolveRegistrationExtensionIdFromSources({
953
+ extensionId: opts.extensionId,
954
+ envExtensionId: process.env[EXTENSION_ID_ENV] || process.env[LEGACY_EXTENSION_ID_ENV],
955
+ configuredExtensionId: config.get('extensionId'),
956
+ registeredExtensionId: config.get('registeredExtensionId'),
957
+ preferPackagedDefaultExtensionId: config.get('preferPackagedDefaultExtensionId') === true,
958
+ manifestExtensionIds: manifestState.manifestExtensionIds,
959
+ manifestParseStatus: manifestState.manifestParseStatus,
960
+ manifestExists: manifestState.manifestExists,
961
+ });
962
+ const success = await installNativeHost({
963
+ force: opts.force ?? false,
964
+ extensionId: opts.extensionId,
965
+ resolvedExtensionId,
966
+ });
967
+ if (success) {
968
+ config.set('registeredExtensionId', resolvedExtensionId.extensionId);
969
+ }
970
+ if (!success) {
971
+ process.exitCode = 1;
972
+ }
973
+ });
974
+ }
975
+ //# sourceMappingURL=install.js.map