@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 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.1.6",
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.0.0"
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 (["debug", "http", "help", "local", "no-home"].includes(key)) {
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
- "Confirm this code on an existing signed-in device before approving.",
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, "notify existing devices?", "Y"))
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
- console.log(color(ROOT_ACCENT, "waiting for approval..."));
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 account = await persistNewLocalAccount(
3126
- ctx,
3127
- config,
3128
- entered,
3129
- privateKey,
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