mindwire 0.1.34 → 0.1.36

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 (39) hide show
  1. package/dist/binary-cache-CG52TXCZ.js +3 -0
  2. package/dist/{binary-cache-VM7VHCEU.js.map → binary-cache-CG52TXCZ.js.map} +1 -1
  3. package/dist/chunk-67BNDGRK.js +20 -0
  4. package/dist/chunk-67BNDGRK.js.map +1 -0
  5. package/dist/{chunk-3FR7BFAW.js → chunk-PFYKJ6LX.js} +56 -4
  6. package/dist/chunk-PFYKJ6LX.js.map +1 -0
  7. package/dist/{chunk-3QFDSYKC.js → chunk-RTGZDLSG.js} +523 -172
  8. package/dist/chunk-RTGZDLSG.js.map +1 -0
  9. package/dist/cli.js +763 -32
  10. package/dist/cli.js.map +1 -1
  11. package/dist/computer/connection-change.d.ts +13 -0
  12. package/dist/computer/connection-flow.d.ts +3 -3
  13. package/dist/computer/directory-http.d.ts +8 -0
  14. package/dist/computer/discovery.d.ts +13 -0
  15. package/dist/computer/lifecycle.d.ts +2 -1
  16. package/dist/computer/managed-relay.d.ts +27 -0
  17. package/dist/computer/oblien-auth.d.ts +25 -0
  18. package/dist/computer/provider-errors.d.ts +9 -0
  19. package/dist/computer/provider-info.d.ts +7 -0
  20. package/dist/computer/provider-setup.d.ts +20 -0
  21. package/dist/computer/relay-health.d.ts +2 -2
  22. package/dist/computer/relay.d.ts +11 -3
  23. package/dist/computer/setup-prompt.d.ts +9 -0
  24. package/dist/computer.d.ts +27 -1
  25. package/dist/connection-change-IBNL42BL.js +41 -0
  26. package/dist/connection-change-IBNL42BL.js.map +1 -0
  27. package/dist/{controller-AGT6MCJG.js → controller-2SJLZMJZ.js} +199 -279
  28. package/dist/controller-2SJLZMJZ.js.map +1 -0
  29. package/dist/{controller-handoff-3LAYBJUL.js → controller-handoff-RUUHODNC.js} +4 -4
  30. package/dist/{controller-handoff-3LAYBJUL.js.map → controller-handoff-RUUHODNC.js.map} +1 -1
  31. package/dist/index.cjs +12 -3
  32. package/dist/index.cjs.map +1 -1
  33. package/dist/index.js +12 -3
  34. package/dist/index.js.map +1 -1
  35. package/package.json +5 -5
  36. package/dist/binary-cache-VM7VHCEU.js +0 -4
  37. package/dist/chunk-3FR7BFAW.js.map +0 -1
  38. package/dist/chunk-3QFDSYKC.js.map +0 -1
  39. package/dist/controller-AGT6MCJG.js.map +0 -1
package/dist/cli.js CHANGED
@@ -1,18 +1,22 @@
1
1
  #!/usr/bin/env node
2
- import { defaultStateDirectory, superviseComputer, ensureComputer, computerPairingURI, stopComputer, computerClient, SDK_VERSION, readJSON, writeJSON, currentProcessIdentity, processStateAlive, acquireProcessLock, computerPairingQRData } from './chunk-3QFDSYKC.js';
3
- import './chunk-T6TD76UA.js';
2
+ import { connectionAddress, connectionInfo, connectionDescription } from './chunk-67BNDGRK.js';
3
+ import { defaultStateDirectory, superviseComputer, computerClient, ensureComputer, readJSON, defaultComputerConfig, computerPairingURI, stopComputer, SDK_VERSION, providerFailure, writeJSON, currentProcessIdentity, processStateAlive, acquireProcessLock, websocketURL, providerNeedsAction, cloudflareCommand, computerPairingQRData, ProviderConnectionError } from './chunk-RTGZDLSG.js';
4
+ import './chunk-PFYKJ6LX.js';
4
5
  import { promisify, parseArgs } from 'util';
5
6
  import { fileURLToPath, pathToFileURL } from 'url';
6
7
  import { realpathSync, mkdtempSync, chmodSync, writeFileSync, rmSync } from 'fs';
7
8
  import * as path from 'path';
8
9
  import { join } from 'path';
9
- import { createHash } from 'crypto';
10
+ import { createHash, randomUUID } from 'crypto';
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
+ import { homedir, hostname, tmpdir } from 'os';
14
15
  import * as fs from 'fs/promises';
15
- import { cursorTo, clearLine } from 'readline';
16
+ import { rm } from 'fs/promises';
17
+ import { cursorTo, clearLine, createInterface as createInterface$1 } from 'readline';
18
+ import { TunnelClient, Oblien } from 'oblien';
19
+ import { Writable } from 'stream';
16
20
 
17
21
  var enterScreen = "\x1B[?1049h\x1B[?25l";
18
22
  var leaveScreen = "\x1B[0m\x1B[?25h\x1B[?1049l";
@@ -425,8 +429,10 @@ function approvalCommand(pairingId, requestId) {
425
429
  return `mindwire approve -- ${pairingId} ${requestId}`;
426
430
  }
427
431
  async function chooseConnectionAction(options) {
428
- if (options.requested === "pair" || options.requested === "reconnect") return "pair";
429
- return options.devices.some((device) => !device.revoked && device.connected) ? "resume" : "pair";
432
+ if (options.requested === "pair") return "pair";
433
+ const recoverable = options.persistent || options.devices.some((device) => !device.revoked && device.addressRecovery);
434
+ if (options.requested === "reconnect" && !recoverable) return "pair";
435
+ return options.devices.some((device) => !device.revoked && (device.connected || recoverable)) ? "resume" : "pair";
430
436
  }
431
437
  async function choosePairingDecision(options) {
432
438
  const replacements = options.replacements.filter((device) => !device.revoked);
@@ -508,13 +514,664 @@ var ConnectionProgress = class {
508
514
  }
509
515
  };
510
516
 
517
+ // src/computer/directory-http.ts
518
+ var DirectoryRequestError = class extends Error {
519
+ constructor(status, code, retryAfter = 0) {
520
+ super(
521
+ status === 0 ? "Couldn't reach the address directory securely. Check its HTTPS address and retry." : `The address directory rejected the request (${code}).`
522
+ );
523
+ this.status = status;
524
+ this.code = code;
525
+ this.retryAfter = retryAfter;
526
+ }
527
+ status;
528
+ code;
529
+ retryAfter;
530
+ };
531
+ async function directoryRequest(url, path5, body) {
532
+ let response;
533
+ try {
534
+ response = await fetch(url + path5, {
535
+ method: "POST",
536
+ redirect: "error",
537
+ signal: AbortSignal.timeout(1e4),
538
+ headers: {
539
+ "Content-Type": "application/json",
540
+ Accept: "application/json"
541
+ },
542
+ body: JSON.stringify(body)
543
+ });
544
+ } catch {
545
+ throw new DirectoryRequestError(0, "unreachable");
546
+ }
547
+ const reader = response.body?.getReader();
548
+ const chunks = [];
549
+ let length = 0;
550
+ try {
551
+ if (reader)
552
+ for (; ; ) {
553
+ const { done, value } = await reader.read();
554
+ if (done) break;
555
+ length += value.byteLength;
556
+ if (length > 64 * 1024) throw new Error("The address directory returned an oversized response.");
557
+ chunks.push(value);
558
+ }
559
+ } finally {
560
+ void reader?.cancel().catch(() => {
561
+ });
562
+ }
563
+ let data;
564
+ try {
565
+ data = JSON.parse(Buffer.concat(chunks).toString("utf8"));
566
+ } catch {
567
+ throw new DirectoryRequestError(response.status, "invalid_response");
568
+ }
569
+ if (!response.ok) {
570
+ const error = data?.error;
571
+ const code = typeof error === "string" ? error : error?.code;
572
+ const retryAfter = Number(response.headers.get("Retry-After"));
573
+ throw new DirectoryRequestError(
574
+ response.status,
575
+ typeof code === "string" && /^[a-z_]{1,64}$/.test(code) ? code : "directory_error",
576
+ Number.isFinite(retryAfter) ? Math.max(0, Math.min(60, retryAfter)) : 0
577
+ );
578
+ }
579
+ return data;
580
+ }
581
+
582
+ // src/computer/discovery.ts
583
+ var DIRECTORY_API_PATH = "/api/computer-directory/v1";
584
+ var DIRECTORY_URL = `https://console.mindwire.sh${DIRECTORY_API_PATH}`;
585
+ function directoryURL(value) {
586
+ let url;
587
+ try {
588
+ url = new URL(value);
589
+ } catch {
590
+ throw new Error("Enter the directory's HTTPS API URL.");
591
+ }
592
+ if (value.length > 2048 || /[\s\x00]/.test(value) || url.username || url.password || url.search || url.hash || url.protocol !== "https:" && !(url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname))) {
593
+ throw new Error("Use an HTTPS directory URL without credentials, a query, or a fragment.");
594
+ }
595
+ if (url.pathname === "/") url.pathname = DIRECTORY_API_PATH;
596
+ if (url.pathname.endsWith("/") || url.pathname.includes("//") || url.pathname.includes("%")) {
597
+ throw new Error("Use the exact directory API URL without a trailing slash.");
598
+ }
599
+ return url.href;
600
+ }
601
+ async function enableAddressDiscovery(options) {
602
+ const lock = await acquireProcessLock(join(options.directory, "discovery-setup.lock"));
603
+ try {
604
+ let status;
605
+ try {
606
+ status = await options.computer.computer.discovery();
607
+ } catch (error) {
608
+ if (error.status === 404)
609
+ throw new Error("Update the Mindwire service before enabling automatic address recovery.");
610
+ throw error;
611
+ }
612
+ const url = directoryURL(
613
+ options.url ?? process.env.MINDWIRE_DIRECTORY_URL ?? status.url ?? DIRECTORY_URL
614
+ );
615
+ if (status.url && status.url !== url && (status.enabled || (status.sequence ?? 0) !== (status.publishedSequence ?? 0))) {
616
+ throw new Error(
617
+ "Disable the current directory with mindwire discovery disable and wait for its withdrawal before changing the directory URL."
618
+ );
619
+ }
620
+ if (!status.enabled || status.url !== url || status.error?.includes("enrollment")) {
621
+ options.prompt.print(`Enabling encrypted address recovery with ${new URL(url).host}\u2026`);
622
+ try {
623
+ const challenge = await directoryRequest(url, "/registrations/challenge", {
624
+ directoryId: status.directoryId
625
+ });
626
+ if (typeof challenge?.challenge !== "string" || challenge.challenge.length < 16 || challenge.challenge.length > 2048) {
627
+ throw new Error("The address directory returned an invalid enrollment challenge.");
628
+ }
629
+ const proof = await options.computer.computer.directoryEnrollment(url, challenge.challenge);
630
+ const enrolled = await directoryRequest(url, "/registrations", proof);
631
+ if (enrolled?.directoryId !== status.directoryId)
632
+ throw new Error("The directory acknowledged a different computer.");
633
+ } catch (error) {
634
+ if (error instanceof DirectoryRequestError) {
635
+ if (error.status === 404 || error.status === 501)
636
+ throw new Error(
637
+ "The address directory isn't available at this URL. Your existing connection still works; retry after the backend is deployed."
638
+ );
639
+ if (error.status === 409)
640
+ throw new Error(
641
+ "This computer's directory registration is revoked or conflicts with its saved identity. Contact the directory operator."
642
+ );
643
+ if (error.code === "account_required")
644
+ throw new Error(
645
+ "This directory still requires an account. Update its Mindwire backend to enable key-based registration, or choose another directory with --directory-url."
646
+ );
647
+ if (error.status === 401 || error.status === 403)
648
+ throw new Error(
649
+ "The directory couldn't verify this computer's key or challenge. Retry enrollment."
650
+ );
651
+ if (error.status === 429)
652
+ throw new Error(
653
+ "The directory's registration limit was reached. Retry later or use another directory."
654
+ );
655
+ if (error.status === 503)
656
+ throw new Error(
657
+ "The directory is temporarily unavailable or at capacity. Your existing connection still works."
658
+ );
659
+ }
660
+ throw error;
661
+ }
662
+ status = await options.computer.computer.configureDiscovery(true, url);
663
+ }
664
+ const deadline = Date.now() + 15e3;
665
+ while (Date.now() < deadline) {
666
+ status = await options.computer.computer.discovery();
667
+ if (status.enabled && status.sequence && status.publishedSequence === status.sequence) return status;
668
+ if (status.error?.includes("enrollment") || status.error?.includes("older revision"))
669
+ throw new Error(status.error);
670
+ await setTimeout$1(250);
671
+ }
672
+ throw new Error(
673
+ "Address recovery is configured and will retry in the background. Run mindwire discovery status to check publication."
674
+ );
675
+ } finally {
676
+ await lock?.close();
677
+ }
678
+ }
679
+ function discoveryDescription(status) {
680
+ if (!status?.enabled) return "Automatic address recovery is off. Enable it with mindwire discovery enable.";
681
+ if (status.error) return status.error;
682
+ return status.sequence && status.publishedSequence === status.sequence ? "Encrypted address recovery is on. Saved phones can find this computer when its tunnel address changes." : "Publishing the computer's encrypted address\u2026";
683
+ }
684
+ var BASE = "https://api.oblien.com";
685
+ var RENEW_BEFORE_MS = 24 * 60 * 6e4;
686
+ var ProviderSignInRequired = class extends Error {
687
+ code = "provider_sign_in";
688
+ constructor(provider = "Oblien") {
689
+ super(`Sign in to ${provider} again with mindwire connection ${provider.toLowerCase()}. Your computer and phone keys are unchanged.`);
690
+ }
691
+ };
692
+ function tokenExpiry(token) {
693
+ try {
694
+ const value = JSON.parse(Buffer.from(token.split(".")[1] ?? "", "base64url").toString());
695
+ return typeof value.exp === "number" && Number.isFinite(value.exp) ? value.exp * 1e3 : void 0;
696
+ } catch {
697
+ return void 0;
698
+ }
699
+ }
700
+ function oblienClient(credentials) {
701
+ const baseUrl = credentials.baseUrl ?? BASE;
702
+ const url = new URL(baseUrl);
703
+ if (url.protocol !== "https:" || url.username || url.password) throw new Error("Oblien requires an HTTPS API address without embedded credentials.");
704
+ if (credentials.clientId && credentials.clientSecret) return boundedOblienClient(new Oblien({ baseUrl, clientId: credentials.clientId, clientSecret: credentials.clientSecret }));
705
+ if (credentials.token) return boundedOblienClient(new Oblien({ baseUrl, token: credentials.token }));
706
+ throw new ProviderSignInRequired();
707
+ }
708
+ function boundedOblienClient(client, timeoutMs = 15e3) {
709
+ const request = client._http.request.bind(client._http);
710
+ client._http.request = async (options) => {
711
+ const controller = new AbortController();
712
+ const cancel = () => controller.abort(options.signal?.reason);
713
+ const timer = setTimeout(() => controller.abort(new Error("Oblien's API did not respond in time.")), timeoutMs);
714
+ options.signal?.addEventListener("abort", cancel, { once: true });
715
+ if (options.signal?.aborted) cancel();
716
+ try {
717
+ return await request({ ...options, signal: controller.signal });
718
+ } finally {
719
+ clearTimeout(timer);
720
+ options.signal?.removeEventListener("abort", cancel);
721
+ }
722
+ };
723
+ return client;
724
+ }
725
+ function requiresSignIn(error) {
726
+ return error instanceof ProviderSignInRequired || [401, 403].includes(error?.status ?? 0);
727
+ }
728
+ async function authenticatedOblien(file, options = {}) {
729
+ const lock = await acquireProcessLock(file + ".lock");
730
+ try {
731
+ const credentials = await readJSON(file);
732
+ if (!credentials) throw new ProviderSignInRequired();
733
+ const client = (options.client ?? oblienClient)(credentials);
734
+ if (!credentials.token || credentials.clientId && credentials.clientSecret) return client;
735
+ const now = (options.now ?? Date.now)(), expires = tokenExpiry(credentials.token);
736
+ if (expires !== void 0 && expires <= now) throw new ProviderSignInRequired();
737
+ if (expires !== void 0 && expires - now > RENEW_BEFORE_MS) return client;
738
+ try {
739
+ const renewed = await client._http.request({
740
+ method: "POST",
741
+ path: "/auth/token",
742
+ body: { old_token: credentials.token },
743
+ signal: AbortSignal.timeout(15e3)
744
+ });
745
+ if (!renewed.token) throw new ProviderSignInRequired();
746
+ const next = (options.client ?? oblienClient)({ ...credentials, token: renewed.token });
747
+ await next._http.request({ method: "GET", path: "/settings/profile", signal: AbortSignal.timeout(15e3) });
748
+ await writeJSON(file, { ...credentials, token: renewed.token });
749
+ return next;
750
+ } catch (error) {
751
+ if (requiresSignIn(error)) throw new ProviderSignInRequired();
752
+ throw error;
753
+ }
754
+ } finally {
755
+ await lock?.close();
756
+ }
757
+ }
758
+ async function signInOblien(file, prompt) {
759
+ const candidates = await Promise.all([
760
+ readJSON(file),
761
+ readJSON(path.join(homedir(), ".oblien", "credentials.json"))
762
+ ]);
763
+ for (const existing of candidates) {
764
+ if (!existing || !(existing.token || existing.clientId && existing.clientSecret)) continue;
765
+ const temporary = `${file}.login-${randomUUID()}.json`;
766
+ await writeJSON(temporary, existing);
767
+ try {
768
+ const client2 = await authenticatedOblien(temporary);
769
+ await client2._http.request({ method: "GET", path: "/settings/profile", signal: AbortSignal.timeout(15e3) });
770
+ await writeJSON(file, await readJSON(temporary));
771
+ return client2;
772
+ } catch (error) {
773
+ if (!requiresSignIn(error)) throw error;
774
+ } finally {
775
+ await rm(temporary, { force: true });
776
+ }
777
+ }
778
+ const client = boundedOblienClient(new Oblien({ token: "", baseUrl: BASE }));
779
+ const session = await client._http.request({ method: "POST", path: "/auth/token", signal: AbortSignal.timeout(15e3) });
780
+ if (!session.token) throw new Error("Oblien couldn't start sign-in. Try again shortly.");
781
+ client.setToken(session.token);
782
+ const login = await client._http.request({
783
+ method: "POST",
784
+ path: "/auth/session",
785
+ body: { app: "oblien", redirect_url: "https://oblien.com/cli?login=success" },
786
+ signal: AbortSignal.timeout(15e3)
787
+ });
788
+ const url = new URL(login.url);
789
+ if (url.protocol !== "https:" || url.hostname !== "auth.oblien.com" || url.username || url.password) throw new Error("Oblien returned an invalid sign-in link.");
790
+ const id = url.pathname.split("/").filter(Boolean).pop();
791
+ if (!id || !/^[\w-]+$/.test(id)) throw new Error("Oblien returned an invalid sign-in session.");
792
+ prompt.print(`
793
+ Sign in to Oblien in your browser:
794
+ ${url.href}
795
+
796
+ Waiting for sign-in\u2026`);
797
+ prompt.open(url.href);
798
+ const deadline = Date.now() + 5e3 * 60;
799
+ while (Date.now() < deadline) {
800
+ await setTimeout$1(2e3);
801
+ const result = await client._http.request({ method: "GET", path: `/auth/session/${id}/poll`, signal: AbortSignal.timeout(15e3) });
802
+ if (result.status === "expired") throw new Error("Oblien sign-in expired. Run mindwire connection oblien to retry.");
803
+ if (result.status !== "authenticated") continue;
804
+ await client._http.request({ method: "GET", path: "/settings/profile", signal: AbortSignal.timeout(15e3) });
805
+ await writeJSON(file, { token: session.token });
806
+ prompt.print("Signed in to Oblien.");
807
+ return client;
808
+ }
809
+ throw new Error("Oblien sign-in timed out. Run mindwire connection oblien to retry.");
810
+ }
811
+
812
+ // src/computer/provider-setup.ts
813
+ var execute2 = promisify(execFile);
814
+ async function chooseProvider(prompt) {
815
+ prompt.print("\nConnect this computer\n\n 1 Automatic \u2014 free Cloudflare tunnel + Mindwire address recovery, no account\n 2 Oblien \u2014 persistent address, browser sign-in\n 3 Cloudflare \u2014 your account and domain\n 4 ngrok \u2014 your account and reserved domain\n 5 Your VPN \u2014 direct SSH\n 6 Existing secure tunnel\n 7 Temporary Cloudflare connection \u2014 no address recovery\n");
816
+ for (; ; ) {
817
+ const answer = (await prompt.question("Connection [1]: ")).trim();
818
+ const provider = ["automatic", "oblien", "cloudflare", "ngrok", "vpn", "custom", "temporary"][Number(answer || "1") - 1];
819
+ if (provider) return provider;
820
+ prompt.print("Choose 1\u20137.");
821
+ }
822
+ }
823
+ function connectionProvider(value) {
824
+ if (["oblien", "cloudflare", "ngrok", "vpn", "custom", "temporary", "automatic"].includes(value)) return value;
825
+ throw new Error("Choose automatic, oblien, cloudflare, ngrok, vpn, custom or temporary.");
826
+ }
827
+ function providerHostname(value) {
828
+ const url = new URL(websocketURL(value.includes("://") ? value : `https://${value}`));
829
+ if (url.port || url.search || url.pathname !== "/ssh" || !url.hostname.includes(".") || /[\s]/.test(value)) {
830
+ throw new Error("Enter the configured public hostname, for example computer.example.com.");
831
+ }
832
+ return url.hostname;
833
+ }
834
+ async function prepareOblienTunnel(client, stateFile, port) {
835
+ let state = await readJSON(stateFile);
836
+ if (!state) {
837
+ state = { installationId: randomUUID() };
838
+ await writeJSON(stateFile, state);
839
+ }
840
+ if (state.oblien) {
841
+ try {
842
+ const { tunnel: tunnel2 } = await client.edgeTunnel.get(state.oblien.id);
843
+ if (tunnel2.status !== "active") throw new Error("This computer's Oblien tunnel is disabled. Enable it in your Oblien dashboard, then retry.");
844
+ if (tunnel2.port !== port) await client.edgeTunnel.update(tunnel2.id, { port });
845
+ const saved2 = { id: tunnel2.id, url: websocketURL(tunnel2.url) };
846
+ await writeJSON(stateFile, { ...state, oblien: saved2 });
847
+ return saved2;
848
+ } catch (error) {
849
+ if (error?.status !== 404) throw error;
850
+ }
851
+ }
852
+ const slug = `mw-${state.installationId.replaceAll("-", "")}`;
853
+ const { tunnels } = await client.edgeTunnel.list();
854
+ const existing = tunnels.find((tunnel2) => tunnel2.slug === slug);
855
+ const tunnel = existing ?? (await client.edgeTunnel.create({ name: `Mindwire \xB7 ${hostname()}`, slug, port })).tunnel;
856
+ if (tunnel.status !== "active") throw new Error("This computer's tunnel is disabled in Oblien.");
857
+ if (tunnel.port !== port) await client.edgeTunnel.update(tunnel.id, { port });
858
+ const saved = { id: tunnel.id, url: websocketURL(tunnel.url) };
859
+ await writeJSON(stateFile, { ...state, oblien: saved });
860
+ return saved;
861
+ }
862
+ async function runVisible(command, args) {
863
+ await new Promise((resolve2, reject) => {
864
+ const child = spawn(command, args, { stdio: "inherit", windowsHide: true });
865
+ child.once("error", () => reject(new Error("Couldn't open the provider's login helper. Try again.")));
866
+ child.once("exit", (code) => code === 0 ? resolve2() : reject(new Error("Provider setup didn't finish. Your existing connection is unchanged.")));
867
+ });
868
+ }
869
+ async function setupCloudflare(directory, stateFile, prompt) {
870
+ const state = await readJSON(stateFile);
871
+ const domain = providerHostname(await prompt.question("Cloudflare hostname (for example computer.example.com): "));
872
+ const command = await cloudflareCommand({ cacheDir: path.join(directory, "tools"), onProgress: (text) => prompt.print(text) });
873
+ let saved = state.cloudflare;
874
+ if (!saved) {
875
+ const certificates = process.env.TUNNEL_ORIGIN_CERT ? [process.env.TUNNEL_ORIGIN_CERT] : [path.join(homedir(), ".cloudflared", "cert.pem"), path.join(homedir(), ".cloudflare-warp", "cert.pem")];
876
+ const hasLogin = (await Promise.all(certificates.map((file) => fs.stat(file).then((value) => value.isFile(), () => false)))).some(Boolean);
877
+ if (!hasLogin) {
878
+ prompt.print("\nSign in to Cloudflare and select the zone for this hostname.\nCloudflare will store its login certificate on this computer.");
879
+ await runVisible(command, ["tunnel", "login"]);
880
+ } else prompt.print("Using your saved Cloudflare login.");
881
+ const name = `mindwire-${state.installationId}`;
882
+ const credentialsFile = path.join(directory, "providers", "cloudflare-tunnel.json");
883
+ let credentials = await readJSON(credentialsFile);
884
+ if (!credentials?.TunnelID) {
885
+ try {
886
+ await execute2(command, ["tunnel", "create", "--credentials-file", credentialsFile, name], { timeout: 6e4, maxBuffer: 65536 });
887
+ } catch {
888
+ throw new Error("Cloudflare couldn't create the named tunnel. Check your account and run mindwire connection cloudflare again.");
889
+ }
890
+ credentials = await readJSON(credentialsFile);
891
+ }
892
+ if (!credentials?.TunnelID || !/^[a-f0-9-]{36}$/i.test(credentials.TunnelID)) throw new Error("Cloudflare didn't save the tunnel credentials. Retry setup.");
893
+ await fs.chmod(credentialsFile, 384);
894
+ saved = { id: credentials.TunnelID, credentialsFile };
895
+ await writeJSON(stateFile, { ...state, cloudflare: saved });
896
+ }
897
+ try {
898
+ await execute2(command, ["tunnel", "route", "dns", saved.id, domain], { timeout: 6e4, maxBuffer: 65536 });
899
+ } catch {
900
+ throw new Error("Cloudflare couldn't assign this hostname. Check the zone and existing DNS record, then retry. Mindwire does not overwrite another service's DNS.");
901
+ }
902
+ return { kind: "cloudflare", url: `wss://${domain}/ssh`, cloudflareTunnelId: saved.id, cloudflareCredentialsFile: saved.credentialsFile };
903
+ }
904
+ async function setupProvider(options) {
905
+ const { directory, provider, prompt } = options;
906
+ await fs.mkdir(path.join(directory, "providers"), { recursive: true, mode: 448 });
907
+ const lock = await acquireProcessLock(path.join(directory, "connection-setup.lock"));
908
+ try {
909
+ const config = options.config ?? defaultComputerConfig();
910
+ const stateFile = path.join(directory, "computer-providers.json");
911
+ if (!await readJSON(stateFile)) await writeJSON(stateFile, { installationId: randomUUID() });
912
+ switch (provider) {
913
+ case "automatic":
914
+ prompt.print("No Mindwire or Oblien account is needed. Cloudflare carries encrypted SSH traffic; the Mindwire directory helps your paired phone find this computer when its address changes. Use --directory-url or MINDWIRE_DIRECTORY_URL for a self-hosted directory.");
915
+ return { relay: { kind: "cloudflare" } };
916
+ case "temporary":
917
+ return { relay: { kind: "cloudflare" } };
918
+ case "vpn": {
919
+ prompt.print("Use a hostname reachable from your phone's VPN, such as your Tailscale or WireGuard network. Mindwire still checks your paired SSH keys.");
920
+ const host = (await prompt.question("Computer's VPN hostname or IP: ")).trim();
921
+ if (!host || host.length > 255 || /[\s/\\?#@]/.test(host)) throw new Error("Enter a valid VPN hostname or IP address.");
922
+ return { relay: { kind: "none" }, host };
923
+ }
924
+ case "custom": {
925
+ prompt.print(`Forward WebSocket traffic to http://127.0.0.1:${config.websocketPort || 8792}. Keep your tunnel running at a persistent HTTPS hostname.`);
926
+ return { relay: { kind: "custom", url: websocketURL(await prompt.question("Secure tunnel URL: ")) } };
927
+ }
928
+ case "cloudflare":
929
+ return { relay: await setupCloudflare(directory, stateFile, prompt) };
930
+ case "ngrok": {
931
+ const url = "https://dashboard.ngrok.com/get-started/your-authtoken";
932
+ prompt.print(`
933
+ Sign in to ngrok to get this computer's authtoken:
934
+ ${url}
935
+ The token stays on this computer and is never sent to your phone.`);
936
+ prompt.open(url);
937
+ const token = await prompt.secret("ngrok authtoken (hidden): ");
938
+ if (!token || token.length > 4096 || /\s/.test(token)) throw new Error("Enter a valid ngrok authtoken.");
939
+ prompt.print("Use the dev domain or reserved domain shown at https://dashboard.ngrok.com/domains. Each computer needs its own address.");
940
+ const host = providerHostname(await prompt.question("ngrok domain: "));
941
+ const tokenFile = path.join(directory, "providers", `ngrok-${randomUUID()}.json`);
942
+ await writeJSON(tokenFile, { token });
943
+ return { relay: { kind: "ngrok", url: `wss://${host}/ssh`, ngrokTokenFile: tokenFile } };
944
+ }
945
+ case "oblien": {
946
+ const credentialsFile = path.join(directory, "providers", `oblien-${randomUUID()}.json`);
947
+ const existing = config.relay.oblienCredentialsFile && await readJSON(config.relay.oblienCredentialsFile);
948
+ if (existing) await writeJSON(credentialsFile, existing);
949
+ try {
950
+ const client = await signInOblien(credentialsFile, prompt);
951
+ const runtime = await readJSON(path.join(directory, "computer-runtime.json"));
952
+ const tunnel = await prepareOblienTunnel(client, stateFile, runtime?.websocketPort || config.websocketPort || 8792);
953
+ return { relay: { kind: "oblien", url: tunnel.url, oblienTunnelId: tunnel.id, oblienCredentialsFile: credentialsFile } };
954
+ } catch (error) {
955
+ await fs.rm(credentialsFile, { force: true });
956
+ throw error;
957
+ }
958
+ }
959
+ }
960
+ throw new Error("Unsupported connection provider.");
961
+ } finally {
962
+ await lock?.close();
963
+ }
964
+ }
965
+ function openSetupURL(value) {
966
+ const url = new URL(value);
967
+ if (url.protocol !== "https:" || url.username || url.password) throw new Error("Invalid provider login URL.");
968
+ const [command, args] = process.platform === "darwin" ? ["open", [url.href]] : process.platform === "win32" ? ["rundll32.exe", ["url.dll,FileProtocolHandler", url.href]] : ["xdg-open", [url.href]];
969
+ const child = spawn(command, [...args], { stdio: "ignore", windowsHide: true });
970
+ child.on("error", () => {
971
+ });
972
+ child.unref();
973
+ }
974
+ function question(text, hidden) {
975
+ if (!process.stdin.isTTY) throw new Error("Connection setup needs an interactive terminal, or explicit provider options.");
976
+ process.stderr.write(text);
977
+ const output = hidden ? new Writable({ write(_chunk, _encoding, done) {
978
+ done();
979
+ } }) : process.stderr;
980
+ const input = createInterface$1({ input: process.stdin, output, terminal: true });
981
+ return new Promise((resolve2, reject) => {
982
+ let answered = false;
983
+ input.once("SIGINT", () => {
984
+ input.close();
985
+ });
986
+ input.once("close", () => {
987
+ if (!answered) reject(new Error("Connection setup cancelled."));
988
+ });
989
+ input.question("", (value) => {
990
+ answered = true;
991
+ input.close();
992
+ if (hidden) process.stderr.write("\n");
993
+ resolve2(value.trim());
994
+ });
995
+ });
996
+ }
997
+ var terminalSetupPrompt = {
998
+ question: (text) => question(text, false),
999
+ secret: (text) => question(text, true),
1000
+ print: (text) => {
1001
+ process.stderr.write(text + "\n");
1002
+ },
1003
+ open: openSetupURL
1004
+ };
1005
+ var OblienTunnelLease = class {
1006
+ constructor(tunnelId, client, now = Date.now) {
1007
+ this.tunnelId = tunnelId;
1008
+ this.client = client;
1009
+ this.now = now;
1010
+ }
1011
+ tunnelId;
1012
+ client;
1013
+ now;
1014
+ value;
1015
+ pending;
1016
+ get(force = false) {
1017
+ if (this.pending) return this.pending;
1018
+ const expires = this.value && (Date.parse(this.value.expires_at) || tokenExpiry(this.value.token) || 0);
1019
+ if (!force && this.value && expires - this.now() > 5 * 6e4) return Promise.resolve(this.value);
1020
+ const pending = (async () => {
1021
+ const client = await this.client();
1022
+ const value = await client.edgeTunnel.issueToken(this.tunnelId);
1023
+ const url = new URL(value.connect_url);
1024
+ if (url.protocol !== "wss:" || url.username || url.password || url.hash) {
1025
+ throw new ProviderConnectionError("provider_configuration", "Oblien returned an invalid broker address.");
1026
+ }
1027
+ const expiry = Date.parse(value.expires_at) || tokenExpiry(value.token) || 0;
1028
+ if (!value.token || expiry - this.now() < 3e4) throw new Error("Oblien returned a token too close to expiry. Retry shortly.");
1029
+ this.value = value;
1030
+ return value;
1031
+ })();
1032
+ this.pending = pending;
1033
+ void pending.finally(() => {
1034
+ if (this.pending === pending) this.pending = void 0;
1035
+ }).catch(() => {
1036
+ });
1037
+ return pending;
1038
+ }
1039
+ };
1040
+ async function runOblien(options, port, signal) {
1041
+ if (!options.oblienCredentialsFile || !options.oblienTunnelId || !options.url) throw new Error("Oblien setup is incomplete. Run mindwire connection oblien.");
1042
+ const credentialsFile = options.oblienCredentialsFile;
1043
+ const lease = new OblienTunnelLease(options.oblienTunnelId, () => authenticatedOblien(credentialsFile));
1044
+ let failures = 0, authFailures = 0, forceToken = false;
1045
+ while (!signal.aborted) {
1046
+ let carrier;
1047
+ try {
1048
+ const token = await lease.get(forceToken);
1049
+ forceToken = false;
1050
+ signal.throwIfAborted();
1051
+ carrier = new TunnelClient({ connectUrl: token.connect_url, token: token.token, localPort: port, tokenExpiresAt: token.expires_at });
1052
+ const tunnel = carrier;
1053
+ let opened = false;
1054
+ let connectionError;
1055
+ let finish;
1056
+ const ended = new Promise((resolve2) => {
1057
+ finish = resolve2;
1058
+ });
1059
+ let endedValue = false;
1060
+ const end = () => {
1061
+ endedValue = true;
1062
+ finish();
1063
+ };
1064
+ tunnel.on("open", () => {
1065
+ opened = true;
1066
+ failures = 0;
1067
+ authFailures = 0;
1068
+ process.stdout.write(JSON.stringify({ event: "relay_ready", url: options.url }) + "\n");
1069
+ });
1070
+ tunnel.on("disconnect", end);
1071
+ tunnel.on("auth-failed", () => {
1072
+ connectionError = new ProviderSignInRequired();
1073
+ end();
1074
+ });
1075
+ tunnel.on("error", (error) => {
1076
+ connectionError = error;
1077
+ end();
1078
+ });
1079
+ const abort = () => {
1080
+ tunnel.close();
1081
+ end();
1082
+ };
1083
+ signal.addEventListener("abort", abort, { once: true });
1084
+ try {
1085
+ tunnel.connect();
1086
+ const started = Date.now();
1087
+ while (!endedValue && !signal.aborted) {
1088
+ const tick = new AbortController();
1089
+ try {
1090
+ await Promise.race([ended, setTimeout$1(opened ? 6e4 : 1e3, void 0, { signal: tick.signal })]);
1091
+ } finally {
1092
+ tick.abort();
1093
+ }
1094
+ if (!opened && Date.now() - started > 2e4) break;
1095
+ if (opened && !endedValue) {
1096
+ await lease.get().catch(() => {
1097
+ });
1098
+ }
1099
+ }
1100
+ if (connectionError) throw connectionError;
1101
+ } finally {
1102
+ signal.removeEventListener("abort", abort);
1103
+ }
1104
+ } catch (error) {
1105
+ if (signal.aborted) return;
1106
+ const failure = providerFailure(error, "Oblien");
1107
+ if (failure.code === "provider_sign_in" && carrier && ++authFailures === 1) forceToken = true;
1108
+ else if (providerNeedsAction(failure.code)) throw failure;
1109
+ } finally {
1110
+ carrier?.close();
1111
+ }
1112
+ if (!signal.aborted) await setTimeout$1(Math.min(3e4, 1e3 * 2 ** Math.min(5, failures++)), void 0, { signal });
1113
+ }
1114
+ }
1115
+ async function runNgrok(options, port, signal) {
1116
+ if (!options.ngrokTokenFile || !options.url) throw new Error("ngrok setup is incomplete. Run mindwire connection ngrok.");
1117
+ const credentials = await readJSON(options.ngrokTokenFile);
1118
+ if (!credentials?.token) throw new ProviderSignInRequired("ngrok");
1119
+ const ngrok = await import('@ngrok/ngrok');
1120
+ const domain = new URL(websocketURL(options.url)).hostname;
1121
+ const listener = await ngrok.forward({
1122
+ addr: `http://127.0.0.1:${port}`,
1123
+ authtoken: credentials.token,
1124
+ domain,
1125
+ schemes: ["https"],
1126
+ onStatusChange: () => {
1127
+ }
1128
+ });
1129
+ try {
1130
+ if (websocketURL(listener.url() ?? "") !== websocketURL(options.url)) throw new Error("ngrok returned a different address.");
1131
+ process.stdout.write(JSON.stringify({ event: "relay_ready", url: options.url }) + "\n");
1132
+ if (!signal.aborted) await new Promise((resolve2) => signal.addEventListener("abort", () => resolve2(), { once: true }));
1133
+ } finally {
1134
+ await listener.close();
1135
+ await ngrok.disconnect();
1136
+ }
1137
+ }
1138
+ async function runManagedRelay(file) {
1139
+ const config = await readJSON(file);
1140
+ if (!config || !Number.isInteger(config.port) || config.port < 1 || config.port > 65535) throw new Error("Invalid relay configuration.");
1141
+ const abort = new AbortController(), stop = () => abort.abort();
1142
+ process.on("SIGTERM", stop);
1143
+ process.on("SIGINT", stop);
1144
+ try {
1145
+ if (config.options.kind === "oblien") await runOblien(config.options, config.port, abort.signal);
1146
+ else if (config.options.kind === "ngrok") await runNgrok(config.options, config.port, abort.signal);
1147
+ else throw new Error("Unsupported managed relay.");
1148
+ } catch (error) {
1149
+ if (!abort.signal.aborted) {
1150
+ const failure = providerFailure(error, config.options.kind === "oblien" ? "Oblien" : "ngrok");
1151
+ process.stdout.write(JSON.stringify({ event: "relay_error", message: failure.message, code: failure.code }) + "\n");
1152
+ process.exitCode = 1;
1153
+ }
1154
+ } finally {
1155
+ process.off("SIGTERM", stop);
1156
+ process.off("SIGINT", stop);
1157
+ }
1158
+ }
1159
+
511
1160
  // src/cli.ts
512
1161
  var help = `Mindwire \u2014 connect this computer to your phone
513
1162
 
514
1163
  npm install -g mindwire
515
1164
  mindwire connect Connect your phone; saved keys reconnect automatically
516
- mindwire reconnect Show a connection QR again (also repairs a missing pairing)
517
- mindwire connect pair Pair another phone with this computer
1165
+ mindwire reconnect Retry the saved connection
1166
+ mindwire connect pair Pair a phone, or repair a lost phone key/address
1167
+ mindwire connection Choose a persistent connection provider
1168
+ mindwire connection automatic Cloudflare traffic with encrypted Mindwire address recovery
1169
+ mindwire connection oblien Sign in and use an Oblien tunnel
1170
+ mindwire connection cloudflare Sign in and configure your Cloudflare domain
1171
+ mindwire connection ngrok Connect your ngrok account and reserved domain
1172
+ mindwire discovery enable Enable encrypted address recovery (no account)
1173
+ mindwire discovery status Check encrypted address recovery
1174
+ mindwire discovery disable Withdraw the saved address directory
518
1175
  mindwire connect --relay none --host laptop.tailnet Use your existing VPN
519
1176
  mindwire connect --relay none Direct SSH only (reachable network required)
520
1177
  mindwire connect --relay cloudflare Cloudflare quick tunnel
@@ -533,9 +1190,13 @@ var help = `Mindwire \u2014 connect this computer to your phone
533
1190
  Options:
534
1191
  --directory PATH Initial workspace directory (default: home)
535
1192
  --host HOST Reachable LAN, public or VPN hostname
536
- --relay none|cloudflare|ngrok|custom Provider (default: Cloudflare internet tunnel)
1193
+ --relay none|oblien|cloudflare|ngrok|custom Explicit provider (skips the setup menu)
537
1194
  --relay-url URL Stable configured WebSocket hostname
538
1195
  --cloudflare-token-file PATH Named Cloudflare tunnel token file
1196
+ --ngrok-token-file PATH Private JSON file containing {"token":"\u2026"}
1197
+ --oblien-credentials-file PATH Private Oblien session/API credential JSON
1198
+ --oblien-tunnel-id ID This computer's existing Oblien tunnel ID
1199
+ --directory-url URL Directory HTTPS API URL (or MINDWIRE_DIRECTORY_URL)
539
1200
  --state-dir PATH Private state directory
540
1201
  --json Structured output (QR invitation includes a secret)
541
1202
  --qr auto|terminal|browser Fit the QR to your terminal or open a local page
@@ -544,10 +1205,10 @@ Options:
544
1205
  --no-startup Skip automatic startup setup
545
1206
  --replace-device DEVICE_ID With approve: replace a lost phone key (repeatable)
546
1207
 
547
- The default works across Wi-Fi and mobile networks. Mindwire privately downloads
548
- a verified Cloudflare helper when needed. Relays carry encrypted SSH.
549
- Quick tunnel addresses change on restart; use a stable hostname/VPN for regular use.
550
- ngrok needs its installed CLI and account. Direct SSH needs no relay when reachable.
1208
+ Interactive setup offers persistent addresses across Wi-Fi and mobile networks.
1209
+ Provider logins and tokens stay on your computer. Relays carry encrypted SSH;
1210
+ they can see connection metadata, but cannot decrypt files, commands or chats.
1211
+ Quick Cloudflare tunnels remain available for temporary use. Direct SSH needs a reachable network or VPN.
551
1212
  Nothing changes your system SSH login.
552
1213
  `;
553
1214
  function safeName(text) {
@@ -565,6 +1226,11 @@ async function main(args = process.argv.slice(2)) {
565
1226
  relay: { type: "string" },
566
1227
  "relay-url": { type: "string" },
567
1228
  "cloudflare-token-file": { type: "string" },
1229
+ "ngrok-token-file": { type: "string" },
1230
+ "oblien-credentials-file": { type: "string" },
1231
+ "oblien-tunnel-id": { type: "string" },
1232
+ "relay-config": { type: "string" },
1233
+ "directory-url": { type: "string" },
568
1234
  "daemon-bin": { type: "string" },
569
1235
  bind: { type: "string" },
570
1236
  port: { type: "string" },
@@ -583,6 +1249,7 @@ async function main(args = process.argv.slice(2)) {
583
1249
  if (values.qr && !["auto", "terminal", "browser"].includes(values.qr)) throw new Error("Choose --qr auto, terminal or browser.");
584
1250
  if (values.startup && values["no-startup"]) throw new Error("Choose either --startup or --no-startup.");
585
1251
  const directory = path.resolve(values["state-dir"] ?? defaultStateDirectory());
1252
+ const discoveryOptions = { url: values["directory-url"] };
586
1253
  const emit = (event, data = {}, text) => {
587
1254
  if (values.json) process.stdout.write(JSON.stringify({ event, ...data }) + "\n");
588
1255
  else if (text) process.stdout.write(text + "\n");
@@ -591,6 +1258,11 @@ async function main(args = process.argv.slice(2)) {
591
1258
  await superviseComputer(directory);
592
1259
  return;
593
1260
  }
1261
+ if (command === "_relay") {
1262
+ if (!values["relay-config"]) throw new Error("Missing relay configuration.");
1263
+ await runManagedRelay(path.resolve(values["relay-config"]));
1264
+ return;
1265
+ }
594
1266
  if (command === "_watch") {
595
1267
  await watchComputer(directory, fileURLToPath(import.meta.url));
596
1268
  return;
@@ -602,9 +1274,20 @@ async function main(args = process.argv.slice(2)) {
602
1274
  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.");
603
1275
  return;
604
1276
  }
605
- if (["connect", "start", "reconnect"].includes(command)) {
606
- const requestedAction = command === "reconnect" ? "reconnect" : command === "connect" ? connectionAction(positionals[1]) : void 0;
1277
+ if (command === "discovery") {
1278
+ const action = positionals[1] ?? "status";
1279
+ if (!["enable", "disable", "status"].includes(action)) throw new Error("Use mindwire discovery enable, disable or status.");
1280
+ const client2 = action === "status" ? await computerClient(directory) : await ensureComputer(directory, fileURLToPath(import.meta.url), {}, void 0, { waitForRoutes: false });
1281
+ const status = action === "enable" ? await enableAddressDiscovery({ directory, computer: client2, prompt: terminalSetupPrompt, ...discoveryOptions }) : action === "disable" ? await client2.computer.configureDiscovery(false) : await client2.computer.discovery();
1282
+ emit("discovery", status, discoveryDescription(status));
1283
+ if (action === "enable") emit("discovery_hint", {}, "Open each saved phone once while its current address works. Pair new phones with mindwire connect pair.");
1284
+ return;
1285
+ }
1286
+ if (["connect", "start", "reconnect", "connection"].includes(command)) {
1287
+ let requestedAction = command === "reconnect" ? "reconnect" : command === "connect" ? connectionAction(positionals[1]) : void 0;
1288
+ let automaticDiscovery = false, addressChanged = false;
607
1289
  const patch = {};
1290
+ const previous = await readJSON(path.join(directory, "computer-config.json"));
608
1291
  if (values.directory) patch.directory = path.resolve(values.directory);
609
1292
  if (values.host) patch.host = values.host;
610
1293
  if (values.bind) patch.bind = values.bind;
@@ -617,33 +1300,64 @@ async function main(args = process.argv.slice(2)) {
617
1300
  }
618
1301
  if (values["daemon-bin"] || process.env.MINDWIRE_DAEMON) patch.daemonBin = path.resolve(values["daemon-bin"] ?? process.env.MINDWIRE_DAEMON);
619
1302
  if (values.version) patch.version = values.version.replace(/^v/, "");
620
- if (values.relay || values["relay-url"] || values["cloudflare-token-file"]) {
1303
+ if (values.relay || values["relay-url"] || values["cloudflare-token-file"] || values["ngrok-token-file"] || values["oblien-credentials-file"]) {
621
1304
  const kind = values.relay ?? "custom";
622
- if (!["none", "cloudflare", "ngrok", "custom"].includes(kind)) throw new Error("Choose relay none, cloudflare, ngrok or custom.");
1305
+ if (!["none", "oblien", "cloudflare", "ngrok", "custom"].includes(kind)) throw new Error("Choose relay none, oblien, cloudflare, ngrok or custom.");
1306
+ const tunnelId = values["oblien-tunnel-id"] === void 0 ? void 0 : Number(values["oblien-tunnel-id"]);
1307
+ if (tunnelId !== void 0 && (!Number.isSafeInteger(tunnelId) || tunnelId <= 0)) throw new Error("Invalid Oblien tunnel ID.");
623
1308
  patch.relay = {
624
1309
  kind,
625
1310
  url: values["relay-url"],
626
- cloudflareTokenFile: values["cloudflare-token-file"] ? path.resolve(values["cloudflare-token-file"]) : void 0
1311
+ cloudflareTokenFile: values["cloudflare-token-file"] ? path.resolve(values["cloudflare-token-file"]) : void 0,
1312
+ ngrokTokenFile: values["ngrok-token-file"] ? path.resolve(values["ngrok-token-file"]) : void 0,
1313
+ oblienCredentialsFile: values["oblien-credentials-file"] ? path.resolve(values["oblien-credentials-file"]) : void 0,
1314
+ oblienTunnelId: tunnelId
627
1315
  };
628
1316
  }
1317
+ const interactive = !values.json && !!process.stdin.isTTY;
1318
+ const needsSetup = command === "connection" || command === "connect" && !previous && !patch.relay && interactive || patch.relay?.kind === "oblien" && !patch.relay.oblienCredentialsFile;
1319
+ if (needsSetup) {
1320
+ const selected = command === "connection" && positionals[1] ? connectionProvider(positionals[1]) : void 0;
1321
+ if (!interactive && selected !== "automatic" && selected !== "temporary")
1322
+ throw new Error("Run mindwire connection in an interactive terminal, or supply the provider's saved URL and credential-file options.");
1323
+ const provider = selected ?? (patch.relay?.kind === "oblien" ? "oblien" : await chooseProvider(terminalSetupPrompt));
1324
+ automaticDiscovery = provider === "automatic";
1325
+ Object.assign(patch, await setupProvider({
1326
+ directory,
1327
+ provider,
1328
+ prompt: terminalSetupPrompt,
1329
+ config: { ...defaultComputerConfig(), ...previous, ...patch }
1330
+ }));
1331
+ if (!previous || connectionAddress(patch.relay ?? previous.relay, patch.host ?? previous.host) !== connectionAddress(previous.relay, previous.host)) addressChanged = true;
1332
+ }
1333
+ if (automaticDiscovery && previous) {
1334
+ const running = await ensureComputer(directory, fileURLToPath(import.meta.url), {}, void 0, { waitForRoutes: false });
1335
+ await enableAddressDiscovery({ directory, computer: running, prompt: terminalSetupPrompt, ...discoveryOptions });
1336
+ }
629
1337
  const progress = !values.json && process.stderr.isTTY && process.env.TERM !== "dumb" ? new ConnectionProgress(process.stderr) : void 0;
630
1338
  emit("starting", {}, progress ? void 0 : "Starting Mindwire\u2026");
631
1339
  const client2 = await ensureComputer(directory, fileURLToPath(import.meta.url), patch, (phase) => {
632
1340
  if (progress) progress.update(phase);
633
1341
  else emit("progress", { message: phase }, phase);
634
1342
  }).finally(() => progress?.close());
1343
+ if (automaticDiscovery && !previous) await enableAddressDiscovery({ directory, computer: client2, prompt: terminalSetupPrompt, ...discoveryOptions });
635
1344
  const info = await client2.computer.info();
636
- emit("ready", { computerId: info.computerId, routes: info.routes }, "Mindwire is running in the background.");
1345
+ const savedConfig = await readJSON(path.join(directory, "computer-config.json"));
1346
+ const connection = connectionInfo(savedConfig?.relay ?? { kind: "cloudflare" }, savedConfig?.host);
1347
+ emit("ready", { computerId: info.computerId, routes: info.routes, connection }, "Mindwire is running in the background.");
1348
+ emit("connection", connection, connectionDescription(connection));
637
1349
  if (command === "start") return;
638
1350
  if (info.routes.some((route) => route.kind === "websocket")) {
639
1351
  emit("secure_connection", {}, "Your phone connects over end-to-end encrypted SSH. The tunnel carries encrypted traffic; only phones you approve can connect.");
640
1352
  }
641
1353
  const devices = await client2.computer.devices();
642
1354
  const saved = devices.filter((device) => !device.revoked);
1355
+ if (addressChanged && !saved.some((device) => device.addressRecovery)) requestedAction = "pair";
1356
+ if (info.discovery?.enabled) emit("discovery", info.discovery, discoveryDescription(info.discovery));
643
1357
  if (saved.length) emit("saved_devices", { devices: saved }, `
644
1358
  Saved approvals on this computer
645
1359
  ${deviceSummary(saved)}`);
646
- const question = !values.json && process.stdin.isTTY ? async (prompt) => {
1360
+ const question2 = !values.json && process.stdin.isTTY ? async (prompt) => {
647
1361
  const input = createInterface({ input: process.stdin, output: process.stderr });
648
1362
  try {
649
1363
  return await input.question(prompt);
@@ -651,13 +1365,13 @@ ${deviceSummary(saved)}`);
651
1365
  input.close();
652
1366
  }
653
1367
  } : void 0;
654
- const action = await chooseConnectionAction({ requested: requestedAction, devices });
1368
+ const action = await chooseConnectionAction({ requested: requestedAction, devices, persistent: connection.address === "persistent" });
655
1369
  const startupPreference = await startupStatus(directory);
656
1370
  const startupAction = await chooseStartupAction({
657
1371
  state: startupPreference,
658
1372
  startup: values.startup,
659
1373
  noStartup: values["no-startup"],
660
- question: action === "pair" && saved.length === 0 ? question : void 0
1374
+ question: action === "pair" && saved.length === 0 ? question2 : void 0
661
1375
  });
662
1376
  if (startupAction !== void 0) {
663
1377
  try {
@@ -671,11 +1385,12 @@ ${deviceSummary(saved)}`);
671
1385
  }
672
1386
  if (action === "resume") {
673
1387
  const connected = saved.filter((device) => device.connected);
674
- emit("connected", { computerId: info.computerId, devices: connected }, `
1388
+ if (connected.length) emit("connected", { computerId: info.computerId, devices: connected }, `
675
1389
  Connected:
676
1390
  ${deviceSummary(connected)}
677
1391
  Mindwire continues running in the background.`);
678
- emit("connect_hint", {}, "To scan again or add a phone, run mindwire reconnect.");
1392
+ else emit("waiting_for_phone", { computerId: info.computerId }, "\nReady for your saved phone. Open Mindwire; it will retry this computer's saved connection.");
1393
+ emit("connect_hint", {}, "If the connection was removed from your phone, run mindwire connect pair to scan again.");
679
1394
  return;
680
1395
  }
681
1396
  const invitation = await client2.computer.invite((await client2.computer.info()).routes);
@@ -732,12 +1447,12 @@ ${safeName(pairing.request.name)} wants to connect.
732
1447
  Device key: ${fingerprint}
733
1448
  Access: files and commands as your computer account.`
734
1449
  );
735
- if (!question) {
1450
+ if (!question2) {
736
1451
  const command2 = approvalCommand(invitation.pairingId, pairing.request.id);
737
1452
  emit("approval_command", { command: command2 }, `Review this device, then run:
738
1453
  ${command2}`);
739
1454
  } else {
740
- const decision = await choosePairingDecision({ replacements, question });
1455
+ const decision = await choosePairingDecision({ replacements, question: question2 });
741
1456
  await client2.computer.decide(
742
1457
  invitation.pairingId,
743
1458
  pairing.request.id,
@@ -761,21 +1476,37 @@ ${command2}`);
761
1476
  const client = await computerClient(directory);
762
1477
  switch (command) {
763
1478
  case "status": {
764
- const [info, health, work, controller, startup] = await Promise.all([
1479
+ const [info, health, work, controller, startup, config, change] = await Promise.all([
765
1480
  client.computer.info(),
766
1481
  client.health(),
767
1482
  client.service.updateStatus(),
768
1483
  readJSON(path.join(directory, "computer-controller.json")),
769
- startupStatus(directory)
1484
+ startupStatus(directory),
1485
+ readJSON(path.join(directory, "computer-config.json")),
1486
+ readJSON(path.join(directory, "computer-connection-change.json"))
770
1487
  ]);
1488
+ const connection = info.connection ?? (config && connectionInfo(config.relay, config.host));
1489
+ const connectionChange = change && { status: change.status, message: change.message };
771
1490
  emit(
772
1491
  "status",
773
- { ...info, daemonVersion: health.version, ...work, startup, recovering: controller?.recovering, relayError: controller?.error, relayErrorCode: controller?.errorCode },
1492
+ {
1493
+ ...info,
1494
+ connection,
1495
+ daemonVersion: health.version,
1496
+ ...work,
1497
+ startup,
1498
+ connectionChange,
1499
+ recovering: controller?.recovering,
1500
+ relayError: controller?.error,
1501
+ relayErrorCode: controller?.errorCode
1502
+ },
774
1503
  `Mindwire ${health.version} \xB7 ${work.idle ? "idle" : "working"}
775
1504
  Startup: ${startup.enabled ? "on" : "off"}
776
- ${info.routes.map((route) => route.kind === "ssh" ? `SSH ${route.host}:${route.port}` : route.url).join("\n")}${controller?.error ? `
777
- ${controller.error}` : ""}`
1505
+ ${connection ? connectionDescription(connection) + "\n" : ""}${info.routes.map((route) => route.kind === "ssh" ? `SSH ${route.host}:${route.port}` : route.url).join("\n")}${controller?.error ? `
1506
+ ${controller.error}` : ""}${change && change.status !== "complete" ? `
1507
+ Connection change: ${change.status}${change.message ? " \xB7 " + change.message : ""}` : ""}`
778
1508
  );
1509
+ if (info.discovery?.enabled && !values.json) emit("discovery", info.discovery, discoveryDescription(info.discovery));
779
1510
  break;
780
1511
  }
781
1512
  case "devices": {