@vex-chat/cli 0.3.0 → 0.5.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 +84 -187
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.5.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": "^8.0.0"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
37
|
"@types/node": "^25.6.0",
|
package/src/vex-chat.js
CHANGED
|
@@ -145,6 +145,9 @@ async function main() {
|
|
|
145
145
|
case "invite":
|
|
146
146
|
await inviteCommand(ctx, positionals);
|
|
147
147
|
break;
|
|
148
|
+
case "entitlements":
|
|
149
|
+
await entitlementsCommand(ctx, positionals);
|
|
150
|
+
break;
|
|
148
151
|
case "group":
|
|
149
152
|
await groupCommand(ctx, positionals);
|
|
150
153
|
break;
|
|
@@ -176,6 +179,7 @@ function isCommand(command) {
|
|
|
176
179
|
"channels",
|
|
177
180
|
"chat",
|
|
178
181
|
"dm",
|
|
182
|
+
"entitlements",
|
|
179
183
|
"group",
|
|
180
184
|
"help",
|
|
181
185
|
"invite",
|
|
@@ -285,6 +289,7 @@ async function createContext(flags) {
|
|
|
285
289
|
return {
|
|
286
290
|
dataDir,
|
|
287
291
|
configPath,
|
|
292
|
+
flags,
|
|
288
293
|
clientOptions: {
|
|
289
294
|
dbFolder: path.join(dataDir, "db"),
|
|
290
295
|
deviceName: "vex-chat-cli",
|
|
@@ -377,6 +382,11 @@ function httpFromApiUrl(raw) {
|
|
|
377
382
|
}
|
|
378
383
|
}
|
|
379
384
|
|
|
385
|
+
function apiBaseUrl(ctx) {
|
|
386
|
+
const scheme = ctx.clientOptions.unsafeHttp ? "http" : "https";
|
|
387
|
+
return `${scheme}://${ctx.clientOptions.host}`;
|
|
388
|
+
}
|
|
389
|
+
|
|
380
390
|
function normalizeAccountHost(host) {
|
|
381
391
|
return String(host ?? DEFAULT_HOST)
|
|
382
392
|
.trim()
|
|
@@ -583,96 +593,6 @@ function isRemovedStoredDeviceError(err) {
|
|
|
583
593
|
return err?.name === "RemovedStoredDeviceError";
|
|
584
594
|
}
|
|
585
595
|
|
|
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
|
-
|
|
676
596
|
async function removeStoredDeviceAccount(ctx, config, accountRef) {
|
|
677
597
|
delete config.accounts[accountRef.key];
|
|
678
598
|
if (config.lastUsername === accountRef.key) {
|
|
@@ -687,11 +607,34 @@ function removedStoredDeviceError(ctx, username) {
|
|
|
687
607
|
return err;
|
|
688
608
|
}
|
|
689
609
|
|
|
610
|
+
async function resolveRegistrationPassword(ctx, args, rl = null) {
|
|
611
|
+
const provided = args[1] ?? ctx.password;
|
|
612
|
+
if (typeof provided === "string" && provided.trim().length > 0) {
|
|
613
|
+
return provided;
|
|
614
|
+
}
|
|
615
|
+
if (input.isTTY && output.isTTY) {
|
|
616
|
+
if (rl) {
|
|
617
|
+
const password = await askText(rl, "password");
|
|
618
|
+
if (password) return password;
|
|
619
|
+
} else {
|
|
620
|
+
const prompt = createInterface({ input, output });
|
|
621
|
+
try {
|
|
622
|
+
const password = await askText(prompt, "password");
|
|
623
|
+
if (password) return password;
|
|
624
|
+
} finally {
|
|
625
|
+
prompt.close();
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
throw new Error(
|
|
630
|
+
"Password is required to register a new account. Usage: vex auth register <username> <password> or pass --password <password>.",
|
|
631
|
+
);
|
|
632
|
+
}
|
|
633
|
+
|
|
690
634
|
async function register(ctx, args) {
|
|
691
635
|
const requestedUsername = args[0] ?? ctx.username;
|
|
692
|
-
const password = args[1] ?? ctx.password;
|
|
693
636
|
if (!requestedUsername) {
|
|
694
|
-
throw new Error("Usage: vex-chat register <username>
|
|
637
|
+
throw new Error("Usage: vex-chat register <username> <password>");
|
|
695
638
|
}
|
|
696
639
|
const config = await readConfig(ctx.configPath);
|
|
697
640
|
const accountRef = resolveAccountEntry(ctx, config, requestedUsername);
|
|
@@ -703,6 +646,7 @@ async function register(ctx, args) {
|
|
|
703
646
|
`Local account already exists for ${username}. Use login or remove it from ${ctx.configPath}.`,
|
|
704
647
|
);
|
|
705
648
|
}
|
|
649
|
+
const password = await resolveRegistrationPassword(ctx, args);
|
|
706
650
|
const privateKey = Client.generateSecretKey();
|
|
707
651
|
const client = await Client.create(privateKey, ctx.clientOptions);
|
|
708
652
|
attachDebugClientEvents(ctx, client, `register:${username}`);
|
|
@@ -790,20 +734,6 @@ function buildPasskeyLoginUrl(ctx, { code, pending, username }) {
|
|
|
790
734
|
return url.toString();
|
|
791
735
|
}
|
|
792
736
|
|
|
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
737
|
async function openExternalUrl(url) {
|
|
808
738
|
const [command, args] =
|
|
809
739
|
process.platform === "darwin"
|
|
@@ -853,68 +783,6 @@ async function launchPasskeyLogin(ctx, username, pending, code) {
|
|
|
853
783
|
}
|
|
854
784
|
}
|
|
855
785
|
|
|
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
|
-
|
|
918
786
|
async function persistPendingLocalAccount(
|
|
919
787
|
ctx,
|
|
920
788
|
config,
|
|
@@ -1265,7 +1133,7 @@ async function authCommand(ctx, args) {
|
|
|
1265
1133
|
return;
|
|
1266
1134
|
default:
|
|
1267
1135
|
throw new Error(
|
|
1268
|
-
"Usage: vex auth register <username> | login <username> [password] | requests | use <username> | accounts | status",
|
|
1136
|
+
"Usage: vex auth register <username> <password> | login <username> [password] | requests | use <username> | accounts | status",
|
|
1269
1137
|
);
|
|
1270
1138
|
}
|
|
1271
1139
|
}
|
|
@@ -1495,6 +1363,47 @@ async function inviteCommand(ctx, args) {
|
|
|
1495
1363
|
});
|
|
1496
1364
|
}
|
|
1497
1365
|
|
|
1366
|
+
async function entitlementsCommand(ctx, args) {
|
|
1367
|
+
const sub = args.shift() ?? "status";
|
|
1368
|
+
if (sub === "set" || sub === "grant") {
|
|
1369
|
+
const userID = requireArg(args, 0, "user id");
|
|
1370
|
+
const tier = requireArg(args, 1, "tier");
|
|
1371
|
+
if (!["free", "plus", "pro"].includes(tier)) {
|
|
1372
|
+
throw new Error("Tier must be one of: free, plus, pro.");
|
|
1373
|
+
}
|
|
1374
|
+
const devKey = ctx.clientOptions.devApiKey;
|
|
1375
|
+
if (!devKey) {
|
|
1376
|
+
throw new Error(
|
|
1377
|
+
"A dev key is required. Pass --dev-key or set DEV_API_KEY.",
|
|
1378
|
+
);
|
|
1379
|
+
}
|
|
1380
|
+
const expiresAt = ctx.flags["expires-at"]
|
|
1381
|
+
? String(ctx.flags["expires-at"])
|
|
1382
|
+
: null;
|
|
1383
|
+
const res = await fetch(`${apiBaseUrl(ctx)}/__dev/billing/grants`, {
|
|
1384
|
+
body: JSON.stringify({ expiresAt, tier, userID }),
|
|
1385
|
+
headers: {
|
|
1386
|
+
"Content-Type": "application/json",
|
|
1387
|
+
"x-dev-api-key": devKey,
|
|
1388
|
+
},
|
|
1389
|
+
method: "POST",
|
|
1390
|
+
});
|
|
1391
|
+
if (!res.ok) {
|
|
1392
|
+
throw new Error(
|
|
1393
|
+
`Entitlement grant failed with HTTP ${String(res.status)}: ${await res.text()}`,
|
|
1394
|
+
);
|
|
1395
|
+
}
|
|
1396
|
+
const body = await res.json();
|
|
1397
|
+
console.log(
|
|
1398
|
+
`${color(ROOT_ACCENT, "granted")} ${color("bold", tier)} ${color("dim", `to ${userID}`)} ${color("dim", `source=${body.source ?? "unknown"}`)}`,
|
|
1399
|
+
);
|
|
1400
|
+
return;
|
|
1401
|
+
}
|
|
1402
|
+
throw new Error(
|
|
1403
|
+
"Usage: vex entitlements set <user-id> <free|plus|pro> [--expires-at <iso>] --dev-key <key>",
|
|
1404
|
+
);
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1498
1407
|
async function groupCommand(ctx, args) {
|
|
1499
1408
|
const sub = args[0];
|
|
1500
1409
|
if (sub === "send") {
|
|
@@ -3366,11 +3275,12 @@ async function authenticateOrRegister(ctx, explicitUsername) {
|
|
|
3366
3275
|
if (answer && answer !== "y" && answer !== "yes") {
|
|
3367
3276
|
throw new Error("No local account selected.");
|
|
3368
3277
|
}
|
|
3278
|
+
const password = await resolveRegistrationPassword(ctx, [entered], rl);
|
|
3369
3279
|
const privateKey = Client.generateSecretKey();
|
|
3370
3280
|
const client = await Client.create(privateKey, ctx.clientOptions);
|
|
3371
3281
|
attachDebugClientEvents(ctx, client, `register:${entered}`);
|
|
3372
3282
|
try {
|
|
3373
|
-
const [, registerErr] = await client.register(entered);
|
|
3283
|
+
const [, registerErr] = await client.register(entered, password);
|
|
3374
3284
|
if (registerErr) throw registerErr;
|
|
3375
3285
|
await persistNewLocalAccount(
|
|
3376
3286
|
ctx,
|
|
@@ -3423,21 +3333,7 @@ async function connectAndWait(client, ctx = null, label = "client") {
|
|
|
3423
3333
|
debugLog(ctx, "client.connect.skip.connected", { label });
|
|
3424
3334
|
return;
|
|
3425
3335
|
}
|
|
3426
|
-
|
|
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
|
-
}
|
|
3336
|
+
await connectOnceAndWait(client, ctx, label);
|
|
3441
3337
|
}
|
|
3442
3338
|
|
|
3443
3339
|
async function connectOnceAndWait(client, ctx = null, label = "client") {
|
|
@@ -4637,17 +4533,18 @@ Commands:
|
|
|
4637
4533
|
vex open the live terminal chat app
|
|
4638
4534
|
vex <username> open as a specific local user
|
|
4639
4535
|
vex chat [username] open the live terminal chat app
|
|
4640
|
-
vex auth register <username>
|
|
4536
|
+
vex auth register <username> <password>
|
|
4641
4537
|
vex auth login <username> request approval as a second device
|
|
4642
4538
|
vex auth requests list pending device login requests
|
|
4643
4539
|
vex auth accounts
|
|
4644
4540
|
vex auth use <username>
|
|
4541
|
+
vex entitlements set <user-id> <free|plus|pro> --dev-key <key>
|
|
4645
4542
|
vex whoami
|
|
4646
4543
|
|
|
4647
4544
|
Flags:
|
|
4648
4545
|
--username <name> local account to use
|
|
4649
4546
|
--user <name> alias for --username
|
|
4650
|
-
--password <password> fallback password for login
|
|
4547
|
+
--password <password> fallback password for register/login
|
|
4651
4548
|
--api-url <url> API base URL, e.g. http://127.0.0.1:16777
|
|
4652
4549
|
--host <host:port> API host, default api.vex.wtf
|
|
4653
4550
|
--local connect to local Spire at 127.0.0.1:16777 over http/ws
|