@vex-chat/cli 0.1.6 → 0.2.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 +257 -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.2.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.1"
|
|
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,44 @@ 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 errorResponseMessage(err) {
|
|
601
|
+
const data = err?.response?.data;
|
|
602
|
+
if (data && typeof data === "object" && !ArrayBuffer.isView(data)) {
|
|
603
|
+
const message = data.error ?? data.message;
|
|
604
|
+
if (typeof message === "string") return message;
|
|
605
|
+
}
|
|
606
|
+
if (typeof data === "string") {
|
|
607
|
+
try {
|
|
608
|
+
const parsed = JSON.parse(data);
|
|
609
|
+
if (typeof parsed?.error === "string") return parsed.error;
|
|
610
|
+
} catch {
|
|
611
|
+
return data;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
return err instanceof Error ? err.message : String(err ?? "");
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function isInitialPasskeySetupRequired(err) {
|
|
618
|
+
if (err?.response?.status !== 403) return false;
|
|
619
|
+
return /passkey must be registered|passkey setup is required/i.test(
|
|
620
|
+
errorResponseMessage(err),
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
|
|
568
624
|
async function removeStoredDeviceAccount(ctx, config, accountRef) {
|
|
569
625
|
delete config.accounts[accountRef.key];
|
|
570
626
|
if (config.lastUsername === accountRef.key) {
|
|
@@ -602,6 +658,13 @@ async function register(ctx, args) {
|
|
|
602
658
|
try {
|
|
603
659
|
const [, registerErr] = await client.register(username, password);
|
|
604
660
|
if (registerErr) throw registerErr;
|
|
661
|
+
await persistNewLocalAccount(
|
|
662
|
+
ctx,
|
|
663
|
+
config,
|
|
664
|
+
username,
|
|
665
|
+
privateKey,
|
|
666
|
+
client,
|
|
667
|
+
);
|
|
605
668
|
await connectAndWait(client, ctx, `register:${username}`);
|
|
606
669
|
} catch (err) {
|
|
607
670
|
if (!isDeviceApprovalRequired(err)) throw err;
|
|
@@ -620,7 +683,6 @@ async function register(ctx, args) {
|
|
|
620
683
|
);
|
|
621
684
|
return;
|
|
622
685
|
}
|
|
623
|
-
await persistNewLocalAccount(ctx, config, username, privateKey, client);
|
|
624
686
|
console.log(
|
|
625
687
|
`${color(ROOT_ACCENT, "registered")} ${color(userAccent(client.me.user().userID), username)}`,
|
|
626
688
|
);
|
|
@@ -651,6 +713,156 @@ async function persistNewLocalAccount(
|
|
|
651
713
|
return { ...config.accounts[accountRef.key], accountKey: accountRef.key };
|
|
652
714
|
}
|
|
653
715
|
|
|
716
|
+
function apiBaseUrl(ctx) {
|
|
717
|
+
return `${ctx.clientOptions.unsafeHttp ? "http" : "https"}://${ctx.clientOptions.host}`;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function defaultPasskeyLoginUrl(ctx) {
|
|
721
|
+
const host = normalizeAccountHost(ctx.clientOptions.host);
|
|
722
|
+
if (isLocalHost(host)) {
|
|
723
|
+
return `http://localhost:5173${DEFAULT_PASSKEY_LOGIN_PATH}`;
|
|
724
|
+
}
|
|
725
|
+
return `${ctx.clientOptions.unsafeHttp ? "http" : "https"}://${host}${DEFAULT_PASSKEY_LOGIN_PATH}`;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
function buildPasskeyLoginUrl(ctx, { code, pending, username }) {
|
|
729
|
+
const url = new URL(ctx.passkeyLoginUrl ?? defaultPasskeyLoginUrl(ctx));
|
|
730
|
+
url.hash = new URLSearchParams({
|
|
731
|
+
api: apiBaseUrl(ctx),
|
|
732
|
+
code,
|
|
733
|
+
device: ctx.clientOptions.deviceName ?? "vex-chat-cli",
|
|
734
|
+
expires: pending.expiresAt ?? "",
|
|
735
|
+
request: pending.requestID,
|
|
736
|
+
username,
|
|
737
|
+
}).toString();
|
|
738
|
+
return url.toString();
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
function buildPasskeyRegistrationUrl(ctx, { token, userID, username }) {
|
|
742
|
+
const url = new URL(ctx.passkeyLoginUrl ?? defaultPasskeyLoginUrl(ctx));
|
|
743
|
+
url.hash = new URLSearchParams({
|
|
744
|
+
api: apiBaseUrl(ctx),
|
|
745
|
+
device: ctx.clientOptions.deviceName ?? "vex-chat-cli",
|
|
746
|
+
mode: "register",
|
|
747
|
+
name: ctx.clientOptions.deviceName ?? "vex-chat-cli",
|
|
748
|
+
token,
|
|
749
|
+
user: userID,
|
|
750
|
+
username,
|
|
751
|
+
}).toString();
|
|
752
|
+
return url.toString();
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
async function openExternalUrl(url) {
|
|
756
|
+
const [command, args] =
|
|
757
|
+
process.platform === "darwin"
|
|
758
|
+
? ["open", [url]]
|
|
759
|
+
: process.platform === "win32"
|
|
760
|
+
? ["cmd", ["/c", "start", "", url]]
|
|
761
|
+
: ["xdg-open", [url]];
|
|
762
|
+
return new Promise((resolve) => {
|
|
763
|
+
execFile(command, args, { timeout: 2_000 }, (err) => {
|
|
764
|
+
resolve(!err);
|
|
765
|
+
});
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
async function launchPasskeyLogin(ctx, username, pending, code) {
|
|
770
|
+
let url;
|
|
771
|
+
try {
|
|
772
|
+
url = buildPasskeyLoginUrl(ctx, { code, pending, username });
|
|
773
|
+
} catch (err) {
|
|
774
|
+
console.log(
|
|
775
|
+
color(
|
|
776
|
+
"yellow",
|
|
777
|
+
`Could not build passkey login URL: ${err instanceof Error ? err.message : String(err)}`,
|
|
778
|
+
),
|
|
779
|
+
);
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
console.log(
|
|
784
|
+
`${color(ROOT_ACCENT, "passkey login")} ${color("dim", "opens in your browser")}`,
|
|
785
|
+
);
|
|
786
|
+
console.log(
|
|
787
|
+
color(
|
|
788
|
+
"dim",
|
|
789
|
+
"Restoring with a passkey will remove other devices from this account.",
|
|
790
|
+
),
|
|
791
|
+
);
|
|
792
|
+
console.log(`${color("dim", "url")} ${url}`);
|
|
793
|
+
if (!ctx.openBrowser) {
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
const opened = await openExternalUrl(url);
|
|
797
|
+
if (!opened) {
|
|
798
|
+
console.log(
|
|
799
|
+
color("yellow", "Could not open a browser. Copy the URL above."),
|
|
800
|
+
);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
async function launchInitialPasskeySetup(ctx, client, username) {
|
|
805
|
+
const token = getClientBearerToken(client);
|
|
806
|
+
if (!token) {
|
|
807
|
+
throw new Error(
|
|
808
|
+
"Passkey setup is required, but the CLI could not read the registration token.",
|
|
809
|
+
);
|
|
810
|
+
}
|
|
811
|
+
const userID = client.me.user().userID;
|
|
812
|
+
let url;
|
|
813
|
+
try {
|
|
814
|
+
url = buildPasskeyRegistrationUrl(ctx, {
|
|
815
|
+
token,
|
|
816
|
+
userID,
|
|
817
|
+
username,
|
|
818
|
+
});
|
|
819
|
+
} catch (err) {
|
|
820
|
+
throw new Error(
|
|
821
|
+
`Could not build passkey setup URL: ${err instanceof Error ? err.message : String(err)}`,
|
|
822
|
+
);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
console.log(
|
|
826
|
+
`${color(ROOT_ACCENT, "passkey setup")} ${color("dim", "opens in your browser")}`,
|
|
827
|
+
);
|
|
828
|
+
console.log(
|
|
829
|
+
color(
|
|
830
|
+
"dim",
|
|
831
|
+
"New accounts must add a passkey before the CLI can connect.",
|
|
832
|
+
),
|
|
833
|
+
);
|
|
834
|
+
console.log(`${color("dim", "url")} ${url}`);
|
|
835
|
+
if (ctx.openBrowser) {
|
|
836
|
+
const opened = await openExternalUrl(url);
|
|
837
|
+
if (!opened) {
|
|
838
|
+
console.log(
|
|
839
|
+
color(
|
|
840
|
+
"yellow",
|
|
841
|
+
"Could not open a browser. Copy the URL above.",
|
|
842
|
+
),
|
|
843
|
+
);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
console.log(color(ROOT_ACCENT, "waiting for passkey setup..."));
|
|
848
|
+
for (let attempt = 0; attempt < 300; attempt++) {
|
|
849
|
+
await sleep(2000);
|
|
850
|
+
const passkeys = await client.passkeys.list().catch((err) => {
|
|
851
|
+
debugLog(ctx, "passkey.register.poll.error", {
|
|
852
|
+
error: err,
|
|
853
|
+
username,
|
|
854
|
+
});
|
|
855
|
+
return [];
|
|
856
|
+
});
|
|
857
|
+
if (passkeys.length > 0) {
|
|
858
|
+
if (input.isTTY && output.isTTY) output.write("\n");
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
if (input.isTTY && output.isTTY) output.write(color("dim", "."));
|
|
862
|
+
}
|
|
863
|
+
throw new Error("Timed out waiting for passkey setup.");
|
|
864
|
+
}
|
|
865
|
+
|
|
654
866
|
async function persistPendingLocalAccount(
|
|
655
867
|
ctx,
|
|
656
868
|
config,
|
|
@@ -752,11 +964,6 @@ async function loginWithDeviceApproval(ctx, username) {
|
|
|
752
964
|
try {
|
|
753
965
|
const [, registerErr] = await client.register(accountUsername);
|
|
754
966
|
if (!registerErr) {
|
|
755
|
-
await connectAndWait(
|
|
756
|
-
client,
|
|
757
|
-
ctx,
|
|
758
|
-
`login-request:${accountUsername}`,
|
|
759
|
-
);
|
|
760
967
|
await persistNewLocalAccount(
|
|
761
968
|
ctx,
|
|
762
969
|
config,
|
|
@@ -764,6 +971,11 @@ async function loginWithDeviceApproval(ctx, username) {
|
|
|
764
971
|
privateKey,
|
|
765
972
|
client,
|
|
766
973
|
);
|
|
974
|
+
await connectAndWait(
|
|
975
|
+
client,
|
|
976
|
+
ctx,
|
|
977
|
+
`login-request:${accountUsername}`,
|
|
978
|
+
);
|
|
767
979
|
console.log(
|
|
768
980
|
`${color(ROOT_ACCENT, "registered")} ${color(userAccent(client.me.user().userID), accountUsername)}`,
|
|
769
981
|
);
|
|
@@ -833,14 +1045,14 @@ async function waitForDeviceApproval(
|
|
|
833
1045
|
console.log(
|
|
834
1046
|
color(
|
|
835
1047
|
"dim",
|
|
836
|
-
"
|
|
1048
|
+
"Approve from an existing device, or restore this CLI with a passkey in the browser.",
|
|
837
1049
|
),
|
|
838
1050
|
);
|
|
839
1051
|
|
|
840
1052
|
if (input.isTTY && output.isTTY) {
|
|
841
1053
|
const rl = createInterface({ input, output });
|
|
842
1054
|
try {
|
|
843
|
-
const answer = (await askText(rl, "
|
|
1055
|
+
const answer = (await askText(rl, "start login request?", "Y"))
|
|
844
1056
|
.trim()
|
|
845
1057
|
.toLowerCase();
|
|
846
1058
|
if (answer === "n" || answer === "no") {
|
|
@@ -873,7 +1085,10 @@ async function waitForDeviceApproval(
|
|
|
873
1085
|
);
|
|
874
1086
|
if (!approvedDeviceID) throw err;
|
|
875
1087
|
});
|
|
876
|
-
|
|
1088
|
+
await launchPasskeyLogin(ctx, username, pending, code);
|
|
1089
|
+
console.log(
|
|
1090
|
+
color(ROOT_ACCENT, "waiting for browser passkey login or approval..."),
|
|
1091
|
+
);
|
|
877
1092
|
|
|
878
1093
|
for (let attempt = 0; attempt < 300; attempt++) {
|
|
879
1094
|
await sleep(2000);
|
|
@@ -3105,6 +3320,13 @@ async function authenticateOrRegister(ctx, explicitUsername) {
|
|
|
3105
3320
|
try {
|
|
3106
3321
|
const [, registerErr] = await client.register(entered);
|
|
3107
3322
|
if (registerErr) throw registerErr;
|
|
3323
|
+
await persistNewLocalAccount(
|
|
3324
|
+
ctx,
|
|
3325
|
+
config,
|
|
3326
|
+
entered,
|
|
3327
|
+
privateKey,
|
|
3328
|
+
client,
|
|
3329
|
+
);
|
|
3108
3330
|
await connectAndWait(client, ctx, `register:${entered}`);
|
|
3109
3331
|
} catch (err) {
|
|
3110
3332
|
if (!isDeviceApprovalRequired(err)) throw err;
|
|
@@ -3122,13 +3344,11 @@ async function authenticateOrRegister(ctx, explicitUsername) {
|
|
|
3122
3344
|
},
|
|
3123
3345
|
);
|
|
3124
3346
|
}
|
|
3125
|
-
const
|
|
3126
|
-
|
|
3127
|
-
config,
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
client,
|
|
3131
|
-
);
|
|
3347
|
+
const createdAccountRef = parseAccountSelector(ctx, entered);
|
|
3348
|
+
const account = {
|
|
3349
|
+
...config.accounts[createdAccountRef.key],
|
|
3350
|
+
accountKey: createdAccountRef.key,
|
|
3351
|
+
};
|
|
3132
3352
|
return { account, client, config };
|
|
3133
3353
|
} finally {
|
|
3134
3354
|
rl.close();
|
|
@@ -3151,6 +3371,24 @@ async function connectAndWait(client, ctx = null, label = "client") {
|
|
|
3151
3371
|
debugLog(ctx, "client.connect.skip.connected", { label });
|
|
3152
3372
|
return;
|
|
3153
3373
|
}
|
|
3374
|
+
try {
|
|
3375
|
+
await connectOnceAndWait(client, ctx, label);
|
|
3376
|
+
return;
|
|
3377
|
+
} catch (err) {
|
|
3378
|
+
if (!ctx || !isInitialPasskeySetupRequired(err)) {
|
|
3379
|
+
throw err;
|
|
3380
|
+
}
|
|
3381
|
+
const username = client.me.user().username ?? "account";
|
|
3382
|
+
debugLog(ctx, "client.connect.passkeySetup.required", {
|
|
3383
|
+
label,
|
|
3384
|
+
username,
|
|
3385
|
+
});
|
|
3386
|
+
await launchInitialPasskeySetup(ctx, client, username);
|
|
3387
|
+
await connectOnceAndWait(client, ctx, `${label}:passkey`);
|
|
3388
|
+
}
|
|
3389
|
+
}
|
|
3390
|
+
|
|
3391
|
+
async function connectOnceAndWait(client, ctx = null, label = "client") {
|
|
3154
3392
|
debugLog(ctx, "client.connect.start", { label });
|
|
3155
3393
|
await new Promise((resolve, reject) => {
|
|
3156
3394
|
const timer = setTimeout(() => {
|
|
@@ -4362,6 +4600,8 @@ Flags:
|
|
|
4362
4600
|
--host <host:port> API host, default api.vex.wtf
|
|
4363
4601
|
--local connect to local Spire at 127.0.0.1:16777 over http/ws
|
|
4364
4602
|
--http use http/ws
|
|
4603
|
+
--passkey-url <url> browser passkey login page, default uses the API host
|
|
4604
|
+
--no-browser print passkey login URL without opening a browser
|
|
4365
4605
|
--dev-key <key> send x-dev-api-key
|
|
4366
4606
|
--debug write send/receive/connect diagnostics to a log file
|
|
4367
4607
|
--debug-file <path> debug log path, default under the CLI data dir
|