mindwire 0.1.28 → 0.1.30

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-5WYSGXFN.js';
2
+ import { defaultStateDirectory, superviseComputer, ensureComputer, computerPairingURI, stopComputer, computerClient, SDK_VERSION, readJSON, writeJSON, currentProcessIdentity, processStateAlive, acquireProcessLock, computerPairingQRData } from './chunk-RN7ERVJ2.js';
3
3
  import './chunk-T6TD76UA.js';
4
4
  import { promisify, parseArgs } from 'util';
5
5
  import { fileURLToPath, pathToFileURL } from 'url';
@@ -7,10 +7,10 @@ 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";
@@ -63,14 +63,14 @@ textarea{width:100%;margin-top:12px;padding:8px;color:inherit;background:#252528
63
63
  <div class="code" id="code" role="img" aria-label="Mindwire connection QR code">${svg}</div>
64
64
  <p id="expiry" data-expires="${escapeHTML(invitation.expiresAt)}" aria-live="off"></p>
65
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>
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>
67
67
  <button type="button" id="copy">Copy ${reconnect ? "reconnect" : "pairing"} link</button>
68
68
  <textarea id="link" readonly hidden aria-label="Pairing link">${escapeHTML(computerPairingURI(invitation))}</textarea>
69
69
  <footer>You can close this page after ${reconnect ? "reconnecting" : "pairing"}.</footer>
70
70
  </main><script>
71
71
  const expiry=document.getElementById('expiry'),code=document.getElementById('code'),copy=document.getElementById('copy'),link=document.getElementById('link');
72
72
  function tick(){const seconds=Math.max(0,Math.ceil((Date.parse(expiry.dataset.expires)-Date.now())/1000));
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;}
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;}
74
74
  expiry.textContent='Expires in '+Math.floor(seconds/60)+':'+String(seconds%60).padStart(2,'0');}
75
75
  tick();setInterval(tick,1000);
76
76
  copy.addEventListener('click',async()=>{try{await navigator.clipboard.writeText(link.value);copy.textContent='Copied';}
@@ -96,9 +96,9 @@ async function openBrowser(url) {
96
96
  async function showPairingInvitation(invitation, options = {}) {
97
97
  const reconnect = "mode" in invitation && invitation.mode === "reconnect";
98
98
  const instruction = scanInstruction(invitation);
99
- const nextStep = reconnect ? "Scan with a saved phone, then press Enter here." : "Return here to approve your phone.";
99
+ const nextStep = reconnect ? "Scan with a saved phone." : "Approve on this computer only if asked.";
100
100
  const waiting = reconnect ? "Your saved pairing stays the same." : "Waiting for your phone\u2026";
101
- const command = reconnect ? "mindwire reconnect" : "mindwire connect pair";
101
+ const command = "mindwire connect";
102
102
  const output = options.output ?? process.stdout;
103
103
  const environment = options.environment ?? process.env;
104
104
  const mode = options.mode ?? "auto";
@@ -417,23 +417,30 @@ function connectionAction(value) {
417
417
  if (value === "resume" || value === "reconnect" || value === "pair") return value;
418
418
  throw new Error("Use mindwire connect, mindwire connect resume, mindwire reconnect, or mindwire connect pair.");
419
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;
420
+ function approvalCommand(pairingId, requestId) {
421
+ if (![pairingId, requestId].every((id) => /^[A-Za-z0-9_-]{8,128}$/.test(id))) {
422
+ throw new Error("Invalid phone pairing request. Run mindwire connect to create a new code.");
427
423
  }
428
- if (paired.length === 0) return "pair";
429
- if (!options.question) return "resume";
424
+ return `mindwire approve -- ${pairingId} ${requestId}`;
425
+ }
426
+ async function chooseConnectionAction(options) {
427
+ if (options.requested === "pair" || options.requested === "reconnect") return "pair";
428
+ return options.devices.some((device) => !device.revoked && device.connected) ? "resume" : "pair";
429
+ }
430
+ async function choosePairingDecision(options) {
431
+ const replacements = options.replacements.filter((device) => !device.revoked);
432
+ const prompt = replacements.length ? `
433
+ Previous approvals with this name:
434
+ ${deviceSummary(replacements, true)}
435
+
436
+ Allow this phone? [r = replace these approvals, a = add a different phone, N = deny]: ` : "Allow this phone to access this computer? [y/N] ";
430
437
  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";
438
+ const answer = (await options.question(prompt)).trim().toLowerCase();
439
+ if (["", "n", "no"].includes(answer)) return { approve: false };
440
+ if (replacements.length) {
441
+ if (["r", "replace"].includes(answer)) return { approve: true, replaceDeviceIds: replacements.map((device) => device.id) };
442
+ if (["a", "add"].includes(answer)) return { approve: true };
443
+ } else if (["y", "yes"].includes(answer)) return { approve: true };
437
444
  }
438
445
  }
439
446
  async function chooseStartupAction(options) {
@@ -450,20 +457,7 @@ async function chooseStartupAction(options) {
450
457
  if (["n", "no"].includes(answer)) return false;
451
458
  }
452
459
  }
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) {
460
+ function deviceSummary(devices, showKeys = false) {
467
461
  const saved = devices.filter((device) => !device.revoked);
468
462
  const names = /* @__PURE__ */ new Map();
469
463
  const safeName2 = (device) => device.name.replace(/[\x00-\x1f\x7f-\x9f]/g, "");
@@ -471,7 +465,7 @@ function deviceSummary(devices) {
471
465
  return saved.sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id)).map((device) => {
472
466
  const name = safeName2(device);
473
467
  const status = device.connected === true ? "connected" : "paired";
474
- const identity = (names.get(name) ?? 0) > 1 ? ` \xB7 key ${device.id.slice(0, 8)}` : "";
468
+ const identity = showKeys || (names.get(name) ?? 0) > 1 ? ` \xB7 key ${device.id.slice(0, 8)}` : "";
475
469
  return ` ${name} \xB7 ${status}${identity}`;
476
470
  }).join("\n");
477
471
  }
@@ -480,9 +474,8 @@ function deviceSummary(devices) {
480
474
  var help = `Mindwire \u2014 connect this computer to your phone
481
475
 
482
476
  npm install -g mindwire
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
477
+ mindwire connect Connect your phone; saved keys reconnect automatically
478
+ mindwire reconnect Show a connection QR again (also repairs a missing pairing)
486
479
  mindwire connect pair Pair another phone with this computer
487
480
  mindwire connect --relay none --host laptop.tailnet Use your existing VPN
488
481
  mindwire connect --relay none Direct SSH only (reachable network required)
@@ -511,6 +504,7 @@ Options:
511
504
  --no-qr Print only the pairing link
512
505
  --startup Enable automatic startup without a prompt
513
506
  --no-startup Skip automatic startup setup
507
+ --replace-device DEVICE_ID With approve: replace a lost phone key (repeatable)
514
508
 
515
509
  The default works across Wi-Fi and mobile networks. Mindwire privately downloads
516
510
  a verified Cloudflare helper when needed. Relays carry encrypted SSH.
@@ -540,7 +534,8 @@ async function main(args = process.argv.slice(2)) {
540
534
  version: { type: "string" },
541
535
  force: { type: "boolean" },
542
536
  startup: { type: "boolean" },
543
- "no-startup": { type: "boolean" }
537
+ "no-startup": { type: "boolean" },
538
+ "replace-device": { type: "string", multiple: true }
544
539
  } });
545
540
  const command = positionals[0] ?? "help";
546
541
  if (values.help || command === "help") {
@@ -609,7 +604,7 @@ async function main(args = process.argv.slice(2)) {
609
604
  const devices = await client2.computer.devices();
610
605
  const saved = devices.filter((device) => !device.revoked);
611
606
  if (saved.length) emit("saved_devices", { devices: saved }, `
612
- Saved phones
607
+ Saved approvals on this computer
613
608
  ${deviceSummary(saved)}`);
614
609
  const question = !values.json && process.stdin.isTTY ? async (prompt) => {
615
610
  const input = createInterface({ input: process.stdin, output: process.stderr });
@@ -619,7 +614,7 @@ ${deviceSummary(saved)}`);
619
614
  input.close();
620
615
  }
621
616
  } : void 0;
622
- const action = await chooseConnectionAction({ requested: requestedAction, devices, question });
617
+ const action = await chooseConnectionAction({ requested: requestedAction, devices });
623
618
  const startupPreference = await startupStatus(directory);
624
619
  const startupAction = await chooseStartupAction({
625
620
  state: startupPreference,
@@ -638,33 +633,12 @@ ${deviceSummary(saved)}`);
638
633
  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.");
639
634
  }
640
635
  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;
646
- }
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:
636
+ const connected = saved.filter((device) => device.connected);
637
+ emit("connected", { computerId: info.computerId, devices: connected }, `
638
+ Connected:
666
639
  ${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.");
640
+ Mindwire continues running in the background.`);
641
+ emit("connect_hint", {}, "To scan again or add a phone, run mindwire reconnect.");
668
642
  return;
669
643
  }
670
644
  const invitation = await client2.computer.invite((await client2.computer.info()).routes);
@@ -675,12 +649,31 @@ Mindwire continues running in the background.` : "Mindwire is running. Open Reco
675
649
  });
676
650
  try {
677
651
  let shownRequest;
652
+ let authorized = false;
678
653
  while (Date.now() < Date.parse(invitation.expiresAt)) {
679
654
  const pairing = await client2.computer.pairing(invitation.pairingId);
655
+ if (pairing.status === "waiting" && requestedAction !== "pair" && requestedAction !== "reconnect") {
656
+ const connected = (await client2.computer.devices()).filter((device) => !device.revoked && device.connected);
657
+ if (connected.length && ((info.pairingVersion ?? 0) < 2 || (await client2.computer.closeUnusedPairing(invitation.pairingId)).cancelled)) {
658
+ display?.close();
659
+ emit("connected", { computerId: info.computerId, devices: connected }, `Connected:
660
+ ${deviceSummary(connected)}
661
+ Mindwire continues running in the background.`);
662
+ return;
663
+ }
664
+ }
680
665
  if (pairing.status === "approved") {
681
666
  display?.close();
682
- emit("paired", {}, "Phone connected. You can close this terminal; Mindwire keeps running.");
683
- return;
667
+ if (pairing.completed) {
668
+ emit("paired", { computerId: info.computerId, completed: true }, "Phone connected and saved. You can close this terminal; Mindwire keeps running.");
669
+ return;
670
+ }
671
+ if (!authorized) {
672
+ authorized = true;
673
+ emit("authorized", {}, "Phone authorized. Waiting for it to save the connection\u2026");
674
+ }
675
+ await setTimeout$1(750);
676
+ continue;
684
677
  }
685
678
  if (pairing.status === "rejected") {
686
679
  display?.close();
@@ -692,33 +685,33 @@ Mindwire continues running in the background.` : "Mindwire is running. Open Reco
692
685
  shownRequest = pairing.request.id;
693
686
  const key = Buffer.from(pairing.request.publicKey.split(" ")[1] ?? "", "base64");
694
687
  const fingerprint = "SHA256:" + createHash("sha256").update(key).digest("base64").replace(/=+$/, "");
688
+ const deviceId = createHash("sha256").update(key).digest("base64url");
689
+ const replacements = (info.pairingVersion ?? 0) >= 2 ? (await client2.computer.devices()).filter((device) => !device.revoked && device.id !== deviceId && device.name === pairing.request.name) : [];
695
690
  emit(
696
691
  "approval_required",
697
- { pairingId: invitation.pairingId, request: pairing.request, fingerprint },
692
+ { pairingId: invitation.pairingId, request: pairing.request, fingerprint, replacements },
698
693
  `
699
694
  ${safeName(pairing.request.name)} wants to connect.
700
- Device key: ${fingerprint}`
695
+ Device key: ${fingerprint}
696
+ Access: files and commands as your computer account.`
701
697
  );
702
- if (!process.stdin.isTTY) {
703
- emit(
704
- "approval_command",
705
- { command: `mindwire approve ${invitation.pairingId} ${pairing.request.id}` },
706
- `Review this device, then run:
707
- mindwire approve ${invitation.pairingId} ${pairing.request.id}`
708
- );
698
+ if (!question) {
699
+ const command2 = approvalCommand(invitation.pairingId, pairing.request.id);
700
+ emit("approval_command", { command: command2 }, `Review this device, then run:
701
+ ${command2}`);
709
702
  } else {
710
- const input = createInterface({ input: process.stdin, output: process.stderr });
711
- try {
712
- const answer = await input.question("Allow this phone to access this workspace? [y/N] ");
713
- await client2.computer.decide(invitation.pairingId, pairing.request.id, /^y(es)?$/i.test(answer.trim()));
714
- } finally {
715
- input.close();
716
- }
703
+ const decision = await choosePairingDecision({ replacements, question });
704
+ await client2.computer.decide(
705
+ invitation.pairingId,
706
+ pairing.request.id,
707
+ decision.approve,
708
+ decision.replaceDeviceIds ? { replaceDeviceIds: decision.replaceDeviceIds } : {}
709
+ );
717
710
  }
718
711
  }
719
712
  await setTimeout$1(750);
720
713
  }
721
- throw new Error("Pairing expired. Run mindwire connect to show a new QR code.");
714
+ 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.");
722
715
  } finally {
723
716
  display?.close();
724
717
  }
@@ -762,8 +755,13 @@ ${controller.error}` : ""}`
762
755
  case "approve":
763
756
  case "reject": {
764
757
  if (!positionals[1] || !positionals[2]) throw new Error(`Use mindwire ${command} PAIRING_ID REQUEST_ID.`);
765
- await client.computer.decide(positionals[1], positionals[2], command === "approve");
766
- emit(command, {}, command === "approve" ? "Phone approved." : "Phone declined.");
758
+ await client.computer.decide(
759
+ positionals[1],
760
+ positionals[2],
761
+ command === "approve",
762
+ values["replace-device"] ? { replaceDeviceIds: values["replace-device"] } : {}
763
+ );
764
+ emit(command, {}, command === "approve" ? "Phone approved. Waiting for it to save the connection." : "Phone declined.");
767
765
  break;
768
766
  }
769
767
  case "update": {