mindwire 0.1.26 → 0.1.28

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/dist/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { defaultStateDirectory, superviseComputer, ensureComputer, computerPairingURI, stopComputer, computerClient, SDK_VERSION, readJSON, writeJSON, currentProcessIdentity, processStateAlive, acquireProcessLock, computerPairingQRData } from './chunk-L3USUTZZ.js';
2
+ import { defaultStateDirectory, superviseComputer, ensureComputer, computerPairingURI, stopComputer, computerClient, SDK_VERSION, readJSON, writeJSON, currentProcessIdentity, processStateAlive, acquireProcessLock, computerPairingQRData } from './chunk-5WYSGXFN.js';
3
3
  import './chunk-T6TD76UA.js';
4
4
  import { promisify, parseArgs } from 'util';
5
5
  import { fileURLToPath, pathToFileURL } from 'url';
@@ -7,16 +7,18 @@ import { realpathSync, mkdtempSync, chmodSync, writeFileSync, rmSync } from 'fs'
7
7
  import * as path from 'path';
8
8
  import { join } from 'path';
9
9
  import { createHash } from 'crypto';
10
+ import { hostname, homedir, tmpdir } from 'os';
10
11
  import { createInterface } from 'readline/promises';
11
12
  import { setTimeout as setTimeout$1 } from 'timers/promises';
12
13
  import { execFile, spawn } from 'child_process';
13
- import { homedir, tmpdir } from 'os';
14
14
  import * as fs from 'fs/promises';
15
15
 
16
16
  var enterScreen = "\x1B[?1049h\x1B[?25l";
17
17
  var leaveScreen = "\x1B[0m\x1B[?25h\x1B[?1049l";
18
18
  var clearScreen = "\x1B[H\x1B[2J";
19
- var scanInstruction = "Mindwire \u2192 Add computer \u2192 Scan QR code";
19
+ function scanInstruction(invitation) {
20
+ return "mode" in invitation && invitation.mode === "reconnect" ? "Workspaces \u2192 your computer \u2192 Reconnect Computer \u2192 Scan Connection Code" : "Mindwire \u2192 Add computer \u2192 Scan QR code";
21
+ }
20
22
  async function createPairingQR(invitation) {
21
23
  const QR = await import('qrcode');
22
24
  const data = computerPairingQRData(invitation);
@@ -34,16 +36,18 @@ async function createPairingQR(invitation) {
34
36
  lines.push("\x1B[30;107m" + line + "\x1B[0m");
35
37
  }
36
38
  const svg = await QR.toString(data, { ...options, type: "svg" });
37
- return { lines, columns: Math.max(width, scanInstruction.length) + 1, rows: lines.length + 5, svg };
39
+ return { lines, columns: Math.max(width, scanInstruction(invitation).length) + 1, rows: lines.length + 5, svg };
38
40
  }
39
41
  function escapeHTML(value) {
40
42
  return value.replace(/[&<>"']/g, (ch) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[ch]);
41
43
  }
42
44
  function browserPage(invitation, svg) {
45
+ const reconnect = "mode" in invitation && invitation.mode === "reconnect";
46
+ const title = reconnect ? "Reconnect your phone" : "Connect your phone";
43
47
  return `<!doctype html>
44
48
  <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
45
49
  <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; base-uri 'none'; form-action 'none'">
46
- <title>Connect your phone \xB7 Mindwire</title>
50
+ <title>${title} \xB7 Mindwire</title>
47
51
  <style>
48
52
  *{box-sizing:border-box}body{margin:0;background:#141416;color:#f5f5f7;font:16px/1.5 system-ui,sans-serif}
49
53
  main{width:min(100% - 40px,520px);margin:32px auto;text-align:center}small{color:#a6a6af;letter-spacing:.16em}
@@ -55,18 +59,18 @@ button:disabled{opacity:.45;cursor:default}button:focus-visible{outline:3px soli
55
59
  textarea{width:100%;margin-top:12px;padding:8px;color:inherit;background:#252528;border:1px solid #45454b;border-radius:8px}
56
60
  [hidden]{display:none!important}#expiry{font-size:14px}footer{font-size:13px;color:#93939d;margin:18px 0}
57
61
  </style></head><body><main>
58
- <small>MINDWIRE</small><h1>Connect your phone</h1><p>${escapeHTML(invitation.name)}</p>
59
- <div class="code" id="code" role="img" aria-label="Mindwire pairing QR code">${svg}</div>
62
+ <small>MINDWIRE</small><h1>${title}</h1><p>${escapeHTML(invitation.name)}</p>
63
+ <div class="code" id="code" role="img" aria-label="Mindwire connection QR code">${svg}</div>
60
64
  <p id="expiry" data-expires="${escapeHTML(invitation.expiresAt)}" aria-live="off"></p>
61
- <div class="steps"><p>In Mindwire, open <strong>Add computer</strong> and scan.</p>
62
- <p>Then return to the terminal to approve your phone.</p></div>
63
- <button type="button" id="copy">Copy pairing link</button>
65
+ <div class="steps"><p>${reconnect ? "In Mindwire, open your computer in <strong>Workspaces</strong>, then <strong>Reconnect Computer \u2192 Scan Connection Code</strong>." : "In Mindwire, open <strong>Add computer</strong> and scan."}</p>
66
+ <p>${reconnect ? "Your saved pairing and workspace stay the same. No new approval is needed." : "Then return to the terminal to approve your phone."}</p></div>
67
+ <button type="button" id="copy">Copy ${reconnect ? "reconnect" : "pairing"} link</button>
64
68
  <textarea id="link" readonly hidden aria-label="Pairing link">${escapeHTML(computerPairingURI(invitation))}</textarea>
65
- <footer>You can close this page after pairing.</footer>
69
+ <footer>You can close this page after ${reconnect ? "reconnecting" : "pairing"}.</footer>
66
70
  </main><script>
67
71
  const expiry=document.getElementById('expiry'),code=document.getElementById('code'),copy=document.getElementById('copy'),link=document.getElementById('link');
68
72
  function tick(){const seconds=Math.max(0,Math.ceil((Date.parse(expiry.dataset.expires)-Date.now())/1000));
69
- if(!seconds){code.remove();copy.disabled=true;link.value='';link.hidden=true;expiry.textContent='Code expired. Run mindwire connect for a new code.';return;}
73
+ if(!seconds){code.remove();copy.disabled=true;link.value='';link.hidden=true;expiry.textContent='Code expired. Run mindwire ${reconnect ? "reconnect" : "connect pair"} for a new code.';return;}
70
74
  expiry.textContent='Expires in '+Math.floor(seconds/60)+':'+String(seconds%60).padStart(2,'0');}
71
75
  tick();setInterval(tick,1000);
72
76
  copy.addEventListener('click',async()=>{try{await navigator.clipboard.writeText(link.value);copy.textContent='Copied';}
@@ -90,16 +94,22 @@ async function openBrowser(url) {
90
94
  });
91
95
  }
92
96
  async function showPairingInvitation(invitation, options = {}) {
97
+ const reconnect = "mode" in invitation && invitation.mode === "reconnect";
98
+ const instruction = scanInstruction(invitation);
99
+ const nextStep = reconnect ? "Scan with a saved phone, then press Enter here." : "Return here to approve your phone.";
100
+ const waiting = reconnect ? "Your saved pairing stays the same." : "Waiting for your phone\u2026";
101
+ const command = reconnect ? "mindwire reconnect" : "mindwire connect pair";
93
102
  const output = options.output ?? process.stdout;
94
103
  const environment = options.environment ?? process.env;
95
104
  const mode = options.mode ?? "auto";
96
105
  const interactive = !!output.isTTY && environment.TERM !== "dumb";
97
106
  const linkOnly = mode === "none" || !interactive && mode !== "browser";
98
107
  if (linkOnly) {
99
- output.write(`Paste this link in Mindwire \u2192 Add computer (expires in 5 minutes):
108
+ const destination = reconnect ? "Workspaces \u2192 your computer \u2192 Reconnect Computer" : "Add computer";
109
+ output.write(`Paste this link in Mindwire \u2192 ${destination} (expires in 5 minutes):
100
110
  ${computerPairingURI(invitation)}
101
111
 
102
- Waiting for your phone\u2026
112
+ ${waiting}
103
113
  `);
104
114
  return { close() {
105
115
  } };
@@ -115,10 +125,10 @@ Waiting for your phone\u2026
115
125
  if (closed || !useScreen) return;
116
126
  let lines;
117
127
  if (fits()) {
118
- lines = [scanInstruction, "", ...qr.lines, "", "Waiting for your phone\u2026"];
128
+ lines = [instruction, "", ...qr.lines, "", waiting];
119
129
  } else {
120
130
  const size = `Terminal QR needs ${qr.columns} columns \xD7 ${qr.rows} rows.`;
121
- lines = browser === "opened" ? ["QR opened in your browser.", scanInstruction, "Return here to approve your phone."] : browser === "opening" ? ["Opening a resizable QR in your browser\u2026", "Return here to approve your phone."] : browser === "failed" ? ["Couldn't open a browser here.", size, "Enlarge this terminal, or run:", "mindwire connect --no-qr"] : [size, "Enlarge this terminal, or use --qr browser."];
131
+ lines = browser === "opened" ? ["QR opened in your browser.", instruction, nextStep] : browser === "opening" ? ["Opening a resizable QR in your browser\u2026", nextStep] : browser === "failed" ? ["Couldn't open a browser here.", size, "Enlarge this terminal, or run:", `${command} --no-qr`] : [size, "Enlarge this terminal, or use --qr browser."];
122
132
  lines = lines.map((line) => line.slice(0, Math.max(0, (output.columns ?? 80) - 1))).slice(0, Math.max(0, (output.rows ?? 24) - 1));
123
133
  }
124
134
  output.write(clearScreen + lines.join("\r\n"));
@@ -143,12 +153,12 @@ Waiting for your phone\u2026
143
153
  if (closed) return;
144
154
  if (useScreen) draw();
145
155
  else output.write(browser === "opened" ? `QR opened in your browser.
146
- ${scanInstruction}
147
- Return here to approve your phone.
156
+ ${instruction}
157
+ ${nextStep}
148
158
  ` : `Open this local QR page in a browser:
149
- ${pageURL ?? "Page unavailable. Run mindwire connect --no-qr for a pairing link."}
159
+ ${pageURL ?? `Page unavailable. Run ${command} --no-qr for a link.`}
150
160
 
151
- Waiting for your phone\u2026
161
+ ${waiting}
152
162
  `);
153
163
  })();
154
164
  return browserAttempt;
@@ -401,11 +411,79 @@ async function watchComputer(directory, cliPath) {
401
411
  }
402
412
  }
403
413
 
414
+ // src/computer/connection-flow.ts
415
+ function connectionAction(value) {
416
+ if (value === void 0) return void 0;
417
+ if (value === "resume" || value === "reconnect" || value === "pair") return value;
418
+ throw new Error("Use mindwire connect, mindwire connect resume, mindwire reconnect, or mindwire connect pair.");
419
+ }
420
+ async function chooseConnectionAction(options) {
421
+ const paired = options.devices.filter((device) => !device.revoked);
422
+ if (options.requested) {
423
+ if (options.requested !== "pair" && paired.length === 0) {
424
+ throw new Error("No saved phones yet. Run mindwire connect to pair one first.");
425
+ }
426
+ return options.requested;
427
+ }
428
+ if (paired.length === 0) return "pair";
429
+ if (!options.question) return "resume";
430
+ for (; ; ) {
431
+ const answer = (await options.question(
432
+ "\n 1 Resume saved connection\n 2 Refresh address on a saved phone (QR)\n 3 Pair another phone\n\nChoose [1]: "
433
+ )).trim().toLowerCase();
434
+ if (answer === "" || answer === "1" || answer === "resume") return "resume";
435
+ if (answer === "2" || answer === "reconnect") return "reconnect";
436
+ if (answer === "3" || answer === "pair") return "pair";
437
+ }
438
+ }
439
+ async function chooseStartupAction(options) {
440
+ if (options.startup && options.noStartup) throw new Error("Choose either --startup or --no-startup.");
441
+ if (options.startup) return !options.state.enabled || !options.state.running ? true : void 0;
442
+ if (options.noStartup) return options.state.kind ? void 0 : false;
443
+ if (options.state.kind) return options.state.enabled && !options.state.running ? true : void 0;
444
+ if (!options.question) return void 0;
445
+ for (; ; ) {
446
+ const answer = (await options.question(
447
+ "\nStart Mindwire automatically after you sign in to this computer? [Y/n] "
448
+ )).trim().toLowerCase();
449
+ if (["", "y", "yes"].includes(answer)) return true;
450
+ if (["n", "no"].includes(answer)) return false;
451
+ }
452
+ }
453
+ function reconnectCode(info, name, now = /* @__PURE__ */ new Date()) {
454
+ if (info.routes.length === 0) throw new Error("No connection address is ready. Run mindwire status and retry once the tunnel is online.");
455
+ return {
456
+ version: 1,
457
+ mode: "reconnect",
458
+ computerId: info.computerId,
459
+ registryId: info.registryId,
460
+ fingerprint: info.fingerprint,
461
+ name,
462
+ routes: info.routes,
463
+ expiresAt: new Date(now.getTime() + 5 * 6e4).toISOString()
464
+ };
465
+ }
466
+ function deviceSummary(devices) {
467
+ const saved = devices.filter((device) => !device.revoked);
468
+ const names = /* @__PURE__ */ new Map();
469
+ const safeName2 = (device) => device.name.replace(/[\x00-\x1f\x7f-\x9f]/g, "");
470
+ for (const device of saved) names.set(safeName2(device), (names.get(safeName2(device)) ?? 0) + 1);
471
+ return saved.sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id)).map((device) => {
472
+ const name = safeName2(device);
473
+ const status = device.connected === true ? "connected" : "paired";
474
+ const identity = (names.get(name) ?? 0) > 1 ? ` \xB7 key ${device.id.slice(0, 8)}` : "";
475
+ return ` ${name} \xB7 ${status}${identity}`;
476
+ }).join("\n");
477
+ }
478
+
404
479
  // src/cli.ts
405
480
  var help = `Mindwire \u2014 connect this computer to your phone
406
481
 
407
482
  npm install -g mindwire
408
- mindwire connect Connect across networks, show QR, approve phone
483
+ mindwire connect Resume a saved connection, or pair your first phone
484
+ mindwire connect resume Resume without creating another pairing
485
+ mindwire reconnect Refresh a saved phone's address with a QR code
486
+ mindwire connect pair Pair another phone with this computer
409
487
  mindwire connect --relay none --host laptop.tailnet Use your existing VPN
410
488
  mindwire connect --relay none Direct SSH only (reachable network required)
411
489
  mindwire connect --relay cloudflare Cloudflare quick tunnel
@@ -431,7 +509,8 @@ Options:
431
509
  --json Structured output (QR invitation includes a secret)
432
510
  --qr auto|terminal|browser Fit the QR to your terminal or open a local page
433
511
  --no-qr Print only the pairing link
434
- --no-startup Pair without changing automatic startup
512
+ --startup Enable automatic startup without a prompt
513
+ --no-startup Skip automatic startup setup
435
514
 
436
515
  The default works across Wi-Fi and mobile networks. Mindwire privately downloads
437
516
  a verified Cloudflare helper when needed. Relays carry encrypted SSH.
@@ -460,6 +539,7 @@ async function main(args = process.argv.slice(2)) {
460
539
  "websocket-port": { type: "string" },
461
540
  version: { type: "string" },
462
541
  force: { type: "boolean" },
542
+ startup: { type: "boolean" },
463
543
  "no-startup": { type: "boolean" }
464
544
  } });
465
545
  const command = positionals[0] ?? "help";
@@ -468,6 +548,7 @@ async function main(args = process.argv.slice(2)) {
468
548
  return;
469
549
  }
470
550
  if (values.qr && !["auto", "terminal", "browser"].includes(values.qr)) throw new Error("Choose --qr auto, terminal or browser.");
551
+ if (values.startup && values["no-startup"]) throw new Error("Choose either --startup or --no-startup.");
471
552
  const directory = path.resolve(values["state-dir"] ?? defaultStateDirectory());
472
553
  const emit = (event, data = {}, text) => {
473
554
  if (values.json) process.stdout.write(JSON.stringify({ event, ...data }) + "\n");
@@ -488,7 +569,8 @@ async function main(args = process.argv.slice(2)) {
488
569
  emit("startup", state, state.enabled ? "Mindwire starts after you sign in to this computer and recovers automatically." : "Automatic startup is off. Use mindwire start to run the service.");
489
570
  return;
490
571
  }
491
- if (["connect", "start"].includes(command)) {
572
+ if (["connect", "start", "reconnect"].includes(command)) {
573
+ const requestedAction = command === "reconnect" ? "reconnect" : command === "connect" ? connectionAction(positionals[1]) : void 0;
492
574
  const patch = {};
493
575
  if (values.directory) patch.directory = path.resolve(values.directory);
494
576
  if (values.host) patch.host = values.host;
@@ -521,22 +603,71 @@ async function main(args = process.argv.slice(2)) {
521
603
  const info = await client2.computer.info();
522
604
  emit("ready", { computerId: info.computerId, routes: info.routes }, "Mindwire is running in the background.");
523
605
  if (command === "start") return;
606
+ if (info.routes.some((route) => route.kind === "websocket")) {
607
+ emit("secure_connection", {}, "Your phone connects over end-to-end encrypted SSH. The tunnel carries encrypted traffic; only phones you approve can connect.");
608
+ }
609
+ const devices = await client2.computer.devices();
610
+ const saved = devices.filter((device) => !device.revoked);
611
+ if (saved.length) emit("saved_devices", { devices: saved }, `
612
+ Saved phones
613
+ ${deviceSummary(saved)}`);
614
+ const question = !values.json && process.stdin.isTTY ? async (prompt) => {
615
+ const input = createInterface({ input: process.stdin, output: process.stderr });
616
+ try {
617
+ return await input.question(prompt);
618
+ } finally {
619
+ input.close();
620
+ }
621
+ } : void 0;
622
+ const action = await chooseConnectionAction({ requested: requestedAction, devices, question });
524
623
  const startupPreference = await startupStatus(directory);
525
- if (!values["no-startup"] && (startupPreference.enabled || !startupPreference.kind)) {
624
+ const startupAction = await chooseStartupAction({
625
+ state: startupPreference,
626
+ startup: values.startup,
627
+ noStartup: values["no-startup"],
628
+ question: action === "pair" && saved.length === 0 ? question : void 0
629
+ });
630
+ if (startupAction !== void 0) {
526
631
  try {
527
- const startup = await configureStartup(directory, fileURLToPath(import.meta.url), true);
528
- emit("startup", startup, "Automatic startup enabled. Mindwire will recover after you sign in to this computer.");
632
+ const startup = await configureStartup(directory, fileURLToPath(import.meta.url), startupAction);
633
+ emit("startup", startup, startup.enabled ? "Automatic startup enabled. Saved phones can reconnect after you sign in to this computer." : "Automatic startup is off. Run mindwire connect after restarting, or mindwire startup enable to change this.");
529
634
  } catch {
530
- emit("startup_unavailable", {}, "Mindwire is running, but automatic startup couldn't be enabled. Run mindwire startup enable to check your system's startup service.");
635
+ emit("startup_unavailable", {}, "Mindwire is running, but startup setup didn't complete. Run mindwire startup enable to check your system's startup service.");
531
636
  }
637
+ } else {
638
+ emit("startup", startupPreference, startupPreference.enabled ? "Automatic startup is on. Change it with mindwire startup disable." : "Automatic startup is off. Enable it with mindwire startup enable.");
532
639
  }
533
- if (info.routes.some((route) => route.kind === "websocket")) {
534
- emit("secure_connection", {}, "Your phone connects over end-to-end encrypted SSH. The tunnel carries encrypted traffic; only phones you approve can connect.");
640
+ if (action === "resume") {
641
+ emit("resumed", { computerId: info.computerId, devices: saved }, "\nReady for your saved phone. Open this computer in Mindwire.");
642
+ if (info.routes.some((route) => route.kind === "websocket" && new URL(route.url).hostname.endsWith(".trycloudflare.com"))) {
643
+ emit("reconnect_hint", {}, "If its temporary tunnel address changed, run mindwire reconnect to refresh your phone.");
644
+ }
645
+ return;
535
646
  }
536
- if (info.routes.some((route) => route.kind === "websocket" && new URL(route.url).hostname.endsWith(".trycloudflare.com"))) {
537
- emit("temporary_address", {}, "Cloudflare internet access is ready. For a permanent address, configure a named Cloudflare tunnel or your VPN; a free temporary address can change after a tunnel restart.");
647
+ if (action === "reconnect") {
648
+ const code = reconnectCode(await client2.computer.info(), hostname());
649
+ emit("reconnect_code", { code, uri: computerPairingURI(code) });
650
+ if (values.json) return;
651
+ emit("reconnect_instructions", {}, "\nOn your iPhone: Workspaces \u2192 your computer \u2192 Reconnect Computer \u2192 Scan Connection Code.\nThis refreshes the saved connection; it does not pair another phone.");
652
+ const display2 = await showPairingInvitation(code, {
653
+ mode: values["no-qr"] ? "none" : values.qr ?? "auto"
654
+ });
655
+ try {
656
+ if (question) await question("\nScan with your saved phone, then press Enter to close this code. ");
657
+ else if (values.qr === "browser") {
658
+ emit("waiting", {}, "This code stays open until it expires. Press Ctrl+C to close it; Mindwire keeps running.");
659
+ await setTimeout$1(Math.max(0, Date.parse(code.expiresAt) - Date.now()));
660
+ }
661
+ } finally {
662
+ display2.close();
663
+ }
664
+ const connected = (await client2.computer.devices()).filter((device) => !device.revoked && device.connected);
665
+ emit(connected.length ? "connected" : "waiting_for_phone", { computerId: info.computerId, devices: connected }, connected.length ? `Connected:
666
+ ${deviceSummary(connected)}
667
+ Mindwire continues running in the background.` : "Mindwire is running. Open Reconnect Computer on your iPhone and scan the code to refresh its address.");
668
+ return;
538
669
  }
539
- const invitation = await client2.computer.invite(info.routes);
670
+ const invitation = await client2.computer.invite((await client2.computer.info()).routes);
540
671
  const uri = computerPairingURI(invitation);
541
672
  emit("invitation", { invitation, uri });
542
673
  const display = values.json ? void 0 : await showPairingInvitation(invitation, {