mindwire 0.1.26 → 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.
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-QXHI4TMC.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';
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";
@@ -40,10 +40,12 @@ function escapeHTML(value) {
40
40
  return value.replace(/[&<>"']/g, (ch) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[ch]);
41
41
  }
42
42
  function browserPage(invitation, svg) {
43
+ const reconnect = "mode" in invitation && invitation.mode === "reconnect";
44
+ const title = reconnect ? "Reconnect your phone" : "Connect your phone";
43
45
  return `<!doctype html>
44
46
  <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
45
47
  <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>
48
+ <title>${title} \xB7 Mindwire</title>
47
49
  <style>
48
50
  *{box-sizing:border-box}body{margin:0;background:#141416;color:#f5f5f7;font:16px/1.5 system-ui,sans-serif}
49
51
  main{width:min(100% - 40px,520px);margin:32px auto;text-align:center}small{color:#a6a6af;letter-spacing:.16em}
@@ -55,18 +57,18 @@ button:disabled{opacity:.45;cursor:default}button:focus-visible{outline:3px soli
55
57
  textarea{width:100%;margin-top:12px;padding:8px;color:inherit;background:#252528;border:1px solid #45454b;border-radius:8px}
56
58
  [hidden]{display:none!important}#expiry{font-size:14px}footer{font-size:13px;color:#93939d;margin:18px 0}
57
59
  </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>
60
+ <small>MINDWIRE</small><h1>${title}</h1><p>${escapeHTML(invitation.name)}</p>
61
+ <div class="code" id="code" role="img" aria-label="Mindwire connection QR code">${svg}</div>
60
62
  <p id="expiry" data-expires="${escapeHTML(invitation.expiresAt)}" aria-live="off"></p>
61
63
  <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>
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
+ <button type="button" id="copy">Copy ${reconnect ? "reconnect" : "pairing"} link</button>
64
66
  <textarea id="link" readonly hidden aria-label="Pairing link">${escapeHTML(computerPairingURI(invitation))}</textarea>
65
- <footer>You can close this page after pairing.</footer>
67
+ <footer>You can close this page after ${reconnect ? "reconnecting" : "pairing"}.</footer>
66
68
  </main><script>
67
69
  const expiry=document.getElementById('expiry'),code=document.getElementById('code'),copy=document.getElementById('copy'),link=document.getElementById('link');
68
70
  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;}
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;}
70
72
  expiry.textContent='Expires in '+Math.floor(seconds/60)+':'+String(seconds%60).padStart(2,'0');}
71
73
  tick();setInterval(tick,1000);
72
74
  copy.addEventListener('click',async()=>{try{await navigator.clipboard.writeText(link.value);copy.textContent='Copied';}
@@ -90,6 +92,10 @@ async function openBrowser(url) {
90
92
  });
91
93
  }
92
94
  async function showPairingInvitation(invitation, options = {}) {
95
+ 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.";
97
+ const waiting = reconnect ? "Your saved pairing stays the same." : "Waiting for your phone\u2026";
98
+ const command = reconnect ? "mindwire reconnect" : "mindwire connect pair";
93
99
  const output = options.output ?? process.stdout;
94
100
  const environment = options.environment ?? process.env;
95
101
  const mode = options.mode ?? "auto";
@@ -99,7 +105,7 @@ async function showPairingInvitation(invitation, options = {}) {
99
105
  output.write(`Paste this link in Mindwire \u2192 Add computer (expires in 5 minutes):
100
106
  ${computerPairingURI(invitation)}
101
107
 
102
- Waiting for your phone\u2026
108
+ ${waiting}
103
109
  `);
104
110
  return { close() {
105
111
  } };
@@ -115,10 +121,10 @@ Waiting for your phone\u2026
115
121
  if (closed || !useScreen) return;
116
122
  let lines;
117
123
  if (fits()) {
118
- lines = [scanInstruction, "", ...qr.lines, "", "Waiting for your phone\u2026"];
124
+ lines = [scanInstruction, "", ...qr.lines, "", waiting];
119
125
  } else {
120
126
  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."];
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."];
122
128
  lines = lines.map((line) => line.slice(0, Math.max(0, (output.columns ?? 80) - 1))).slice(0, Math.max(0, (output.rows ?? 24) - 1));
123
129
  }
124
130
  output.write(clearScreen + lines.join("\r\n"));
@@ -144,11 +150,11 @@ Waiting for your phone\u2026
144
150
  if (useScreen) draw();
145
151
  else output.write(browser === "opened" ? `QR opened in your browser.
146
152
  ${scanInstruction}
147
- Return here to approve your phone.
153
+ ${nextStep}
148
154
  ` : `Open this local QR page in a browser:
149
- ${pageURL ?? "Page unavailable. Run mindwire connect --no-qr for a pairing link."}
155
+ ${pageURL ?? `Page unavailable. Run ${command} --no-qr for a link.`}
150
156
 
151
- Waiting for your phone\u2026
157
+ ${waiting}
152
158
  `);
153
159
  })();
154
160
  return browserAttempt;
@@ -401,11 +407,74 @@ async function watchComputer(directory, cliPath) {
401
407
  }
402
408
  }
403
409
 
410
+ // src/computer/connection-flow.ts
411
+ function connectionAction(value) {
412
+ if (value === void 0) return void 0;
413
+ if (value === "resume" || value === "reconnect" || value === "pair") return value;
414
+ throw new Error("Use mindwire connect, mindwire connect resume, mindwire reconnect, or mindwire connect pair.");
415
+ }
416
+ 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";
426
+ 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";
433
+ }
434
+ }
435
+ async function chooseStartupAction(options) {
436
+ if (options.startup && options.noStartup) throw new Error("Choose either --startup or --no-startup.");
437
+ if (options.startup) return !options.state.enabled || !options.state.running ? true : void 0;
438
+ if (options.noStartup) return options.state.kind ? void 0 : false;
439
+ if (options.state.kind) return options.state.enabled && !options.state.running ? true : void 0;
440
+ if (!options.question) return void 0;
441
+ for (; ; ) {
442
+ const answer = (await options.question(
443
+ "\nStart Mindwire automatically after you sign in to this computer? [Y/n] "
444
+ )).trim().toLowerCase();
445
+ if (["", "y", "yes"].includes(answer)) return true;
446
+ if (["n", "no"].includes(answer)) return false;
447
+ }
448
+ }
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, "");
465
+ const status = device.connected === true ? "connected" : "paired";
466
+ return ` ${name} \xB7 ${status}`;
467
+ }).join("\n");
468
+ }
469
+
404
470
  // src/cli.ts
405
471
  var help = `Mindwire \u2014 connect this computer to your phone
406
472
 
407
473
  npm install -g mindwire
408
- mindwire connect Connect across networks, show QR, approve phone
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
477
+ mindwire connect pair Pair another phone with this computer
409
478
  mindwire connect --relay none --host laptop.tailnet Use your existing VPN
410
479
  mindwire connect --relay none Direct SSH only (reachable network required)
411
480
  mindwire connect --relay cloudflare Cloudflare quick tunnel
@@ -431,7 +500,8 @@ Options:
431
500
  --json Structured output (QR invitation includes a secret)
432
501
  --qr auto|terminal|browser Fit the QR to your terminal or open a local page
433
502
  --no-qr Print only the pairing link
434
- --no-startup Pair without changing automatic startup
503
+ --startup Enable automatic startup without a prompt
504
+ --no-startup Skip automatic startup setup
435
505
 
436
506
  The default works across Wi-Fi and mobile networks. Mindwire privately downloads
437
507
  a verified Cloudflare helper when needed. Relays carry encrypted SSH.
@@ -460,6 +530,7 @@ async function main(args = process.argv.slice(2)) {
460
530
  "websocket-port": { type: "string" },
461
531
  version: { type: "string" },
462
532
  force: { type: "boolean" },
533
+ startup: { type: "boolean" },
463
534
  "no-startup": { type: "boolean" }
464
535
  } });
465
536
  const command = positionals[0] ?? "help";
@@ -468,6 +539,7 @@ async function main(args = process.argv.slice(2)) {
468
539
  return;
469
540
  }
470
541
  if (values.qr && !["auto", "terminal", "browser"].includes(values.qr)) throw new Error("Choose --qr auto, terminal or browser.");
542
+ if (values.startup && values["no-startup"]) throw new Error("Choose either --startup or --no-startup.");
471
543
  const directory = path.resolve(values["state-dir"] ?? defaultStateDirectory());
472
544
  const emit = (event, data = {}, text) => {
473
545
  if (values.json) process.stdout.write(JSON.stringify({ event, ...data }) + "\n");
@@ -488,7 +560,8 @@ async function main(args = process.argv.slice(2)) {
488
560
  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
561
  return;
490
562
  }
491
- if (["connect", "start"].includes(command)) {
563
+ if (["connect", "start", "reconnect"].includes(command)) {
564
+ const requestedAction = command === "reconnect" ? "reconnect" : command === "connect" ? connectionAction(positionals[1]) : void 0;
492
565
  const patch = {};
493
566
  if (values.directory) patch.directory = path.resolve(values.directory);
494
567
  if (values.host) patch.host = values.host;
@@ -521,22 +594,67 @@ async function main(args = process.argv.slice(2)) {
521
594
  const info = await client2.computer.info();
522
595
  emit("ready", { computerId: info.computerId, routes: info.routes }, "Mindwire is running in the background.");
523
596
  if (command === "start") return;
597
+ if (info.routes.some((route) => route.kind === "websocket")) {
598
+ emit("secure_connection", {}, "Your phone connects over end-to-end encrypted SSH. The tunnel carries encrypted traffic; only phones you approve can connect.");
599
+ }
600
+ const devices = await client2.computer.devices();
601
+ const saved = devices.filter((device) => !device.revoked);
602
+ if (saved.length) emit("saved_devices", { devices: saved }, `
603
+ Saved phones
604
+ ${deviceSummary(saved)}`);
605
+ const question = !values.json && process.stdin.isTTY ? async (prompt) => {
606
+ const input = createInterface({ input: process.stdin, output: process.stderr });
607
+ try {
608
+ return await input.question(prompt);
609
+ } finally {
610
+ input.close();
611
+ }
612
+ } : void 0;
613
+ const action = await chooseConnectionAction({ requested: requestedAction, devices, question });
524
614
  const startupPreference = await startupStatus(directory);
525
- if (!values["no-startup"] && (startupPreference.enabled || !startupPreference.kind)) {
615
+ const startupAction = await chooseStartupAction({
616
+ state: startupPreference,
617
+ startup: values.startup,
618
+ noStartup: values["no-startup"],
619
+ question
620
+ });
621
+ if (startupAction !== void 0) {
526
622
  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.");
623
+ const startup = await configureStartup(directory, fileURLToPath(import.meta.url), startupAction);
624
+ 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
625
  } 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.");
626
+ emit("startup_unavailable", {}, "Mindwire is running, but startup setup didn't complete. Run mindwire startup enable to check your system's startup service.");
531
627
  }
628
+ } else {
629
+ 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
630
  }
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.");
631
+ 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;
535
637
  }
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.");
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.");
655
+ return;
538
656
  }
539
- const invitation = await client2.computer.invite(info.routes);
657
+ const invitation = await client2.computer.invite((await client2.computer.info()).routes);
540
658
  const uri = computerPairingURI(invitation);
541
659
  emit("invitation", { invitation, uri });
542
660
  const display = values.json ? void 0 : await showPairingInvitation(invitation, {