@vex-chat/cli 0.1.6 → 0.3.0
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/README.md +2 -0
- package/package.json +2 -2
- package/src/vex-chat.js +309 -17
package/README.md
CHANGED
|
@@ -11,6 +11,8 @@ After that it opens straight into a live chat session.
|
|
|
11
11
|
|
|
12
12
|
By default the CLI connects to production at `api.vex.wtf`. For local Spire development, pass `--local` to use `127.0.0.1:16777` over http/ws. Custom targets can use `--api-url <url>` to set both the host and protocol, or `--host <host:port> --http` to set them separately.
|
|
13
13
|
|
|
14
|
+
When creating a new account, the CLI opens a browser passkey page to attach the account's first passkey before connecting. When adding this machine to an account that already exists, the same page can restore the pending device with a saved passkey. Use `--passkey-url <url>` or `VEX_CHAT_PASSKEY_URL` when the WebAuthn page is hosted somewhere other than the API origin; use `--no-browser` or `VEX_CHAT_NO_BROWSER=1` to print the URL without launching a browser.
|
|
15
|
+
|
|
14
16
|
Inside the app:
|
|
15
17
|
|
|
16
18
|
- `/accounts` lists local users on this machine
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vex-chat/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Terminal client for vex-chat.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"dependencies": {
|
|
32
32
|
"better-sqlite3": "11.10.0",
|
|
33
33
|
"msgpackr": "^1.11.9",
|
|
34
|
-
"@vex-chat/libvex": "^7.
|
|
34
|
+
"@vex-chat/libvex": "^7.1.2"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
37
|
"@types/node": "^25.6.0",
|
package/src/vex-chat.js
CHANGED
|
@@ -15,6 +15,7 @@ import { unpack } from "msgpackr";
|
|
|
15
15
|
const require = createRequire(import.meta.url);
|
|
16
16
|
const CLI_VERSION = require("../package.json").version;
|
|
17
17
|
const DEFAULT_HOST = "api.vex.wtf";
|
|
18
|
+
const DEFAULT_PASSKEY_LOGIN_PATH = "/cli/passkey";
|
|
18
19
|
const LOCAL_HOST = "127.0.0.1:16777";
|
|
19
20
|
const COLOR = process.env.NO_COLOR === undefined;
|
|
20
21
|
const ANSI = {
|
|
@@ -201,7 +202,16 @@ function parseArgs(argv) {
|
|
|
201
202
|
continue;
|
|
202
203
|
}
|
|
203
204
|
const key = arg.slice(2);
|
|
204
|
-
if (
|
|
205
|
+
if (
|
|
206
|
+
[
|
|
207
|
+
"debug",
|
|
208
|
+
"help",
|
|
209
|
+
"http",
|
|
210
|
+
"local",
|
|
211
|
+
"no-browser",
|
|
212
|
+
"no-home",
|
|
213
|
+
].includes(key)
|
|
214
|
+
) {
|
|
205
215
|
flags[key] = true;
|
|
206
216
|
continue;
|
|
207
217
|
}
|
|
@@ -293,6 +303,14 @@ async function createContext(flags) {
|
|
|
293
303
|
? String(flags.username ?? flags.user).toLowerCase()
|
|
294
304
|
: undefined,
|
|
295
305
|
noHome: Boolean(flags["no-home"]),
|
|
306
|
+
openBrowser:
|
|
307
|
+
!flags["no-browser"] && process.env.VEX_CHAT_NO_BROWSER !== "1",
|
|
308
|
+
passkeyLoginUrl:
|
|
309
|
+
flags["passkey-url"] || process.env.VEX_CHAT_PASSKEY_URL
|
|
310
|
+
? String(
|
|
311
|
+
flags["passkey-url"] ?? process.env.VEX_CHAT_PASSKEY_URL,
|
|
312
|
+
)
|
|
313
|
+
: undefined,
|
|
296
314
|
password: flags.password ? String(flags.password) : undefined,
|
|
297
315
|
debug,
|
|
298
316
|
debugFile,
|
|
@@ -565,6 +583,96 @@ function isRemovedStoredDeviceError(err) {
|
|
|
565
583
|
return err?.name === "RemovedStoredDeviceError";
|
|
566
584
|
}
|
|
567
585
|
|
|
586
|
+
function getClientBearerToken(client) {
|
|
587
|
+
if (typeof client.token === "string" && client.token.length > 0) {
|
|
588
|
+
return client.token;
|
|
589
|
+
}
|
|
590
|
+
const authorization =
|
|
591
|
+
client.http?.defaults?.headers?.common?.Authorization ??
|
|
592
|
+
client.http?.defaults?.headers?.common?.authorization;
|
|
593
|
+
if (typeof authorization !== "string") {
|
|
594
|
+
return null;
|
|
595
|
+
}
|
|
596
|
+
const match = /^Bearer\s+(.+)$/i.exec(authorization.trim());
|
|
597
|
+
return match?.[1] ?? null;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function responseBodyMessage(data) {
|
|
601
|
+
if (!data || typeof data !== "object" || ArrayBuffer.isView(data)) {
|
|
602
|
+
return null;
|
|
603
|
+
}
|
|
604
|
+
if (data instanceof ArrayBuffer) {
|
|
605
|
+
return null;
|
|
606
|
+
}
|
|
607
|
+
const error = data.error;
|
|
608
|
+
if (typeof error === "string") return error;
|
|
609
|
+
if (error && typeof error === "object") {
|
|
610
|
+
const nestedMessage = error.message;
|
|
611
|
+
if (typeof nestedMessage === "string") return nestedMessage;
|
|
612
|
+
}
|
|
613
|
+
const message = data.message;
|
|
614
|
+
return typeof message === "string" ? message : null;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function bytesFromResponseBody(data) {
|
|
618
|
+
if (ArrayBuffer.isView(data)) {
|
|
619
|
+
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
620
|
+
}
|
|
621
|
+
if (data instanceof ArrayBuffer) {
|
|
622
|
+
return new Uint8Array(data);
|
|
623
|
+
}
|
|
624
|
+
return null;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function decodeBinaryResponseBody(data) {
|
|
628
|
+
const bytes = bytesFromResponseBody(data);
|
|
629
|
+
if (!bytes) return null;
|
|
630
|
+
try {
|
|
631
|
+
return unpack(bytes);
|
|
632
|
+
} catch {
|
|
633
|
+
// Express JSON error bodies also arrive as ArrayBuffer with fetch.
|
|
634
|
+
}
|
|
635
|
+
const text = new TextDecoder("utf-8", { fatal: false })
|
|
636
|
+
.decode(bytes)
|
|
637
|
+
.trim();
|
|
638
|
+
if (!text) return null;
|
|
639
|
+
if (text.startsWith("{") || text.startsWith("[")) {
|
|
640
|
+
try {
|
|
641
|
+
return JSON.parse(text);
|
|
642
|
+
} catch {
|
|
643
|
+
return text;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
return text;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function errorResponseMessage(err) {
|
|
650
|
+
const data = err?.response?.data;
|
|
651
|
+
const binaryBody = decodeBinaryResponseBody(data);
|
|
652
|
+
const binaryMessage = responseBodyMessage(binaryBody);
|
|
653
|
+
if (binaryMessage) return binaryMessage;
|
|
654
|
+
if (typeof binaryBody === "string") return binaryBody;
|
|
655
|
+
const objectMessage = responseBodyMessage(data);
|
|
656
|
+
if (objectMessage) return objectMessage;
|
|
657
|
+
if (typeof data === "string") {
|
|
658
|
+
try {
|
|
659
|
+
const parsed = JSON.parse(data);
|
|
660
|
+
const jsonMessage = responseBodyMessage(parsed);
|
|
661
|
+
if (jsonMessage) return jsonMessage;
|
|
662
|
+
} catch {
|
|
663
|
+
return data;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
return err instanceof Error ? err.message : String(err ?? "");
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function isInitialPasskeySetupRequired(err) {
|
|
670
|
+
if (err?.response?.status !== 403) return false;
|
|
671
|
+
return /passkey must be registered|passkey setup is required/i.test(
|
|
672
|
+
errorResponseMessage(err),
|
|
673
|
+
);
|
|
674
|
+
}
|
|
675
|
+
|
|
568
676
|
async function removeStoredDeviceAccount(ctx, config, accountRef) {
|
|
569
677
|
delete config.accounts[accountRef.key];
|
|
570
678
|
if (config.lastUsername === accountRef.key) {
|
|
@@ -602,6 +710,13 @@ async function register(ctx, args) {
|
|
|
602
710
|
try {
|
|
603
711
|
const [, registerErr] = await client.register(username, password);
|
|
604
712
|
if (registerErr) throw registerErr;
|
|
713
|
+
await persistNewLocalAccount(
|
|
714
|
+
ctx,
|
|
715
|
+
config,
|
|
716
|
+
username,
|
|
717
|
+
privateKey,
|
|
718
|
+
client,
|
|
719
|
+
);
|
|
605
720
|
await connectAndWait(client, ctx, `register:${username}`);
|
|
606
721
|
} catch (err) {
|
|
607
722
|
if (!isDeviceApprovalRequired(err)) throw err;
|
|
@@ -620,7 +735,6 @@ async function register(ctx, args) {
|
|
|
620
735
|
);
|
|
621
736
|
return;
|
|
622
737
|
}
|
|
623
|
-
await persistNewLocalAccount(ctx, config, username, privateKey, client);
|
|
624
738
|
console.log(
|
|
625
739
|
`${color(ROOT_ACCENT, "registered")} ${color(userAccent(client.me.user().userID), username)}`,
|
|
626
740
|
);
|
|
@@ -651,6 +765,156 @@ async function persistNewLocalAccount(
|
|
|
651
765
|
return { ...config.accounts[accountRef.key], accountKey: accountRef.key };
|
|
652
766
|
}
|
|
653
767
|
|
|
768
|
+
function apiBaseUrl(ctx) {
|
|
769
|
+
return `${ctx.clientOptions.unsafeHttp ? "http" : "https"}://${ctx.clientOptions.host}`;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
function defaultPasskeyLoginUrl(ctx) {
|
|
773
|
+
const host = normalizeAccountHost(ctx.clientOptions.host);
|
|
774
|
+
if (isLocalHost(host)) {
|
|
775
|
+
return `http://localhost:5173${DEFAULT_PASSKEY_LOGIN_PATH}`;
|
|
776
|
+
}
|
|
777
|
+
return `${ctx.clientOptions.unsafeHttp ? "http" : "https"}://${host}${DEFAULT_PASSKEY_LOGIN_PATH}`;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
function buildPasskeyLoginUrl(ctx, { code, pending, username }) {
|
|
781
|
+
const url = new URL(ctx.passkeyLoginUrl ?? defaultPasskeyLoginUrl(ctx));
|
|
782
|
+
url.hash = new URLSearchParams({
|
|
783
|
+
api: apiBaseUrl(ctx),
|
|
784
|
+
code,
|
|
785
|
+
device: ctx.clientOptions.deviceName ?? "vex-chat-cli",
|
|
786
|
+
expires: pending.expiresAt ?? "",
|
|
787
|
+
request: pending.requestID,
|
|
788
|
+
username,
|
|
789
|
+
}).toString();
|
|
790
|
+
return url.toString();
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
function buildPasskeyRegistrationUrl(ctx, { token, userID, username }) {
|
|
794
|
+
const url = new URL(ctx.passkeyLoginUrl ?? defaultPasskeyLoginUrl(ctx));
|
|
795
|
+
url.hash = new URLSearchParams({
|
|
796
|
+
api: apiBaseUrl(ctx),
|
|
797
|
+
device: ctx.clientOptions.deviceName ?? "vex-chat-cli",
|
|
798
|
+
mode: "register",
|
|
799
|
+
name: ctx.clientOptions.deviceName ?? "vex-chat-cli",
|
|
800
|
+
token,
|
|
801
|
+
user: userID,
|
|
802
|
+
username,
|
|
803
|
+
}).toString();
|
|
804
|
+
return url.toString();
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
async function openExternalUrl(url) {
|
|
808
|
+
const [command, args] =
|
|
809
|
+
process.platform === "darwin"
|
|
810
|
+
? ["open", [url]]
|
|
811
|
+
: process.platform === "win32"
|
|
812
|
+
? ["cmd", ["/c", "start", "", url]]
|
|
813
|
+
: ["xdg-open", [url]];
|
|
814
|
+
return new Promise((resolve) => {
|
|
815
|
+
execFile(command, args, { timeout: 2_000 }, (err) => {
|
|
816
|
+
resolve(!err);
|
|
817
|
+
});
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
async function launchPasskeyLogin(ctx, username, pending, code) {
|
|
822
|
+
let url;
|
|
823
|
+
try {
|
|
824
|
+
url = buildPasskeyLoginUrl(ctx, { code, pending, username });
|
|
825
|
+
} catch (err) {
|
|
826
|
+
console.log(
|
|
827
|
+
color(
|
|
828
|
+
"yellow",
|
|
829
|
+
`Could not build passkey login URL: ${err instanceof Error ? err.message : String(err)}`,
|
|
830
|
+
),
|
|
831
|
+
);
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
console.log(
|
|
836
|
+
`${color(ROOT_ACCENT, "passkey login")} ${color("dim", "opens in your browser")}`,
|
|
837
|
+
);
|
|
838
|
+
console.log(
|
|
839
|
+
color(
|
|
840
|
+
"dim",
|
|
841
|
+
"Restoring with a passkey will remove other devices from this account.",
|
|
842
|
+
),
|
|
843
|
+
);
|
|
844
|
+
console.log(`${color("dim", "url")} ${url}`);
|
|
845
|
+
if (!ctx.openBrowser) {
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
const opened = await openExternalUrl(url);
|
|
849
|
+
if (!opened) {
|
|
850
|
+
console.log(
|
|
851
|
+
color("yellow", "Could not open a browser. Copy the URL above."),
|
|
852
|
+
);
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
async function launchInitialPasskeySetup(ctx, client, username) {
|
|
857
|
+
const token = getClientBearerToken(client);
|
|
858
|
+
if (!token) {
|
|
859
|
+
throw new Error(
|
|
860
|
+
"Passkey setup is required, but the CLI could not read the registration token.",
|
|
861
|
+
);
|
|
862
|
+
}
|
|
863
|
+
const userID = client.me.user().userID;
|
|
864
|
+
let url;
|
|
865
|
+
try {
|
|
866
|
+
url = buildPasskeyRegistrationUrl(ctx, {
|
|
867
|
+
token,
|
|
868
|
+
userID,
|
|
869
|
+
username,
|
|
870
|
+
});
|
|
871
|
+
} catch (err) {
|
|
872
|
+
throw new Error(
|
|
873
|
+
`Could not build passkey setup URL: ${err instanceof Error ? err.message : String(err)}`,
|
|
874
|
+
);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
console.log(
|
|
878
|
+
`${color(ROOT_ACCENT, "passkey setup")} ${color("dim", "opens in your browser")}`,
|
|
879
|
+
);
|
|
880
|
+
console.log(
|
|
881
|
+
color(
|
|
882
|
+
"dim",
|
|
883
|
+
"New accounts must add a passkey before the CLI can connect.",
|
|
884
|
+
),
|
|
885
|
+
);
|
|
886
|
+
console.log(`${color("dim", "url")} ${url}`);
|
|
887
|
+
if (ctx.openBrowser) {
|
|
888
|
+
const opened = await openExternalUrl(url);
|
|
889
|
+
if (!opened) {
|
|
890
|
+
console.log(
|
|
891
|
+
color(
|
|
892
|
+
"yellow",
|
|
893
|
+
"Could not open a browser. Copy the URL above.",
|
|
894
|
+
),
|
|
895
|
+
);
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
console.log(color(ROOT_ACCENT, "waiting for passkey setup..."));
|
|
900
|
+
for (let attempt = 0; attempt < 300; attempt++) {
|
|
901
|
+
await sleep(2000);
|
|
902
|
+
const passkeys = await client.passkeys.list().catch((err) => {
|
|
903
|
+
debugLog(ctx, "passkey.register.poll.error", {
|
|
904
|
+
error: err,
|
|
905
|
+
username,
|
|
906
|
+
});
|
|
907
|
+
return [];
|
|
908
|
+
});
|
|
909
|
+
if (passkeys.length > 0) {
|
|
910
|
+
if (input.isTTY && output.isTTY) output.write("\n");
|
|
911
|
+
return;
|
|
912
|
+
}
|
|
913
|
+
if (input.isTTY && output.isTTY) output.write(color("dim", "."));
|
|
914
|
+
}
|
|
915
|
+
throw new Error("Timed out waiting for passkey setup.");
|
|
916
|
+
}
|
|
917
|
+
|
|
654
918
|
async function persistPendingLocalAccount(
|
|
655
919
|
ctx,
|
|
656
920
|
config,
|
|
@@ -752,11 +1016,6 @@ async function loginWithDeviceApproval(ctx, username) {
|
|
|
752
1016
|
try {
|
|
753
1017
|
const [, registerErr] = await client.register(accountUsername);
|
|
754
1018
|
if (!registerErr) {
|
|
755
|
-
await connectAndWait(
|
|
756
|
-
client,
|
|
757
|
-
ctx,
|
|
758
|
-
`login-request:${accountUsername}`,
|
|
759
|
-
);
|
|
760
1019
|
await persistNewLocalAccount(
|
|
761
1020
|
ctx,
|
|
762
1021
|
config,
|
|
@@ -764,6 +1023,11 @@ async function loginWithDeviceApproval(ctx, username) {
|
|
|
764
1023
|
privateKey,
|
|
765
1024
|
client,
|
|
766
1025
|
);
|
|
1026
|
+
await connectAndWait(
|
|
1027
|
+
client,
|
|
1028
|
+
ctx,
|
|
1029
|
+
`login-request:${accountUsername}`,
|
|
1030
|
+
);
|
|
767
1031
|
console.log(
|
|
768
1032
|
`${color(ROOT_ACCENT, "registered")} ${color(userAccent(client.me.user().userID), accountUsername)}`,
|
|
769
1033
|
);
|
|
@@ -833,14 +1097,14 @@ async function waitForDeviceApproval(
|
|
|
833
1097
|
console.log(
|
|
834
1098
|
color(
|
|
835
1099
|
"dim",
|
|
836
|
-
"
|
|
1100
|
+
"Approve from an existing device, or restore this CLI with a passkey in the browser.",
|
|
837
1101
|
),
|
|
838
1102
|
);
|
|
839
1103
|
|
|
840
1104
|
if (input.isTTY && output.isTTY) {
|
|
841
1105
|
const rl = createInterface({ input, output });
|
|
842
1106
|
try {
|
|
843
|
-
const answer = (await askText(rl, "
|
|
1107
|
+
const answer = (await askText(rl, "start login request?", "Y"))
|
|
844
1108
|
.trim()
|
|
845
1109
|
.toLowerCase();
|
|
846
1110
|
if (answer === "n" || answer === "no") {
|
|
@@ -873,7 +1137,10 @@ async function waitForDeviceApproval(
|
|
|
873
1137
|
);
|
|
874
1138
|
if (!approvedDeviceID) throw err;
|
|
875
1139
|
});
|
|
876
|
-
|
|
1140
|
+
await launchPasskeyLogin(ctx, username, pending, code);
|
|
1141
|
+
console.log(
|
|
1142
|
+
color(ROOT_ACCENT, "waiting for browser passkey login or approval..."),
|
|
1143
|
+
);
|
|
877
1144
|
|
|
878
1145
|
for (let attempt = 0; attempt < 300; attempt++) {
|
|
879
1146
|
await sleep(2000);
|
|
@@ -3105,6 +3372,13 @@ async function authenticateOrRegister(ctx, explicitUsername) {
|
|
|
3105
3372
|
try {
|
|
3106
3373
|
const [, registerErr] = await client.register(entered);
|
|
3107
3374
|
if (registerErr) throw registerErr;
|
|
3375
|
+
await persistNewLocalAccount(
|
|
3376
|
+
ctx,
|
|
3377
|
+
config,
|
|
3378
|
+
entered,
|
|
3379
|
+
privateKey,
|
|
3380
|
+
client,
|
|
3381
|
+
);
|
|
3108
3382
|
await connectAndWait(client, ctx, `register:${entered}`);
|
|
3109
3383
|
} catch (err) {
|
|
3110
3384
|
if (!isDeviceApprovalRequired(err)) throw err;
|
|
@@ -3122,13 +3396,11 @@ async function authenticateOrRegister(ctx, explicitUsername) {
|
|
|
3122
3396
|
},
|
|
3123
3397
|
);
|
|
3124
3398
|
}
|
|
3125
|
-
const
|
|
3126
|
-
|
|
3127
|
-
config,
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
client,
|
|
3131
|
-
);
|
|
3399
|
+
const createdAccountRef = parseAccountSelector(ctx, entered);
|
|
3400
|
+
const account = {
|
|
3401
|
+
...config.accounts[createdAccountRef.key],
|
|
3402
|
+
accountKey: createdAccountRef.key,
|
|
3403
|
+
};
|
|
3132
3404
|
return { account, client, config };
|
|
3133
3405
|
} finally {
|
|
3134
3406
|
rl.close();
|
|
@@ -3151,6 +3423,24 @@ async function connectAndWait(client, ctx = null, label = "client") {
|
|
|
3151
3423
|
debugLog(ctx, "client.connect.skip.connected", { label });
|
|
3152
3424
|
return;
|
|
3153
3425
|
}
|
|
3426
|
+
try {
|
|
3427
|
+
await connectOnceAndWait(client, ctx, label);
|
|
3428
|
+
return;
|
|
3429
|
+
} catch (err) {
|
|
3430
|
+
if (!ctx || !isInitialPasskeySetupRequired(err)) {
|
|
3431
|
+
throw err;
|
|
3432
|
+
}
|
|
3433
|
+
const username = client.me.user().username ?? "account";
|
|
3434
|
+
debugLog(ctx, "client.connect.passkeySetup.required", {
|
|
3435
|
+
label,
|
|
3436
|
+
username,
|
|
3437
|
+
});
|
|
3438
|
+
await launchInitialPasskeySetup(ctx, client, username);
|
|
3439
|
+
await connectOnceAndWait(client, ctx, `${label}:passkey`);
|
|
3440
|
+
}
|
|
3441
|
+
}
|
|
3442
|
+
|
|
3443
|
+
async function connectOnceAndWait(client, ctx = null, label = "client") {
|
|
3154
3444
|
debugLog(ctx, "client.connect.start", { label });
|
|
3155
3445
|
await new Promise((resolve, reject) => {
|
|
3156
3446
|
const timer = setTimeout(() => {
|
|
@@ -4362,6 +4652,8 @@ Flags:
|
|
|
4362
4652
|
--host <host:port> API host, default api.vex.wtf
|
|
4363
4653
|
--local connect to local Spire at 127.0.0.1:16777 over http/ws
|
|
4364
4654
|
--http use http/ws
|
|
4655
|
+
--passkey-url <url> browser passkey login page, default uses the API host
|
|
4656
|
+
--no-browser print passkey login URL without opening a browser
|
|
4365
4657
|
--dev-key <key> send x-dev-api-key
|
|
4366
4658
|
--debug write send/receive/connect diagnostics to a log file
|
|
4367
4659
|
--debug-file <path> debug log path, default under the CLI data dir
|