@vex-chat/cli 0.4.0 → 0.6.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 +11 -5
- package/package.json +2 -2
- package/src/vex-chat.js +48 -198
package/README.md
CHANGED
|
@@ -6,12 +6,18 @@ Terminal chat client for Vex backed by `@vex-chat/libvex`.
|
|
|
6
6
|
pnpm vex
|
|
7
7
|
```
|
|
8
8
|
|
|
9
|
-
On first run, `vex` prompts for a username and registers a local
|
|
10
|
-
|
|
9
|
+
On first run, `vex` prompts for a username and password, registers a local
|
|
10
|
+
device key, and 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
|
-
|
|
14
|
+
New accounts use the password you set during registration. Passkeys can be added
|
|
15
|
+
later as a supplemental recovery/sign-in method. When adding this machine to an
|
|
16
|
+
account that already exists, the browser passkey page can restore the pending
|
|
17
|
+
device with a saved passkey. Use `--passkey-url <url>` or
|
|
18
|
+
`VEX_CHAT_PASSKEY_URL` when the WebAuthn page is hosted somewhere other than the
|
|
19
|
+
API origin; use `--no-browser` or `VEX_CHAT_NO_BROWSER=1` to print the URL
|
|
20
|
+
without launching a browser.
|
|
15
21
|
|
|
16
22
|
Inside the app:
|
|
17
23
|
|
|
@@ -43,8 +49,8 @@ Inside the app:
|
|
|
43
49
|
Scriptable helpers still exist:
|
|
44
50
|
|
|
45
51
|
```sh
|
|
46
|
-
pnpm vex auth register alice
|
|
47
|
-
pnpm vex auth register bob
|
|
52
|
+
pnpm vex auth register alice alice-password
|
|
53
|
+
pnpm vex auth register bob bob-password
|
|
48
54
|
pnpm vex alice
|
|
49
55
|
pnpm vex bob
|
|
50
56
|
pnpm vex auth accounts
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vex-chat/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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": "^
|
|
34
|
+
"@vex-chat/libvex": "^9.0.0"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
37
|
"@types/node": "^25.6.0",
|
package/src/vex-chat.js
CHANGED
|
@@ -593,96 +593,6 @@ function isRemovedStoredDeviceError(err) {
|
|
|
593
593
|
return err?.name === "RemovedStoredDeviceError";
|
|
594
594
|
}
|
|
595
595
|
|
|
596
|
-
function getClientBearerToken(client) {
|
|
597
|
-
if (typeof client.token === "string" && client.token.length > 0) {
|
|
598
|
-
return client.token;
|
|
599
|
-
}
|
|
600
|
-
const authorization =
|
|
601
|
-
client.http?.defaults?.headers?.common?.Authorization ??
|
|
602
|
-
client.http?.defaults?.headers?.common?.authorization;
|
|
603
|
-
if (typeof authorization !== "string") {
|
|
604
|
-
return null;
|
|
605
|
-
}
|
|
606
|
-
const match = /^Bearer\s+(.+)$/i.exec(authorization.trim());
|
|
607
|
-
return match?.[1] ?? null;
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
function responseBodyMessage(data) {
|
|
611
|
-
if (!data || typeof data !== "object" || ArrayBuffer.isView(data)) {
|
|
612
|
-
return null;
|
|
613
|
-
}
|
|
614
|
-
if (data instanceof ArrayBuffer) {
|
|
615
|
-
return null;
|
|
616
|
-
}
|
|
617
|
-
const error = data.error;
|
|
618
|
-
if (typeof error === "string") return error;
|
|
619
|
-
if (error && typeof error === "object") {
|
|
620
|
-
const nestedMessage = error.message;
|
|
621
|
-
if (typeof nestedMessage === "string") return nestedMessage;
|
|
622
|
-
}
|
|
623
|
-
const message = data.message;
|
|
624
|
-
return typeof message === "string" ? message : null;
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
function bytesFromResponseBody(data) {
|
|
628
|
-
if (ArrayBuffer.isView(data)) {
|
|
629
|
-
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
630
|
-
}
|
|
631
|
-
if (data instanceof ArrayBuffer) {
|
|
632
|
-
return new Uint8Array(data);
|
|
633
|
-
}
|
|
634
|
-
return null;
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
function decodeBinaryResponseBody(data) {
|
|
638
|
-
const bytes = bytesFromResponseBody(data);
|
|
639
|
-
if (!bytes) return null;
|
|
640
|
-
try {
|
|
641
|
-
return unpack(bytes);
|
|
642
|
-
} catch {
|
|
643
|
-
// Express JSON error bodies also arrive as ArrayBuffer with fetch.
|
|
644
|
-
}
|
|
645
|
-
const text = new TextDecoder("utf-8", { fatal: false })
|
|
646
|
-
.decode(bytes)
|
|
647
|
-
.trim();
|
|
648
|
-
if (!text) return null;
|
|
649
|
-
if (text.startsWith("{") || text.startsWith("[")) {
|
|
650
|
-
try {
|
|
651
|
-
return JSON.parse(text);
|
|
652
|
-
} catch {
|
|
653
|
-
return text;
|
|
654
|
-
}
|
|
655
|
-
}
|
|
656
|
-
return text;
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
function errorResponseMessage(err) {
|
|
660
|
-
const data = err?.response?.data;
|
|
661
|
-
const binaryBody = decodeBinaryResponseBody(data);
|
|
662
|
-
const binaryMessage = responseBodyMessage(binaryBody);
|
|
663
|
-
if (binaryMessage) return binaryMessage;
|
|
664
|
-
if (typeof binaryBody === "string") return binaryBody;
|
|
665
|
-
const objectMessage = responseBodyMessage(data);
|
|
666
|
-
if (objectMessage) return objectMessage;
|
|
667
|
-
if (typeof data === "string") {
|
|
668
|
-
try {
|
|
669
|
-
const parsed = JSON.parse(data);
|
|
670
|
-
const jsonMessage = responseBodyMessage(parsed);
|
|
671
|
-
if (jsonMessage) return jsonMessage;
|
|
672
|
-
} catch {
|
|
673
|
-
return data;
|
|
674
|
-
}
|
|
675
|
-
}
|
|
676
|
-
return err instanceof Error ? err.message : String(err ?? "");
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
function isInitialPasskeySetupRequired(err) {
|
|
680
|
-
if (err?.response?.status !== 403) return false;
|
|
681
|
-
return /passkey must be registered|passkey setup is required/i.test(
|
|
682
|
-
errorResponseMessage(err),
|
|
683
|
-
);
|
|
684
|
-
}
|
|
685
|
-
|
|
686
596
|
async function removeStoredDeviceAccount(ctx, config, accountRef) {
|
|
687
597
|
delete config.accounts[accountRef.key];
|
|
688
598
|
if (config.lastUsername === accountRef.key) {
|
|
@@ -697,11 +607,37 @@ function removedStoredDeviceError(ctx, username) {
|
|
|
697
607
|
return err;
|
|
698
608
|
}
|
|
699
609
|
|
|
610
|
+
async function resolveRegistrationPassword(
|
|
611
|
+
ctx,
|
|
612
|
+
args,
|
|
613
|
+
rl = null,
|
|
614
|
+
missingMessage = "Password is required to register a new account. Usage: vex auth register <username> <password> or pass --password <password>.",
|
|
615
|
+
) {
|
|
616
|
+
const provided = args[1] ?? ctx.password;
|
|
617
|
+
if (typeof provided === "string" && provided.trim().length > 0) {
|
|
618
|
+
return provided;
|
|
619
|
+
}
|
|
620
|
+
if (input.isTTY && output.isTTY) {
|
|
621
|
+
if (rl) {
|
|
622
|
+
const password = await askText(rl, "password");
|
|
623
|
+
if (password) return password;
|
|
624
|
+
} else {
|
|
625
|
+
const prompt = createInterface({ input, output });
|
|
626
|
+
try {
|
|
627
|
+
const password = await askText(prompt, "password");
|
|
628
|
+
if (password) return password;
|
|
629
|
+
} finally {
|
|
630
|
+
prompt.close();
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
throw new Error(missingMessage);
|
|
635
|
+
}
|
|
636
|
+
|
|
700
637
|
async function register(ctx, args) {
|
|
701
638
|
const requestedUsername = args[0] ?? ctx.username;
|
|
702
|
-
const password = args[1] ?? ctx.password;
|
|
703
639
|
if (!requestedUsername) {
|
|
704
|
-
throw new Error("Usage: vex-chat register <username>
|
|
640
|
+
throw new Error("Usage: vex-chat register <username> <password>");
|
|
705
641
|
}
|
|
706
642
|
const config = await readConfig(ctx.configPath);
|
|
707
643
|
const accountRef = resolveAccountEntry(ctx, config, requestedUsername);
|
|
@@ -713,6 +649,7 @@ async function register(ctx, args) {
|
|
|
713
649
|
`Local account already exists for ${username}. Use login or remove it from ${ctx.configPath}.`,
|
|
714
650
|
);
|
|
715
651
|
}
|
|
652
|
+
const password = await resolveRegistrationPassword(ctx, args);
|
|
716
653
|
const privateKey = Client.generateSecretKey();
|
|
717
654
|
const client = await Client.create(privateKey, ctx.clientOptions);
|
|
718
655
|
attachDebugClientEvents(ctx, client, `register:${username}`);
|
|
@@ -800,20 +737,6 @@ function buildPasskeyLoginUrl(ctx, { code, pending, username }) {
|
|
|
800
737
|
return url.toString();
|
|
801
738
|
}
|
|
802
739
|
|
|
803
|
-
function buildPasskeyRegistrationUrl(ctx, { token, userID, username }) {
|
|
804
|
-
const url = new URL(ctx.passkeyLoginUrl ?? defaultPasskeyLoginUrl(ctx));
|
|
805
|
-
url.hash = new URLSearchParams({
|
|
806
|
-
api: apiBaseUrl(ctx),
|
|
807
|
-
device: ctx.clientOptions.deviceName ?? "vex-chat-cli",
|
|
808
|
-
mode: "register",
|
|
809
|
-
name: ctx.clientOptions.deviceName ?? "vex-chat-cli",
|
|
810
|
-
token,
|
|
811
|
-
user: userID,
|
|
812
|
-
username,
|
|
813
|
-
}).toString();
|
|
814
|
-
return url.toString();
|
|
815
|
-
}
|
|
816
|
-
|
|
817
740
|
async function openExternalUrl(url) {
|
|
818
741
|
const [command, args] =
|
|
819
742
|
process.platform === "darwin"
|
|
@@ -863,68 +786,6 @@ async function launchPasskeyLogin(ctx, username, pending, code) {
|
|
|
863
786
|
}
|
|
864
787
|
}
|
|
865
788
|
|
|
866
|
-
async function launchInitialPasskeySetup(ctx, client, username) {
|
|
867
|
-
const token = getClientBearerToken(client);
|
|
868
|
-
if (!token) {
|
|
869
|
-
throw new Error(
|
|
870
|
-
"Passkey setup is required, but the CLI could not read the registration token.",
|
|
871
|
-
);
|
|
872
|
-
}
|
|
873
|
-
const userID = client.me.user().userID;
|
|
874
|
-
let url;
|
|
875
|
-
try {
|
|
876
|
-
url = buildPasskeyRegistrationUrl(ctx, {
|
|
877
|
-
token,
|
|
878
|
-
userID,
|
|
879
|
-
username,
|
|
880
|
-
});
|
|
881
|
-
} catch (err) {
|
|
882
|
-
throw new Error(
|
|
883
|
-
`Could not build passkey setup URL: ${err instanceof Error ? err.message : String(err)}`,
|
|
884
|
-
);
|
|
885
|
-
}
|
|
886
|
-
|
|
887
|
-
console.log(
|
|
888
|
-
`${color(ROOT_ACCENT, "passkey setup")} ${color("dim", "opens in your browser")}`,
|
|
889
|
-
);
|
|
890
|
-
console.log(
|
|
891
|
-
color(
|
|
892
|
-
"dim",
|
|
893
|
-
"New accounts must add a passkey before the CLI can connect.",
|
|
894
|
-
),
|
|
895
|
-
);
|
|
896
|
-
console.log(`${color("dim", "url")} ${url}`);
|
|
897
|
-
if (ctx.openBrowser) {
|
|
898
|
-
const opened = await openExternalUrl(url);
|
|
899
|
-
if (!opened) {
|
|
900
|
-
console.log(
|
|
901
|
-
color(
|
|
902
|
-
"yellow",
|
|
903
|
-
"Could not open a browser. Copy the URL above.",
|
|
904
|
-
),
|
|
905
|
-
);
|
|
906
|
-
}
|
|
907
|
-
}
|
|
908
|
-
|
|
909
|
-
console.log(color(ROOT_ACCENT, "waiting for passkey setup..."));
|
|
910
|
-
for (let attempt = 0; attempt < 300; attempt++) {
|
|
911
|
-
await sleep(2000);
|
|
912
|
-
const passkeys = await client.passkeys.list().catch((err) => {
|
|
913
|
-
debugLog(ctx, "passkey.register.poll.error", {
|
|
914
|
-
error: err,
|
|
915
|
-
username,
|
|
916
|
-
});
|
|
917
|
-
return [];
|
|
918
|
-
});
|
|
919
|
-
if (passkeys.length > 0) {
|
|
920
|
-
if (input.isTTY && output.isTTY) output.write("\n");
|
|
921
|
-
return;
|
|
922
|
-
}
|
|
923
|
-
if (input.isTTY && output.isTTY) output.write(color("dim", "."));
|
|
924
|
-
}
|
|
925
|
-
throw new Error("Timed out waiting for passkey setup.");
|
|
926
|
-
}
|
|
927
|
-
|
|
928
789
|
async function persistPendingLocalAccount(
|
|
929
790
|
ctx,
|
|
930
791
|
config,
|
|
@@ -1020,11 +881,20 @@ async function loginWithDeviceApproval(ctx, username) {
|
|
|
1020
881
|
}
|
|
1021
882
|
|
|
1022
883
|
const { username: accountUsername } = accountRef;
|
|
884
|
+
const password = await resolveRegistrationPassword(
|
|
885
|
+
ctx,
|
|
886
|
+
[accountUsername],
|
|
887
|
+
null,
|
|
888
|
+
"Password is required to sign in on a new device. Pass it as the second argument or with --password.",
|
|
889
|
+
);
|
|
1023
890
|
const privateKey = Client.generateSecretKey();
|
|
1024
891
|
const client = await Client.create(privateKey, ctx.clientOptions);
|
|
1025
892
|
attachDebugClientEvents(ctx, client, `login-request:${accountUsername}`);
|
|
1026
893
|
try {
|
|
1027
|
-
const [, registerErr] = await client.
|
|
894
|
+
const [, registerErr] = await client.requestDeviceEnrollment(
|
|
895
|
+
accountUsername,
|
|
896
|
+
password,
|
|
897
|
+
);
|
|
1028
898
|
if (!registerErr) {
|
|
1029
899
|
await persistNewLocalAccount(
|
|
1030
900
|
ctx,
|
|
@@ -1098,9 +968,7 @@ async function waitForDeviceApproval(
|
|
|
1098
968
|
}
|
|
1099
969
|
|
|
1100
970
|
const code = matchingCodeStringForSignKey(client.getKeys().public);
|
|
1101
|
-
console.log(
|
|
1102
|
-
`${color(ROOT_ACCENT, "device approval required")} ${color("dim", `request=${pending.requestID}`)}`,
|
|
1103
|
-
);
|
|
971
|
+
console.log(color(ROOT_ACCENT, "device approval required"));
|
|
1104
972
|
console.log(
|
|
1105
973
|
`${color("dim", "matching code")} ${formatDeviceApprovalCode(code)}`,
|
|
1106
974
|
);
|
|
@@ -1275,7 +1143,7 @@ async function authCommand(ctx, args) {
|
|
|
1275
1143
|
return;
|
|
1276
1144
|
default:
|
|
1277
1145
|
throw new Error(
|
|
1278
|
-
"Usage: vex auth register <username> | login <username> [password] | requests | use <username> | accounts | status",
|
|
1146
|
+
"Usage: vex auth register <username> <password> | login <username> [password] | requests | use <username> | accounts | status",
|
|
1279
1147
|
);
|
|
1280
1148
|
}
|
|
1281
1149
|
}
|
|
@@ -3417,11 +3285,12 @@ async function authenticateOrRegister(ctx, explicitUsername) {
|
|
|
3417
3285
|
if (answer && answer !== "y" && answer !== "yes") {
|
|
3418
3286
|
throw new Error("No local account selected.");
|
|
3419
3287
|
}
|
|
3288
|
+
const password = await resolveRegistrationPassword(ctx, [entered], rl);
|
|
3420
3289
|
const privateKey = Client.generateSecretKey();
|
|
3421
3290
|
const client = await Client.create(privateKey, ctx.clientOptions);
|
|
3422
3291
|
attachDebugClientEvents(ctx, client, `register:${entered}`);
|
|
3423
3292
|
try {
|
|
3424
|
-
const [, registerErr] = await client.register(entered);
|
|
3293
|
+
const [, registerErr] = await client.register(entered, password);
|
|
3425
3294
|
if (registerErr) throw registerErr;
|
|
3426
3295
|
await persistNewLocalAccount(
|
|
3427
3296
|
ctx,
|
|
@@ -3474,21 +3343,7 @@ async function connectAndWait(client, ctx = null, label = "client") {
|
|
|
3474
3343
|
debugLog(ctx, "client.connect.skip.connected", { label });
|
|
3475
3344
|
return;
|
|
3476
3345
|
}
|
|
3477
|
-
|
|
3478
|
-
await connectOnceAndWait(client, ctx, label);
|
|
3479
|
-
return;
|
|
3480
|
-
} catch (err) {
|
|
3481
|
-
if (!ctx || !isInitialPasskeySetupRequired(err)) {
|
|
3482
|
-
throw err;
|
|
3483
|
-
}
|
|
3484
|
-
const username = client.me.user().username ?? "account";
|
|
3485
|
-
debugLog(ctx, "client.connect.passkeySetup.required", {
|
|
3486
|
-
label,
|
|
3487
|
-
username,
|
|
3488
|
-
});
|
|
3489
|
-
await launchInitialPasskeySetup(ctx, client, username);
|
|
3490
|
-
await connectOnceAndWait(client, ctx, `${label}:passkey`);
|
|
3491
|
-
}
|
|
3346
|
+
await connectOnceAndWait(client, ctx, label);
|
|
3492
3347
|
}
|
|
3493
3348
|
|
|
3494
3349
|
async function connectOnceAndWait(client, ctx = null, label = "client") {
|
|
@@ -4688,7 +4543,7 @@ Commands:
|
|
|
4688
4543
|
vex open the live terminal chat app
|
|
4689
4544
|
vex <username> open as a specific local user
|
|
4690
4545
|
vex chat [username] open the live terminal chat app
|
|
4691
|
-
vex auth register <username>
|
|
4546
|
+
vex auth register <username> <password>
|
|
4692
4547
|
vex auth login <username> request approval as a second device
|
|
4693
4548
|
vex auth requests list pending device login requests
|
|
4694
4549
|
vex auth accounts
|
|
@@ -4699,7 +4554,7 @@ Commands:
|
|
|
4699
4554
|
Flags:
|
|
4700
4555
|
--username <name> local account to use
|
|
4701
4556
|
--user <name> alias for --username
|
|
4702
|
-
--password <password> fallback password for login
|
|
4557
|
+
--password <password> fallback password for register/login
|
|
4703
4558
|
--api-url <url> API base URL, e.g. http://127.0.0.1:16777
|
|
4704
4559
|
--host <host:port> API host, default api.vex.wtf
|
|
4705
4560
|
--local connect to local Spire at 127.0.0.1:16777 over http/ws
|
|
@@ -4746,12 +4601,7 @@ main()
|
|
|
4746
4601
|
.then(() => {
|
|
4747
4602
|
process.exit(0);
|
|
4748
4603
|
})
|
|
4749
|
-
.catch((
|
|
4750
|
-
console.error(
|
|
4751
|
-
color(
|
|
4752
|
-
ROOT_ACCENT,
|
|
4753
|
-
err instanceof Error ? err.message : String(err),
|
|
4754
|
-
),
|
|
4755
|
-
);
|
|
4604
|
+
.catch(() => {
|
|
4605
|
+
console.error(color(ROOT_ACCENT, "Vex command failed."));
|
|
4756
4606
|
process.exit(1);
|
|
4757
4607
|
});
|