@empir3/empir3-bridge 0.3.21

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 (62) hide show
  1. package/CHANGELOG.md +1531 -0
  2. package/CODE_OF_CONDUCT.md +9 -0
  3. package/CONTRIBUTING.md +75 -0
  4. package/LICENSE +21 -0
  5. package/README.md +464 -0
  6. package/SECURITY.md +130 -0
  7. package/assets/accuracy-lab.html +2639 -0
  8. package/assets/api-clis-real.jpg +0 -0
  9. package/assets/bridge-console-hero.jpg +0 -0
  10. package/assets/browser-privacy.svg +151 -0
  11. package/assets/demo-orchestration.svg +74 -0
  12. package/assets/desktop-select-region.jpg +0 -0
  13. package/assets/in-page-chat.gif +0 -0
  14. package/assets/orchestration-hero.svg +126 -0
  15. package/assets/social-preview.png +0 -0
  16. package/assets/zara-accent.png +0 -0
  17. package/build/bootstrap.js +548 -0
  18. package/build/build.js +680 -0
  19. package/build/payload-entry.js +649 -0
  20. package/build/payload-signing-pub.json +7 -0
  21. package/docs/AGENT_GUIDE.md +259 -0
  22. package/docs/RELEASE.md +106 -0
  23. package/docs/SAFETY.md +112 -0
  24. package/docs/TESTING.md +181 -0
  25. package/installer/server.js +231 -0
  26. package/installer/ui/app.js +278 -0
  27. package/installer/ui/index.html +24 -0
  28. package/installer/ui/styles.css +146 -0
  29. package/package.json +95 -0
  30. package/scripts/bootstrap-e2e.mjs +650 -0
  31. package/scripts/certify-bridge.mjs +636 -0
  32. package/scripts/check-companion-surface.mjs +118 -0
  33. package/scripts/extract-welcome.mjs +64 -0
  34. package/scripts/gh-route-handler-check.mjs +57 -0
  35. package/scripts/gh-wire-test.mjs +107 -0
  36. package/scripts/publish-downloads.mjs +180 -0
  37. package/scripts/smoke-all-tools.mjs +509 -0
  38. package/scripts/smoke-live-bridge.mjs +696 -0
  39. package/scripts/splice-welcome.mjs +63 -0
  40. package/scripts/welcome-body.txt +2733 -0
  41. package/src/anthropic-client.ts +192 -0
  42. package/src/bootstrap-exe.ts +69 -0
  43. package/src/bridge.ts +2444 -0
  44. package/src/chat.ts +345 -0
  45. package/src/cli-runner.ts +239 -0
  46. package/src/cli.ts +649 -0
  47. package/src/config.ts +199 -0
  48. package/src/desktop-overlay.ps1 +121 -0
  49. package/src/executable-resolver.ts +330 -0
  50. package/src/handlers/agy-imagegen.ts +179 -0
  51. package/src/handlers/github-cli.ts +399 -0
  52. package/src/handlers/higgsfield-cli.ts +783 -0
  53. package/src/launch.js +337 -0
  54. package/src/mcp-server.ts +1265 -0
  55. package/src/pair-claim.ts +218 -0
  56. package/src/payload-daemon.ts +168 -0
  57. package/src/server.ts +21036 -0
  58. package/src/tool-defaults.ts +230 -0
  59. package/src/update-check.js +136 -0
  60. package/tray/build.py +76 -0
  61. package/tray/requirements.txt +2 -0
  62. package/tray/tray.py +1843 -0
@@ -0,0 +1,548 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Empir3 Bridge — bootstrapper (stable SEA entry).
4
+ *
5
+ * This is the part of Empir3Setup.exe that should NEVER change after the
6
+ * first signed release. Its only job is to fetch, verify, cache, and
7
+ * execute a remote-hosted "payload" tarball that contains the actual
8
+ * bridge code (daemon, installer, etc.).
9
+ *
10
+ * Why split things this way:
11
+ * - Empir3Setup.exe is an unsigned binary today and a code-signed binary
12
+ * once we wire Azure Trusted Signing. Each rebuild produces a new
13
+ * hash, which throws away SmartScreen reputation. By keeping the
14
+ * bootstrapper code small and frozen, the exe hash stays stable and
15
+ * reputation accumulates. New code ships as payload updates.
16
+ *
17
+ * - Mirrors how Chrome / VSCode / Electron / many CLIs do auto-updates:
18
+ * a small native bootstrapper validates a signed manifest + payload
19
+ * and hands off to it.
20
+ *
21
+ * Trust model:
22
+ * - The bootstrapper holds an Ed25519 PUBLIC key (compiled in below).
23
+ * - The build machine holds the matching PRIVATE key
24
+ * (`bridge/build/payload-signing-key.pem`, gitignored, treated as a
25
+ * credential — see `bridge/build/payload-signing-pub.json` for the
26
+ * public half + how to rotate).
27
+ * - Every payload is a tarball + a detached Ed25519 signature over its
28
+ * bytes. The bootstrapper refuses to run a payload whose signature
29
+ * doesn't verify against the embedded pubkey. If the signing key is
30
+ * ever compromised, rotation requires shipping a new bootstrapper
31
+ * exe (which IS a signing event under SmartScreen / Azure Trusted
32
+ * Signing — same shape as Chrome's stage-2 root rotation).
33
+ *
34
+ * Update flow at runtime:
35
+ * 1. GET https://app.empir3.com/downloads/bridge-version.json
36
+ * → { version, payloadUrl, signatureUrl, sha256 }
37
+ * 2. Compare server `version` to the locally cached payload version
38
+ * stored at `~/.empir3-bridge/payload/.version`.
39
+ * 3. If newer (or no cached payload), download tarball + signature.
40
+ * 4. Verify signature with `crypto.verify(null, tarballBuf, pubKey, sig)`
41
+ * (Ed25519 — Node 20+ accepts a null algorithm).
42
+ * 5. Verify sha256 matches the manifest.
43
+ * 6. Extract tarball into `~/.empir3-bridge/payload/<version>/`.
44
+ * 7. Atomically swap `.version` to the new value.
45
+ * 8. require() `payload/<version>/entry.js` and forward argv.
46
+ *
47
+ * Failure modes:
48
+ * - Server unreachable → run last-known-good cached payload.
49
+ * - Signature fails → keep cached payload, log loudly, refuse update.
50
+ * - sha256 mismatch → same as above.
51
+ * - No cached payload AND
52
+ * no network → exit with a clear "first-run requires
53
+ * internet" message.
54
+ * - Payload entry.js throws → propagate as fatal error to the exe caller
55
+ * (the payload is responsible for its own
56
+ * error handling internally).
57
+ *
58
+ * Bootstrapper-only concerns (NOT delegated to the payload):
59
+ * - Hard-coded pubkey, version-check URL, payload cache layout.
60
+ * - The `--bootstrap-version` and `--bootstrap-pubkey` debug flags.
61
+ *
62
+ * Everything else — `--daemon`, `--uninstall`, `--version`, `--help`,
63
+ * autostart, force-install policy, asset extraction, Koba installer UI,
64
+ * relay client, CDP handlers — lives inside the payload's entry.js.
65
+ */
66
+ const crypto = require('crypto');
67
+ const fs = require('fs');
68
+ const https = require('https');
69
+ const http = require('http');
70
+ const os = require('os');
71
+ const path = require('path');
72
+ const Module = require('module');
73
+ const { spawnSync } = require('child_process');
74
+
75
+ // ─── Compile-time constants ────────────────────────────────────────────
76
+
77
+ const BOOTSTRAP_VERSION = '1.1.0';
78
+
79
+ // Ed25519 public key (32 raw bytes, hex). Generated once with
80
+ // `node bridge/build/build.js --gen-key` (which writes the matching
81
+ // private key to bridge/build/payload-signing-key.pem). The public half
82
+ // is also committed at bridge/build/payload-signing-pub.json for audit.
83
+ const PAYLOAD_PUBKEY_HEX = 'a0813b51654fcb6026c0cfc9d0f367c8535b96c94b52c9fff15d2fe59f7cd68a';
84
+
85
+ // The version manifest lives next to the payload tarball + signature on
86
+ // the public CDN. All four URLs end up resolved relative to this base.
87
+ const VERSION_URL = process.env.EMPIR3_BRIDGE_VERSION_URL
88
+ || 'https://app.empir3.com/downloads/bridge-version.json';
89
+
90
+ // Where the bootstrapper caches payloads it has fetched + verified.
91
+ // ~/.empir3-bridge/payload/.version ← currently active version
92
+ // ~/.empir3-bridge/payload/<version>/ ← extracted tree
93
+ // ~/.empir3-bridge/payload/<version>.tar.gz ← retained for re-verify
94
+ // ~/.empir3-bridge/payload/<version>.sig ← retained for re-verify
95
+ const HOME = os.homedir();
96
+ const BRIDGE_HOME = path.join(HOME, '.empir3-bridge');
97
+ const PAYLOAD_ROOT = path.join(BRIDGE_HOME, 'payload');
98
+ const ACTIVE_VERSION_FILE = path.join(PAYLOAD_ROOT, '.version');
99
+
100
+ // Install footprint the bootstrapper can tear down WITHOUT the payload. The
101
+ // payload (payload-entry.js) owns the canonical uninstall; these mirror its
102
+ // constants and power the no-cached-payload fallback only. Keep in sync.
103
+ const APPDATA_ROAMING = process.env.APPDATA || path.join(HOME, 'AppData', 'Roaming');
104
+ const APPDATA_DIR = path.join(APPDATA_ROAMING, 'Empir3');
105
+ const AUTOSTART_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run';
106
+ const AUTOSTART_VALUE_NAME = 'Empir3Bridge';
107
+ const FORCELIST_KEY = 'HKCU\\Software\\Policies\\Google\\Chrome\\ExtensionInstallForcelist';
108
+ const EXTENSION_ID = 'gbigofjjgcpjkffhlfepjdglabhngeii';
109
+ const START_MENU_LNK = path.join(APPDATA_ROAMING, 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Empir3', 'Empir3.lnk');
110
+
111
+ // Network timeout for the initial version probe. Short — we fall back to
112
+ // the cached payload very fast so a flaky network doesn't slow the daemon
113
+ // or installer launch.
114
+ const VERSION_PROBE_TIMEOUT_MS = 5_000;
115
+ const PAYLOAD_DOWNLOAD_TIMEOUT_MS = 60_000;
116
+
117
+ // ─── Public-key import (DER SPKI from raw 32 bytes) ────────────────────
118
+
119
+ // Node's crypto.verify wants a KeyObject. Wrap our 32 raw bytes in the
120
+ // fixed Ed25519 SPKI prefix:
121
+ // 30 2a 30 05 06 03 2b 65 70 03 21 00 <32 bytes pubkey>
122
+ const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
123
+ function loadPubKey() {
124
+ const raw = Buffer.from(PAYLOAD_PUBKEY_HEX, 'hex');
125
+ if (raw.length !== 32) throw new Error(`Bad PAYLOAD_PUBKEY_HEX length: ${raw.length}`);
126
+ const spki = Buffer.concat([ED25519_SPKI_PREFIX, raw]);
127
+ return crypto.createPublicKey({ key: spki, format: 'der', type: 'spki' });
128
+ }
129
+
130
+ // ─── Tiny HTTPS GET helpers (no external deps) ─────────────────────────
131
+
132
+ function fetchBuffer(url, timeoutMs) {
133
+ return new Promise((resolve, reject) => {
134
+ const u = new URL(url);
135
+ const lib = u.protocol === 'http:' ? http : https;
136
+ const req = lib.get(url, { timeout: timeoutMs, headers: { 'User-Agent': `empir3-bootstrap/${BOOTSTRAP_VERSION}` } }, (res) => {
137
+ // Follow one redirect (CDN failover etc.).
138
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
139
+ res.resume();
140
+ const next = new URL(res.headers.location, url).toString();
141
+ return resolve(fetchBuffer(next, timeoutMs));
142
+ }
143
+ if (res.statusCode !== 200) {
144
+ res.resume();
145
+ return reject(new Error(`HTTP ${res.statusCode} for ${url}`));
146
+ }
147
+ const chunks = [];
148
+ res.on('data', (c) => chunks.push(c));
149
+ res.on('end', () => resolve(Buffer.concat(chunks)));
150
+ res.on('error', reject);
151
+ });
152
+ req.on('error', reject);
153
+ req.on('timeout', () => { req.destroy(new Error(`timeout fetching ${url}`)); });
154
+ });
155
+ }
156
+
157
+ async function fetchJson(url, timeoutMs) {
158
+ const buf = await fetchBuffer(url, timeoutMs);
159
+ return JSON.parse(buf.toString('utf8'));
160
+ }
161
+
162
+ // ─── Payload cache helpers ─────────────────────────────────────────────
163
+
164
+ function readActiveVersion() {
165
+ try { return fs.readFileSync(ACTIVE_VERSION_FILE, 'utf8').trim(); } catch { return null; }
166
+ }
167
+
168
+ function payloadDir(version) {
169
+ return path.join(PAYLOAD_ROOT, version);
170
+ }
171
+
172
+ function payloadIsExtracted(version) {
173
+ if (!version) return false;
174
+ return fs.existsSync(path.join(payloadDir(version), 'entry.js'));
175
+ }
176
+
177
+ function compareVersions(a, b) {
178
+ const left = String(a || '').replace(/^v/i, '').split('.').map((part) => Number.parseInt(part, 10));
179
+ const right = String(b || '').replace(/^v/i, '').split('.').map((part) => Number.parseInt(part, 10));
180
+ const len = Math.max(left.length, right.length, 3);
181
+ for (let i = 0; i < len; i += 1) {
182
+ const l = Number.isFinite(left[i]) ? left[i] : 0;
183
+ const r = Number.isFinite(right[i]) ? right[i] : 0;
184
+ if (l !== r) return l > r ? 1 : -1;
185
+ }
186
+ return 0;
187
+ }
188
+
189
+ function writeActiveVersion(version) {
190
+ fs.mkdirSync(PAYLOAD_ROOT, { recursive: true });
191
+ // Atomic-ish: write to .new then rename.
192
+ const tmp = `${ACTIVE_VERSION_FILE}.new`;
193
+ fs.writeFileSync(tmp, version);
194
+ fs.renameSync(tmp, ACTIVE_VERSION_FILE);
195
+ }
196
+
197
+ // ─── Tar extraction (built-in, no npm deps) ────────────────────────────
198
+
199
+ // Minimal POSIX-tar extractor. Handles regular files + directories +
200
+ // long names (GNU 'L' headers). Inputs are .tar.gz buffers; we gunzip via
201
+ // zlib (built-in) into the raw tar stream then parse blocks of 512.
202
+ //
203
+ // Deliberately built in-process (no `tar` npm dep, no shelling out to
204
+ // bsdtar) so the bootstrapper is fully self-contained inside the SEA
205
+ // blob. ~80 lines.
206
+ async function extractTarGz(tarGzBuffer, destDir) {
207
+ const zlib = require('zlib');
208
+ const raw = await new Promise((resolve, reject) => {
209
+ zlib.gunzip(tarGzBuffer, (err, out) => err ? reject(err) : resolve(out));
210
+ });
211
+
212
+ fs.mkdirSync(destDir, { recursive: true });
213
+
214
+ const BLOCK = 512;
215
+ let pos = 0;
216
+ let pendingLongName = null;
217
+
218
+ while (pos + BLOCK <= raw.length) {
219
+ const header = raw.slice(pos, pos + BLOCK);
220
+ // Empty block(s) at the end mark EOF.
221
+ if (header.every((b) => b === 0)) { pos += BLOCK; continue; }
222
+
223
+ // Parse name (100), size (12), typeflag (1).
224
+ let name = header.slice(0, 100).toString('utf8').replace(/\0.*$/, '');
225
+ const sizeOctal = header.slice(124, 136).toString('utf8').replace(/\0.*$/, '').trim();
226
+ const size = parseInt(sizeOctal, 8) || 0;
227
+ const typeflag = String.fromCharCode(header[156]);
228
+ const prefix = header.slice(345, 500).toString('utf8').replace(/\0.*$/, '');
229
+
230
+ if (prefix && name) name = prefix + '/' + name;
231
+ if (pendingLongName) { name = pendingLongName; pendingLongName = null; }
232
+
233
+ pos += BLOCK;
234
+ const dataEnd = pos + size;
235
+ const data = raw.slice(pos, dataEnd);
236
+ pos = dataEnd + ((BLOCK - (size % BLOCK)) % BLOCK);
237
+
238
+ if (!name) continue;
239
+
240
+ if (typeflag === 'L') {
241
+ // GNU long-name header — name of the NEXT entry is in this block's data.
242
+ pendingLongName = data.toString('utf8').replace(/\0+$/, '');
243
+ continue;
244
+ }
245
+ if (typeflag === 'x' || typeflag === 'g') {
246
+ // pax extended headers — ignore, we don't need their metadata.
247
+ continue;
248
+ }
249
+
250
+ // Sanitize: strip leading slashes, reject path traversal.
251
+ const safeRel = name.replace(/^\.?\/+/, '');
252
+ if (safeRel.includes('..')) throw new Error(`Refusing tar entry with .. in path: ${name}`);
253
+ const out = path.join(destDir, safeRel);
254
+ if (!out.startsWith(destDir)) throw new Error(`Tar entry escapes dest dir: ${name}`);
255
+
256
+ if (typeflag === '5' || name.endsWith('/')) {
257
+ fs.mkdirSync(out, { recursive: true });
258
+ } else if (typeflag === '0' || typeflag === '' || typeflag === '\0') {
259
+ fs.mkdirSync(path.dirname(out), { recursive: true });
260
+ fs.writeFileSync(out, data);
261
+ }
262
+ // We ignore symlinks ('2'), hardlinks ('1'), block/char devices, etc.
263
+ // Our payload tarballs only ship regular files + dirs (build script
264
+ // enforces this).
265
+ }
266
+ }
267
+
268
+ // ─── Main update + dispatch flow ───────────────────────────────────────
269
+
270
+ function log(msg) { console.log(`[empir3-bootstrap] ${msg}`); }
271
+ function warn(msg) { console.warn(`[empir3-bootstrap] WARN: ${msg}`); }
272
+ function err(msg) { console.error(`[empir3-bootstrap] ERROR: ${msg}`); }
273
+
274
+ async function tryUpdate() {
275
+ let manifest;
276
+ try {
277
+ manifest = await fetchJson(VERSION_URL, VERSION_PROBE_TIMEOUT_MS);
278
+ } catch (e) {
279
+ warn(`version probe failed (${e.message}); using cached payload if any.`);
280
+ return null;
281
+ }
282
+ if (!manifest || typeof manifest.version !== 'string' || !manifest.payloadUrl || !manifest.signatureUrl) {
283
+ warn('version manifest malformed; using cached payload if any.');
284
+ return null;
285
+ }
286
+
287
+ const active = readActiveVersion();
288
+ if (payloadIsExtracted(active) && compareVersions(active, manifest.version) > 0) {
289
+ warn(`active payload v${active} is newer than manifest v${manifest.version}; keeping cached payload.`);
290
+ return active;
291
+ }
292
+
293
+ if (active === manifest.version && payloadIsExtracted(active)) {
294
+ return active; // already on latest
295
+ }
296
+
297
+ if (payloadIsExtracted(manifest.version)) {
298
+ writeActiveVersion(manifest.version);
299
+ log(`using existing payload v${manifest.version}`);
300
+ return manifest.version;
301
+ }
302
+
303
+ log(`fetching payload v${manifest.version} (have: ${active || 'none'})`);
304
+ let tarball, signature;
305
+ try {
306
+ [tarball, signature] = await Promise.all([
307
+ fetchBuffer(manifest.payloadUrl, PAYLOAD_DOWNLOAD_TIMEOUT_MS),
308
+ fetchBuffer(manifest.signatureUrl, PAYLOAD_DOWNLOAD_TIMEOUT_MS),
309
+ ]);
310
+ } catch (e) {
311
+ warn(`payload download failed (${e.message}); using cached payload if any.`);
312
+ return null;
313
+ }
314
+
315
+ // Verify Ed25519 signature over tarball bytes.
316
+ let pubKey;
317
+ try { pubKey = loadPubKey(); }
318
+ catch (e) { err(`embedded pubkey is corrupt: ${e.message}`); return null; }
319
+
320
+ let valid = false;
321
+ try { valid = crypto.verify(null, tarball, pubKey, signature); }
322
+ catch (e) { err(`signature verify threw: ${e.message}`); return null; }
323
+ if (!valid) {
324
+ err(`SIGNATURE INVALID for payload v${manifest.version} — refusing to install. Keeping cached payload.`);
325
+ return null;
326
+ }
327
+
328
+ // Verify sha256 (defence-in-depth — catches a benign CDN swap).
329
+ if (manifest.sha256) {
330
+ const got = crypto.createHash('sha256').update(tarball).digest('hex');
331
+ if (got !== manifest.sha256) {
332
+ err(`sha256 mismatch (got ${got}, expected ${manifest.sha256}) — refusing to install.`);
333
+ return null;
334
+ }
335
+ }
336
+
337
+ // Extract into a fresh dir; only flip .version once it's intact.
338
+ const destDir = payloadDir(manifest.version);
339
+ try {
340
+ fs.rmSync(destDir, { recursive: true, force: true });
341
+ await extractTarGz(tarball, destDir);
342
+ if (!fs.existsSync(path.join(destDir, 'entry.js'))) {
343
+ throw new Error('payload missing entry.js after extraction');
344
+ }
345
+ // Persist the verified tar + sig alongside (lets ops re-verify
346
+ // off-machine without redownload, and lets uninstall clear them).
347
+ fs.writeFileSync(path.join(PAYLOAD_ROOT, `${manifest.version}.tar.gz`), tarball);
348
+ fs.writeFileSync(path.join(PAYLOAD_ROOT, `${manifest.version}.sig`), signature);
349
+ writeActiveVersion(manifest.version);
350
+ log(`installed payload v${manifest.version}`);
351
+ return manifest.version;
352
+ } catch (e) {
353
+ err(`payload install failed: ${e.message}`);
354
+ try { fs.rmSync(destDir, { recursive: true, force: true }); } catch {}
355
+ return null;
356
+ }
357
+ }
358
+
359
+ function dispatchToPayload(version) {
360
+ const entry = path.join(payloadDir(version), 'entry.js');
361
+ if (!fs.existsSync(entry)) {
362
+ err(`payload entry.js missing at ${entry}`);
363
+ process.exit(70); // EX_SOFTWARE
364
+ }
365
+
366
+ // Tell the payload where it lives + what bootstrap loaded it. The
367
+ // payload uses these to find its bundled-asset siblings (installer-ui/)
368
+ // without needing SEA-asset extraction.
369
+ process.env.EMPIR3_BRIDGE_PAYLOAD_DIR = payloadDir(version);
370
+ process.env.EMPIR3_BRIDGE_PAYLOAD_VERSION = version;
371
+ process.env.EMPIR3_BRIDGE_BOOTSTRAP_VERSION = BOOTSTRAP_VERSION;
372
+
373
+ // Give the payload a clean require() that resolves against its own
374
+ // extracted tree (so any internal `require('./foo')` works).
375
+ const payloadRequire = Module.createRequire(entry);
376
+ const payload = payloadRequire(entry);
377
+
378
+ // Payload contract: export an async start(argv) function. argv is the
379
+ // forwarded CLI args (excluding node + script).
380
+ if (typeof payload.start !== 'function') {
381
+ err('payload entry.js does not export start(argv)');
382
+ process.exit(70);
383
+ }
384
+ return payload.start(process.argv.slice(2));
385
+ }
386
+
387
+ // ─── Uninstall (network-free) ──────────────────────────────────────────
388
+ //
389
+ // Uninstall is a teardown: it must never depend on the network or on
390
+ // fetching a fresh payload — downloading the very thing we're about to
391
+ // delete is nonsensical and would fail offline. main() dispatches to the
392
+ // cached payload's canonical uninstall when one is extracted; this native
393
+ // fallback runs only when no usable payload is on disk (corrupt / partial
394
+ // install), so a broken payload can never leave the user unable to remove
395
+ // the bridge. It mirrors payload-entry.js uninstall() — keep them aligned.
396
+
397
+ function showUninstallDoneDialog(steps) {
398
+ if (process.platform !== 'win32') return;
399
+ try {
400
+ const body =
401
+ 'Empir3 Bridge has been uninstalled.\n\n' +
402
+ `${steps} item(s) were removed. You can delete Empir3Setup.exe whenever you like.\n\n` +
403
+ 'If Chrome is open, the helper extension disappears the next time you restart it.';
404
+ const b64 = Buffer.from(body, 'utf8').toString('base64');
405
+ const ps =
406
+ 'Add-Type -AssemblyName System.Windows.Forms | Out-Null; ' +
407
+ '[System.Windows.Forms.MessageBox]::Show(' +
408
+ `[System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('${b64}')),` +
409
+ "'Empir3 Bridge','OK','Information') | Out-Null";
410
+ spawnSync('powershell', ['-NoProfile', '-NonInteractive', '-STA', '-Command', ps],
411
+ { stdio: 'ignore', windowsHide: true });
412
+ } catch {}
413
+ }
414
+
415
+ function nativeUninstall() {
416
+ log('uninstalling (native fallback — no cached payload to delegate to)');
417
+ let steps = 0;
418
+ const isWin = process.platform === 'win32';
419
+
420
+ if (isWin) {
421
+ // Kill the tray first so it can't respawn anything we kill on a port.
422
+ const tk = spawnSync('taskkill', ['/F', '/IM', 'Empir3Tray.exe'], { encoding: 'utf8' });
423
+ if (tk.status === 0) { log(' killed Empir3Tray.exe'); steps++; }
424
+
425
+ // Stop any running daemon (anything listening on 3006-3306).
426
+ const ns = spawnSync('netstat', ['-ano'], { encoding: 'utf8' });
427
+ if (ns.stdout) {
428
+ const seen = new Set();
429
+ for (const port of [3006, 3106, 3206, 3306]) {
430
+ for (const line of ns.stdout.split('\n')) {
431
+ if ((line.includes(`127.0.0.1:${port}`) || line.includes(`0.0.0.0:${port}`)) && /LISTENING/.test(line)) {
432
+ const m = line.match(/(\d+)\s*$/);
433
+ if (m) seen.add(m[1]);
434
+ }
435
+ }
436
+ }
437
+ for (const pid of seen) {
438
+ if (pid === String(process.pid)) continue;
439
+ spawnSync('taskkill', ['/F', '/PID', pid], { stdio: 'ignore' });
440
+ log(` killed bridge daemon pid ${pid}`); steps++;
441
+ }
442
+ }
443
+
444
+ // Autostart entry — critical: without this it relaunches on next login.
445
+ if (spawnSync('reg', ['query', AUTOSTART_KEY, '/v', AUTOSTART_VALUE_NAME], { encoding: 'utf8' }).status === 0) {
446
+ spawnSync('reg', ['delete', AUTOSTART_KEY, '/v', AUTOSTART_VALUE_NAME, '/f'], { stdio: 'ignore' });
447
+ log(' removed Windows autostart'); steps++;
448
+ }
449
+
450
+ // Chrome force-install policy — only the slots that hold OUR extension.
451
+ const q = spawnSync('reg', ['query', FORCELIST_KEY], { encoding: 'utf8' });
452
+ if (q.status === 0 && q.stdout) {
453
+ const re = /\s+(\S+)\s+REG_SZ\s+(.+?)\s*$/gm;
454
+ let m;
455
+ while ((m = re.exec(q.stdout)) !== null) {
456
+ if (m[2].startsWith(EXTENSION_ID + ';')) {
457
+ spawnSync('reg', ['delete', FORCELIST_KEY, '/v', m[1], '/f'], { stdio: 'ignore' });
458
+ log(` removed Chrome force-install policy (slot ${m[1]})`); steps++;
459
+ }
460
+ }
461
+ }
462
+
463
+ // Start Menu shortcut + parent folder (if empty).
464
+ try {
465
+ if (fs.existsSync(START_MENU_LNK)) {
466
+ fs.rmSync(START_MENU_LNK, { force: true });
467
+ log(' removed Start Menu shortcut'); steps++;
468
+ }
469
+ const folder = path.dirname(START_MENU_LNK);
470
+ if (fs.existsSync(folder) && fs.readdirSync(folder).length === 0) fs.rmdirSync(folder);
471
+ } catch {}
472
+ }
473
+
474
+ // Cached payloads + extracted runtime files (the whole ~/.empir3-bridge).
475
+ try {
476
+ fs.rmSync(BRIDGE_HOME, { recursive: true, force: true });
477
+ log(' cleared ~/.empir3-bridge (payloads + runtime files)'); steps++;
478
+ } catch {}
479
+
480
+ // Auth, settings, logs. The running Empir3Setup.exe lives here and can't
481
+ // delete itself — that's expected and documented.
482
+ if (isWin) {
483
+ try {
484
+ if (fs.existsSync(APPDATA_DIR)) {
485
+ fs.rmSync(APPDATA_DIR, { recursive: true, force: true });
486
+ log(' cleared %APPDATA%/Empir3 (auth, settings, logs)'); steps++;
487
+ }
488
+ } catch (e) {
489
+ warn(`could not fully clear %APPDATA%/Empir3: ${e.message}`);
490
+ }
491
+ }
492
+
493
+ log(`uninstall complete (${steps} steps).`);
494
+ log('Note: Empir3Setup.exe can be deleted manually.');
495
+ showUninstallDoneDialog(steps);
496
+ }
497
+
498
+ async function main() {
499
+ const argv = process.argv.slice(2);
500
+
501
+ // Bootstrap-only debug flags. Help/version of the bridge itself live in
502
+ // the payload (so users get the SHIPPED help, not the frozen version
503
+ // baked into the exe).
504
+ if (argv.includes('--bootstrap-version')) {
505
+ console.log(`empir3-bootstrap ${BOOTSTRAP_VERSION}`);
506
+ return;
507
+ }
508
+ if (argv.includes('--bootstrap-pubkey')) {
509
+ console.log(PAYLOAD_PUBKEY_HEX);
510
+ return;
511
+ }
512
+
513
+ // Uninstall runs BEFORE any network/update logic. It tears the install
514
+ // down using only what's already on disk — never downloads (you don't
515
+ // fetch the thing you're about to delete, and it must work offline).
516
+ // Delegate to the cached payload's canonical uninstall when present;
517
+ // otherwise fall back to the self-contained native cleanup.
518
+ if (argv.includes('--uninstall')) {
519
+ const cached = readActiveVersion();
520
+ if (cached && payloadIsExtracted(cached)) {
521
+ log(`uninstalling via cached payload v${cached} (offline)`);
522
+ await dispatchToPayload(cached);
523
+ } else {
524
+ nativeUninstall();
525
+ }
526
+ return;
527
+ }
528
+
529
+ // Try to bring the local payload up to date with the server's manifest.
530
+ // Returns the new active version on success, null on any kind of fail.
531
+ let active = await tryUpdate();
532
+ if (!active) active = readActiveVersion();
533
+
534
+ if (!active || !payloadIsExtracted(active)) {
535
+ err('No usable payload installed and update failed. ' +
536
+ 'First-run requires network access to ' + VERSION_URL + '.');
537
+ process.exit(69); // EX_UNAVAILABLE
538
+ }
539
+
540
+ // Hand off. The payload is now fully responsible for everything
541
+ // (--daemon, --uninstall, --version, --help, installer UI, etc.).
542
+ await dispatchToPayload(active);
543
+ }
544
+
545
+ main().catch((e) => {
546
+ err(`fatal: ${e.stack || e.message}`);
547
+ process.exit(1);
548
+ });