mindwire 0.1.27 → 0.1.29

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-QXHI4TMC.js';
2
+ import { defaultStateDirectory, superviseComputer, ensureComputer, computerPairingURI, stopComputer, computerClient, SDK_VERSION, readJSON, writeJSON, currentProcessIdentity, processStateAlive, acquireProcessLock, computerPairingQRData } from './chunk-QPZMGVY7.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';
11
10
  import { createInterface } from 'readline/promises';
12
11
  import { setTimeout as setTimeout$1 } from 'timers/promises';
13
12
  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,7 +36,7 @@ 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]);
@@ -60,15 +62,15 @@ textarea{width:100%;margin-top:12px;padding:8px;color:inherit;background:#252528
60
62
  <small>MINDWIRE</small><h1>${title}</h1><p>${escapeHTML(invitation.name)}</p>
61
63
  <div class="code" id="code" role="img" aria-label="Mindwire connection QR code">${svg}</div>
62
64
  <p id="expiry" data-expires="${escapeHTML(invitation.expiresAt)}" aria-live="off"></p>
63
- <div class="steps"><p>In Mindwire, open <strong>Add computer</strong> and scan.</p>
64
- <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>
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." : "Saved keys reconnect automatically. Approve on this computer only if asked."}</p></div>
65
67
  <button type="button" id="copy">Copy ${reconnect ? "reconnect" : "pairing"} link</button>
66
68
  <textarea id="link" readonly hidden aria-label="Pairing link">${escapeHTML(computerPairingURI(invitation))}</textarea>
67
69
  <footer>You can close this page after ${reconnect ? "reconnecting" : "pairing"}.</footer>
68
70
  </main><script>
69
71
  const expiry=document.getElementById('expiry'),code=document.getElementById('code'),copy=document.getElementById('copy'),link=document.getElementById('link');
70
72
  function tick(){const seconds=Math.max(0,Math.ceil((Date.parse(expiry.dataset.expires)-Date.now())/1000));
71
- 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;}
73
+ if(!seconds){code.remove();copy.disabled=true;link.value='';link.hidden=true;expiry.textContent='Code expired. Run mindwire connect for a new code.';return;}
72
74
  expiry.textContent='Expires in '+Math.floor(seconds/60)+':'+String(seconds%60).padStart(2,'0');}
73
75
  tick();setInterval(tick,1000);
74
76
  copy.addEventListener('click',async()=>{try{await navigator.clipboard.writeText(link.value);copy.textContent='Copied';}
@@ -93,16 +95,18 @@ async function openBrowser(url) {
93
95
  }
94
96
  async function showPairingInvitation(invitation, options = {}) {
95
97
  const reconnect = "mode" in invitation && invitation.mode === "reconnect";
96
- const nextStep = reconnect ? "Scan with a saved phone, then press Enter here." : "Return here to approve your phone.";
98
+ const instruction = scanInstruction(invitation);
99
+ const nextStep = reconnect ? "Scan with a saved phone." : "Approve on this computer only if asked.";
97
100
  const waiting = reconnect ? "Your saved pairing stays the same." : "Waiting for your phone\u2026";
98
- const command = reconnect ? "mindwire reconnect" : "mindwire connect pair";
101
+ const command = "mindwire connect";
99
102
  const output = options.output ?? process.stdout;
100
103
  const environment = options.environment ?? process.env;
101
104
  const mode = options.mode ?? "auto";
102
105
  const interactive = !!output.isTTY && environment.TERM !== "dumb";
103
106
  const linkOnly = mode === "none" || !interactive && mode !== "browser";
104
107
  if (linkOnly) {
105
- 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):
106
110
  ${computerPairingURI(invitation)}
107
111
 
108
112
  ${waiting}
@@ -121,10 +125,10 @@ ${waiting}
121
125
  if (closed || !useScreen) return;
122
126
  let lines;
123
127
  if (fits()) {
124
- lines = [scanInstruction, "", ...qr.lines, "", waiting];
128
+ lines = [instruction, "", ...qr.lines, "", waiting];
125
129
  } else {
126
130
  const size = `Terminal QR needs ${qr.columns} columns \xD7 ${qr.rows} rows.`;
127
- lines = browser === "opened" ? ["QR opened in your browser.", scanInstruction, 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."];
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."];
128
132
  lines = lines.map((line) => line.slice(0, Math.max(0, (output.columns ?? 80) - 1))).slice(0, Math.max(0, (output.rows ?? 24) - 1));
129
133
  }
130
134
  output.write(clearScreen + lines.join("\r\n"));
@@ -149,7 +153,7 @@ ${waiting}
149
153
  if (closed) return;
150
154
  if (useScreen) draw();
151
155
  else output.write(browser === "opened" ? `QR opened in your browser.
152
- ${scanInstruction}
156
+ ${instruction}
153
157
  ${nextStep}
154
158
  ` : `Open this local QR page in a browser:
155
159
  ${pageURL ?? `Page unavailable. Run ${command} --no-qr for a link.`}
@@ -414,22 +418,23 @@ function connectionAction(value) {
414
418
  throw new Error("Use mindwire connect, mindwire connect resume, mindwire reconnect, or mindwire connect pair.");
415
419
  }
416
420
  async function chooseConnectionAction(options) {
417
- const paired = options.devices.filter((device) => !device.revoked);
418
- if (options.requested) {
419
- if (options.requested !== "pair" && paired.length === 0) {
420
- throw new Error("No saved phones yet. Run mindwire connect to pair one first.");
421
- }
422
- return options.requested;
423
- }
424
- if (paired.length === 0) return "pair";
425
- if (!options.question) return "resume";
421
+ if (options.requested === "pair" || options.requested === "reconnect") return "pair";
422
+ return options.devices.some((device) => !device.revoked && device.connected) ? "resume" : "pair";
423
+ }
424
+ async function choosePairingDecision(options) {
425
+ const replacements = options.replacements.filter((device) => !device.revoked);
426
+ const prompt = replacements.length ? `
427
+ Previous approvals with this name:
428
+ ${deviceSummary(replacements, true)}
429
+
430
+ Allow this phone? [r = replace these approvals, a = add a different phone, N = deny]: ` : "Allow this phone to access this workspace? [y/N] ";
426
431
  for (; ; ) {
427
- const answer = (await options.question(
428
- "\n 1 Resume saved connection\n 2 Refresh address on a saved phone (QR)\n 3 Pair another phone\n\nChoose [1]: "
429
- )).trim().toLowerCase();
430
- if (answer === "" || answer === "1" || answer === "resume") return "resume";
431
- if (answer === "2" || answer === "reconnect") return "reconnect";
432
- if (answer === "3" || answer === "pair") return "pair";
432
+ const answer = (await options.question(prompt)).trim().toLowerCase();
433
+ if (["", "n", "no"].includes(answer)) return { approve: false };
434
+ if (replacements.length) {
435
+ if (["r", "replace"].includes(answer)) return { approve: true, replaceDeviceIds: replacements.map((device) => device.id) };
436
+ if (["a", "add"].includes(answer)) return { approve: true };
437
+ } else if (["y", "yes"].includes(answer)) return { approve: true };
433
438
  }
434
439
  }
435
440
  async function chooseStartupAction(options) {
@@ -446,24 +451,16 @@ async function chooseStartupAction(options) {
446
451
  if (["n", "no"].includes(answer)) return false;
447
452
  }
448
453
  }
449
- function reconnectCode(info, name, now = /* @__PURE__ */ new Date()) {
450
- if (info.routes.length === 0) throw new Error("No connection address is ready. Run mindwire status and retry once the tunnel is online.");
451
- return {
452
- version: 1,
453
- mode: "reconnect",
454
- computerId: info.computerId,
455
- registryId: info.registryId,
456
- fingerprint: info.fingerprint,
457
- name,
458
- routes: info.routes,
459
- expiresAt: new Date(now.getTime() + 5 * 6e4).toISOString()
460
- };
461
- }
462
- function deviceSummary(devices) {
463
- return devices.filter((device) => !device.revoked).sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id)).map((device) => {
464
- const name = device.name.replace(/[\x00-\x1f\x7f-\x9f]/g, "");
454
+ function deviceSummary(devices, showKeys = false) {
455
+ const saved = devices.filter((device) => !device.revoked);
456
+ const names = /* @__PURE__ */ new Map();
457
+ const safeName2 = (device) => device.name.replace(/[\x00-\x1f\x7f-\x9f]/g, "");
458
+ for (const device of saved) names.set(safeName2(device), (names.get(safeName2(device)) ?? 0) + 1);
459
+ return saved.sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id)).map((device) => {
460
+ const name = safeName2(device);
465
461
  const status = device.connected === true ? "connected" : "paired";
466
- return ` ${name} \xB7 ${status}`;
462
+ const identity = showKeys || (names.get(name) ?? 0) > 1 ? ` \xB7 key ${device.id.slice(0, 8)}` : "";
463
+ return ` ${name} \xB7 ${status}${identity}`;
467
464
  }).join("\n");
468
465
  }
469
466
 
@@ -471,9 +468,8 @@ function deviceSummary(devices) {
471
468
  var help = `Mindwire \u2014 connect this computer to your phone
472
469
 
473
470
  npm install -g mindwire
474
- mindwire connect Resume a saved connection, or pair your first phone
475
- mindwire connect resume Resume without creating another pairing
476
- mindwire reconnect Refresh a saved phone's address with a QR code
471
+ mindwire connect Connect your phone; saved keys reconnect automatically
472
+ mindwire reconnect Show a connection QR again (also repairs a missing pairing)
477
473
  mindwire connect pair Pair another phone with this computer
478
474
  mindwire connect --relay none --host laptop.tailnet Use your existing VPN
479
475
  mindwire connect --relay none Direct SSH only (reachable network required)
@@ -502,6 +498,7 @@ Options:
502
498
  --no-qr Print only the pairing link
503
499
  --startup Enable automatic startup without a prompt
504
500
  --no-startup Skip automatic startup setup
501
+ --replace-device DEVICE_ID With approve: replace a lost phone key (repeatable)
505
502
 
506
503
  The default works across Wi-Fi and mobile networks. Mindwire privately downloads
507
504
  a verified Cloudflare helper when needed. Relays carry encrypted SSH.
@@ -531,7 +528,8 @@ async function main(args = process.argv.slice(2)) {
531
528
  version: { type: "string" },
532
529
  force: { type: "boolean" },
533
530
  startup: { type: "boolean" },
534
- "no-startup": { type: "boolean" }
531
+ "no-startup": { type: "boolean" },
532
+ "replace-device": { type: "string", multiple: true }
535
533
  } });
536
534
  const command = positionals[0] ?? "help";
537
535
  if (values.help || command === "help") {
@@ -600,7 +598,7 @@ async function main(args = process.argv.slice(2)) {
600
598
  const devices = await client2.computer.devices();
601
599
  const saved = devices.filter((device) => !device.revoked);
602
600
  if (saved.length) emit("saved_devices", { devices: saved }, `
603
- Saved phones
601
+ Saved approvals on this computer
604
602
  ${deviceSummary(saved)}`);
605
603
  const question = !values.json && process.stdin.isTTY ? async (prompt) => {
606
604
  const input = createInterface({ input: process.stdin, output: process.stderr });
@@ -610,13 +608,13 @@ ${deviceSummary(saved)}`);
610
608
  input.close();
611
609
  }
612
610
  } : void 0;
613
- const action = await chooseConnectionAction({ requested: requestedAction, devices, question });
611
+ const action = await chooseConnectionAction({ requested: requestedAction, devices });
614
612
  const startupPreference = await startupStatus(directory);
615
613
  const startupAction = await chooseStartupAction({
616
614
  state: startupPreference,
617
615
  startup: values.startup,
618
616
  noStartup: values["no-startup"],
619
- question
617
+ question: action === "pair" && saved.length === 0 ? question : void 0
620
618
  });
621
619
  if (startupAction !== void 0) {
622
620
  try {
@@ -629,29 +627,12 @@ ${deviceSummary(saved)}`);
629
627
  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.");
630
628
  }
631
629
  if (action === "resume") {
632
- emit("resumed", { computerId: info.computerId, devices: saved }, "\nReady for your saved phone. Open this computer in Mindwire.");
633
- if (info.routes.some((route) => route.kind === "websocket" && new URL(route.url).hostname.endsWith(".trycloudflare.com"))) {
634
- emit("reconnect_hint", {}, "If its temporary tunnel address changed, run mindwire reconnect to refresh your phone.");
635
- }
636
- return;
637
- }
638
- if (action === "reconnect") {
639
- const code = reconnectCode(await client2.computer.info(), hostname());
640
- emit("reconnect_code", { code, uri: computerPairingURI(code) });
641
- if (values.json) return;
642
- const display2 = await showPairingInvitation(code, {
643
- mode: values["no-qr"] ? "none" : values.qr ?? "auto"
644
- });
645
- try {
646
- if (question) await question("\nScan with your saved phone, then press Enter to close this code. ");
647
- else if (values.qr === "browser") {
648
- emit("waiting", {}, "This code stays open until it expires. Press Ctrl+C to close it; Mindwire keeps running.");
649
- await setTimeout$1(Math.max(0, Date.parse(code.expiresAt) - Date.now()));
650
- }
651
- } finally {
652
- display2.close();
653
- }
654
- emit("resumed", { computerId: info.computerId }, "Saved pairing kept. Mindwire continues running in the background.");
630
+ const connected = saved.filter((device) => device.connected);
631
+ emit("connected", { computerId: info.computerId, devices: connected }, `
632
+ Connected:
633
+ ${deviceSummary(connected)}
634
+ Mindwire continues running in the background.`);
635
+ emit("connect_hint", {}, "To scan again or add a phone, run mindwire reconnect.");
655
636
  return;
656
637
  }
657
638
  const invitation = await client2.computer.invite((await client2.computer.info()).routes);
@@ -662,12 +643,31 @@ ${deviceSummary(saved)}`);
662
643
  });
663
644
  try {
664
645
  let shownRequest;
646
+ let authorized = false;
665
647
  while (Date.now() < Date.parse(invitation.expiresAt)) {
666
648
  const pairing = await client2.computer.pairing(invitation.pairingId);
649
+ if (pairing.status === "waiting" && requestedAction !== "pair" && requestedAction !== "reconnect") {
650
+ const connected = (await client2.computer.devices()).filter((device) => !device.revoked && device.connected);
651
+ if (connected.length && ((info.pairingVersion ?? 0) < 2 || (await client2.computer.closeUnusedPairing(invitation.pairingId)).cancelled)) {
652
+ display?.close();
653
+ emit("connected", { computerId: info.computerId, devices: connected }, `Connected:
654
+ ${deviceSummary(connected)}
655
+ Mindwire continues running in the background.`);
656
+ return;
657
+ }
658
+ }
667
659
  if (pairing.status === "approved") {
668
660
  display?.close();
669
- emit("paired", {}, "Phone connected. You can close this terminal; Mindwire keeps running.");
670
- return;
661
+ if (pairing.completed) {
662
+ emit("paired", { computerId: info.computerId, completed: true }, "Phone connected and saved. You can close this terminal; Mindwire keeps running.");
663
+ return;
664
+ }
665
+ if (!authorized) {
666
+ authorized = true;
667
+ emit("authorized", {}, "Phone authorized. Waiting for it to save the connection\u2026");
668
+ }
669
+ await setTimeout$1(750);
670
+ continue;
671
671
  }
672
672
  if (pairing.status === "rejected") {
673
673
  display?.close();
@@ -679,14 +679,16 @@ ${deviceSummary(saved)}`);
679
679
  shownRequest = pairing.request.id;
680
680
  const key = Buffer.from(pairing.request.publicKey.split(" ")[1] ?? "", "base64");
681
681
  const fingerprint = "SHA256:" + createHash("sha256").update(key).digest("base64").replace(/=+$/, "");
682
+ const deviceId = createHash("sha256").update(key).digest("base64url");
683
+ const replacements = (info.pairingVersion ?? 0) >= 2 ? (await client2.computer.devices()).filter((device) => !device.revoked && device.id !== deviceId && device.name === pairing.request.name) : [];
682
684
  emit(
683
685
  "approval_required",
684
- { pairingId: invitation.pairingId, request: pairing.request, fingerprint },
686
+ { pairingId: invitation.pairingId, request: pairing.request, fingerprint, replacements },
685
687
  `
686
688
  ${safeName(pairing.request.name)} wants to connect.
687
689
  Device key: ${fingerprint}`
688
690
  );
689
- if (!process.stdin.isTTY) {
691
+ if (!question) {
690
692
  emit(
691
693
  "approval_command",
692
694
  { command: `mindwire approve ${invitation.pairingId} ${pairing.request.id}` },
@@ -694,18 +696,18 @@ Device key: ${fingerprint}`
694
696
  mindwire approve ${invitation.pairingId} ${pairing.request.id}`
695
697
  );
696
698
  } else {
697
- const input = createInterface({ input: process.stdin, output: process.stderr });
698
- try {
699
- const answer = await input.question("Allow this phone to access this workspace? [y/N] ");
700
- await client2.computer.decide(invitation.pairingId, pairing.request.id, /^y(es)?$/i.test(answer.trim()));
701
- } finally {
702
- input.close();
703
- }
699
+ const decision = await choosePairingDecision({ replacements, question });
700
+ await client2.computer.decide(
701
+ invitation.pairingId,
702
+ pairing.request.id,
703
+ decision.approve,
704
+ decision.replaceDeviceIds ? { replaceDeviceIds: decision.replaceDeviceIds } : {}
705
+ );
704
706
  }
705
707
  }
706
708
  await setTimeout$1(750);
707
709
  }
708
- throw new Error("Pairing expired. Run mindwire connect to show a new QR code.");
710
+ throw new Error(authorized ? "The phone was approved but did not finish saving. Run mindwire connect and scan again; its saved key will be reused." : "Connection code expired. Run mindwire connect to show a new QR code.");
709
711
  } finally {
710
712
  display?.close();
711
713
  }
@@ -749,8 +751,13 @@ ${controller.error}` : ""}`
749
751
  case "approve":
750
752
  case "reject": {
751
753
  if (!positionals[1] || !positionals[2]) throw new Error(`Use mindwire ${command} PAIRING_ID REQUEST_ID.`);
752
- await client.computer.decide(positionals[1], positionals[2], command === "approve");
753
- emit(command, {}, command === "approve" ? "Phone approved." : "Phone declined.");
754
+ await client.computer.decide(
755
+ positionals[1],
756
+ positionals[2],
757
+ command === "approve",
758
+ values["replace-device"] ? { replaceDeviceIds: values["replace-device"] } : {}
759
+ );
760
+ emit(command, {}, command === "approve" ? "Phone approved. Waiting for it to save the connection." : "Phone declined.");
754
761
  break;
755
762
  }
756
763
  case "update": {