@vex-chat/cli 0.1.3 → 0.1.5

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.
Files changed (2) hide show
  1. package/package.json +2 -2
  2. package/src/vex-chat.js +382 -69
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vex-chat/cli",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
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": "^6.6.1"
34
+ "@vex-chat/libvex": "^6.7.0"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@types/node": "^25.6.0",
package/src/vex-chat.js CHANGED
@@ -359,14 +359,238 @@ function httpFromApiUrl(raw) {
359
359
  }
360
360
  }
361
361
 
362
+ function normalizeAccountHost(host) {
363
+ return String(host ?? DEFAULT_HOST)
364
+ .trim()
365
+ .toLowerCase();
366
+ }
367
+
368
+ function normalizeAccountName(value) {
369
+ return String(value ?? "")
370
+ .trim()
371
+ .toLowerCase();
372
+ }
373
+
374
+ function accountKeyFor(ctx, username) {
375
+ return `${normalizeAccountName(username)}@${normalizeAccountHost(ctx.clientOptions.host)}`;
376
+ }
377
+
378
+ function parseAccountSelector(ctx, value) {
379
+ const selector = normalizeAccountName(value);
380
+ const currentHost = normalizeAccountHost(ctx.clientOptions.host);
381
+ const at = selector.lastIndexOf("@");
382
+ if (at > 0) {
383
+ const host = selector.slice(at + 1);
384
+ return {
385
+ host,
386
+ hostMatches: host === currentHost,
387
+ key: selector,
388
+ scoped: true,
389
+ username: selector.slice(0, at),
390
+ };
391
+ }
392
+ return {
393
+ host: currentHost,
394
+ hostMatches: true,
395
+ key: `${selector}@${currentHost}`,
396
+ scoped: false,
397
+ username: selector,
398
+ };
399
+ }
400
+
401
+ function stripExpiredPendingApproval(account) {
402
+ const expiresAt = account?.pendingApproval?.expiresAt;
403
+ if (typeof expiresAt !== "string") return false;
404
+ const expiresAtMs = Date.parse(expiresAt);
405
+ if (Number.isFinite(expiresAtMs) && expiresAtMs > Date.now()) {
406
+ return false;
407
+ }
408
+ delete account.pendingApproval;
409
+ return true;
410
+ }
411
+
412
+ function normalizeStoredAccount(config, key, fallbackUsername) {
413
+ const account = config.accounts[key];
414
+ if (!account || typeof account !== "object") return false;
415
+ let changed = stripExpiredPendingApproval(account);
416
+ const username =
417
+ typeof account.username === "string" && account.username.trim()
418
+ ? account.username.trim().toLowerCase()
419
+ : fallbackUsername;
420
+ if (account.username !== username) {
421
+ account.username = username;
422
+ changed = true;
423
+ }
424
+ return changed;
425
+ }
426
+
427
+ function removeUnusableAccount(config, key) {
428
+ const account = config.accounts[key];
429
+ if (!account || account.deviceID || account.pendingApproval) {
430
+ return false;
431
+ }
432
+ delete config.accounts[key];
433
+ if (config.lastUsername === key) {
434
+ delete config.lastUsername;
435
+ }
436
+ return true;
437
+ }
438
+
439
+ function resolveAccountEntry(ctx, config, selector) {
440
+ const parsed = parseAccountSelector(ctx, selector);
441
+ let changed = false;
442
+
443
+ if (!parsed.username) {
444
+ return { ...parsed, account: null, changed };
445
+ }
446
+
447
+ const exact = config.accounts[parsed.key];
448
+ if (parsed.scoped) {
449
+ if (exact) {
450
+ changed =
451
+ normalizeStoredAccount(config, parsed.key, parsed.username) ||
452
+ changed;
453
+ if (removeUnusableAccount(config, parsed.key)) {
454
+ return { ...parsed, account: null, changed: true };
455
+ }
456
+ return {
457
+ ...parsed,
458
+ account: config.accounts[parsed.key],
459
+ changed,
460
+ };
461
+ }
462
+ return { ...parsed, account: null, changed };
463
+ }
464
+
465
+ const scopedKey = accountKeyFor(ctx, parsed.username);
466
+ const scoped = config.accounts[scopedKey];
467
+ if (scoped) {
468
+ changed =
469
+ normalizeStoredAccount(config, scopedKey, parsed.username) ||
470
+ changed;
471
+ if (removeUnusableAccount(config, scopedKey)) {
472
+ return {
473
+ ...parsed,
474
+ account: null,
475
+ changed: true,
476
+ key: scopedKey,
477
+ };
478
+ }
479
+ const legacy = config.accounts[parsed.username];
480
+ if (legacy && stripExpiredPendingApproval(legacy)) {
481
+ delete config.accounts[parsed.username];
482
+ changed = true;
483
+ }
484
+ if (config.lastUsername === parsed.username) {
485
+ config.lastUsername = scopedKey;
486
+ changed = true;
487
+ }
488
+ return {
489
+ ...parsed,
490
+ account: config.accounts[scopedKey],
491
+ changed,
492
+ key: scopedKey,
493
+ };
494
+ }
495
+
496
+ const legacy = config.accounts[parsed.username];
497
+ if (!legacy) {
498
+ return { ...parsed, account: null, changed, key: scopedKey };
499
+ }
500
+
501
+ stripExpiredPendingApproval(legacy);
502
+ if (!legacy.deviceID && !legacy.pendingApproval) {
503
+ delete config.accounts[parsed.username];
504
+ if (config.lastUsername === parsed.username) {
505
+ delete config.lastUsername;
506
+ }
507
+ return { ...parsed, account: null, changed: true, key: scopedKey };
508
+ }
509
+
510
+ config.accounts[scopedKey] = {
511
+ ...legacy,
512
+ username:
513
+ typeof legacy.username === "string" && legacy.username.trim()
514
+ ? legacy.username.trim().toLowerCase()
515
+ : parsed.username,
516
+ };
517
+ delete config.accounts[parsed.username];
518
+ if (config.lastUsername === parsed.username) {
519
+ config.lastUsername = scopedKey;
520
+ }
521
+ return {
522
+ ...parsed,
523
+ account: config.accounts[scopedKey],
524
+ changed: true,
525
+ key: scopedKey,
526
+ };
527
+ }
528
+
529
+ async function writeConfigIfChanged(ctx, config, changed) {
530
+ if (changed) {
531
+ await writeConfig(ctx.configPath, config);
532
+ }
533
+ }
534
+
535
+ function assertAccountHostMatches(ctx, accountRef) {
536
+ if (!accountRef.scoped || accountRef.hostMatches) return;
537
+ const currentHost = normalizeAccountHost(ctx.clientOptions.host);
538
+ throw new Error(
539
+ `Local account ${accountRef.key} is for ${accountRef.host}; current host is ${currentHost}. Pass --host ${accountRef.host}.`,
540
+ );
541
+ }
542
+
543
+ function deleteLocalAccount(ctx, config, username) {
544
+ const { key } = parseAccountSelector(ctx, username);
545
+ delete config.accounts[key];
546
+ if (config.lastUsername === key) {
547
+ delete config.lastUsername;
548
+ }
549
+ }
550
+
551
+ function formatRemovedDeviceLoginHint(ctx, username) {
552
+ const host = normalizeAccountHost(ctx.clientOptions.host);
553
+ return `Local device for ${username}@${host} is no longer on the account. Run vex auth login ${username} --host ${host} to add this machine as a new device.`;
554
+ }
555
+
556
+ function isMissingStoredDeviceError(err) {
557
+ if (err?.response?.status === 404) return true;
558
+ const message = err instanceof Error ? err.message : String(err ?? "");
559
+ return /\b(?:http|status(?: code)?)?\s*404\b|404[^\n]*not found|not found[^\n]*404/i.test(
560
+ message,
561
+ );
562
+ }
563
+
564
+ function isRemovedStoredDeviceError(err) {
565
+ return err?.name === "RemovedStoredDeviceError";
566
+ }
567
+
568
+ async function removeStoredDeviceAccount(ctx, config, accountRef) {
569
+ delete config.accounts[accountRef.key];
570
+ if (config.lastUsername === accountRef.key) {
571
+ delete config.lastUsername;
572
+ }
573
+ await writeConfig(ctx.configPath, config);
574
+ }
575
+
576
+ function removedStoredDeviceError(ctx, username) {
577
+ const err = new Error(formatRemovedDeviceLoginHint(ctx, username));
578
+ err.name = "RemovedStoredDeviceError";
579
+ return err;
580
+ }
581
+
362
582
  async function register(ctx, args) {
363
- const username = (args[0] ?? ctx.username)?.toLowerCase();
583
+ const requestedUsername = args[0] ?? ctx.username;
364
584
  const password = args[1] ?? ctx.password;
365
- if (!username) {
585
+ if (!requestedUsername) {
366
586
  throw new Error("Usage: vex-chat register <username> [password]");
367
587
  }
368
588
  const config = await readConfig(ctx.configPath);
369
- if (config.accounts[username]) {
589
+ const accountRef = resolveAccountEntry(ctx, config, requestedUsername);
590
+ await writeConfigIfChanged(ctx, config, accountRef.changed);
591
+ assertAccountHostMatches(ctx, accountRef);
592
+ const { username } = accountRef;
593
+ if (accountRef.account) {
370
594
  throw new Error(
371
595
  `Local account already exists for ${username}. Use login or remove it from ${ctx.configPath}.`,
372
596
  );
@@ -414,15 +638,17 @@ async function persistNewLocalAccount(
414
638
  client,
415
639
  deviceID = client.me.device().deviceID,
416
640
  ) {
417
- config.accounts[username] = {
641
+ const accountRef = parseAccountSelector(ctx, username);
642
+ const storedUsername = client.me.user().username ?? accountRef.username;
643
+ config.accounts[accountRef.key] = {
418
644
  deviceID,
419
645
  privateKey,
420
646
  userID: client.me.user().userID,
421
- username,
647
+ username: storedUsername,
422
648
  };
423
- config.lastUsername = username;
649
+ config.lastUsername = accountRef.key;
424
650
  await writeConfig(ctx.configPath, config);
425
- return config.accounts[username];
651
+ return { ...config.accounts[accountRef.key], accountKey: accountRef.key };
426
652
  }
427
653
 
428
654
  async function persistPendingLocalAccount(
@@ -432,8 +658,9 @@ async function persistPendingLocalAccount(
432
658
  privateKey,
433
659
  pending,
434
660
  ) {
435
- const previous = config.accounts[username] ?? {};
436
- config.accounts[username] = {
661
+ const accountRef = parseAccountSelector(ctx, username);
662
+ const previous = config.accounts[accountRef.key] ?? {};
663
+ config.accounts[accountRef.key] = {
437
664
  ...previous,
438
665
  privateKey,
439
666
  pendingApproval: {
@@ -442,38 +669,38 @@ async function persistPendingLocalAccount(
442
669
  requestID: pending.requestID,
443
670
  },
444
671
  ...(pending.userID ? { userID: pending.userID } : {}),
445
- username,
672
+ username: accountRef.username,
446
673
  };
447
- config.lastUsername = username;
674
+ config.lastUsername = accountRef.key;
448
675
  await writeConfig(ctx.configPath, config);
449
- return config.accounts[username];
676
+ return { ...config.accounts[accountRef.key], accountKey: accountRef.key };
450
677
  }
451
678
 
452
679
  async function login(ctx, args) {
453
- const username = (args[0] ?? ctx.username)?.toLowerCase();
680
+ const requestedUsername = args[0] ?? ctx.username;
454
681
  const password = args[1] ?? ctx.password;
455
- if (!username) {
682
+ if (!requestedUsername) {
456
683
  throw new Error("Usage: vex-chat login <username> [password]");
457
684
  }
685
+ const { username } = parseAccountSelector(ctx, requestedUsername);
458
686
  if (!password) {
459
- await loginWithDeviceApproval(ctx, username);
687
+ await loginWithDeviceApproval(ctx, requestedUsername);
460
688
  return;
461
689
  }
462
- const { client, config } = await makeClient(ctx, username);
690
+ const { client, config } = await makeClient(ctx, requestedUsername);
463
691
  attachDebugClientEvents(ctx, client, `login:${username}`);
464
692
  try {
465
693
  const loginResult = await client.login(username, password);
466
694
  if (!loginResult.ok)
467
695
  throw new Error(loginResult.error ?? "Login failed.");
468
696
  await connectAndWait(client, ctx, `login:${username}`);
469
- config.accounts[username] = {
470
- deviceID: client.me.device().deviceID,
471
- privateKey: client.getKeys().private,
472
- userID: client.me.user().userID,
697
+ await persistNewLocalAccount(
698
+ ctx,
699
+ config,
473
700
  username,
474
- };
475
- config.lastUsername = username;
476
- await writeConfig(ctx.configPath, config);
701
+ client.getKeys().private,
702
+ client,
703
+ );
477
704
  console.log(
478
705
  `${color(ROOT_ACCENT, "logged in")} ${color(userAccent(client.me.user().userID), username)}`,
479
706
  );
@@ -485,35 +712,60 @@ async function login(ctx, args) {
485
712
 
486
713
  async function loginWithDeviceApproval(ctx, username) {
487
714
  const config = await readConfig(ctx.configPath);
488
- if (config.accounts[username]) {
489
- const { client } = await authenticate(ctx, username);
715
+ const accountRef = resolveAccountEntry(ctx, config, username);
716
+ await writeConfigIfChanged(ctx, config, accountRef.changed);
717
+ assertAccountHostMatches(ctx, accountRef);
718
+ if (accountRef.account) {
719
+ let existing;
490
720
  try {
721
+ existing = await authenticate(ctx, accountRef.key);
722
+ } catch (err) {
723
+ if (!isRemovedStoredDeviceError(err)) {
724
+ throw err;
725
+ }
491
726
  console.log(
492
- `${color(ROOT_ACCENT, "using")} ${color(userAccent(client.me.user().userID), username)}`,
727
+ color(
728
+ "yellow",
729
+ `local device removed; requesting approval for ${accountRef.username} as a new device`,
730
+ ),
493
731
  );
494
- printWhoami(client);
495
- } finally {
496
- await client.close().catch(() => {});
732
+ await removeStoredDeviceAccount(ctx, config, accountRef);
733
+ }
734
+ if (existing) {
735
+ const { client } = existing;
736
+ try {
737
+ console.log(
738
+ `${color(ROOT_ACCENT, "using")} ${color(userAccent(client.me.user().userID), accountRef.username)}`,
739
+ );
740
+ printWhoami(client);
741
+ } finally {
742
+ await client.close().catch(() => {});
743
+ }
744
+ return;
497
745
  }
498
- return;
499
746
  }
500
747
 
748
+ const { username: accountUsername } = accountRef;
501
749
  const privateKey = Client.generateSecretKey();
502
750
  const client = await Client.create(privateKey, ctx.clientOptions);
503
- attachDebugClientEvents(ctx, client, `login-request:${username}`);
751
+ attachDebugClientEvents(ctx, client, `login-request:${accountUsername}`);
504
752
  try {
505
- const [, registerErr] = await client.register(username);
753
+ const [, registerErr] = await client.register(accountUsername);
506
754
  if (!registerErr) {
507
- await connectAndWait(client, ctx, `login-request:${username}`);
755
+ await connectAndWait(
756
+ client,
757
+ ctx,
758
+ `login-request:${accountUsername}`,
759
+ );
508
760
  await persistNewLocalAccount(
509
761
  ctx,
510
762
  config,
511
- username,
763
+ accountUsername,
512
764
  privateKey,
513
765
  client,
514
766
  );
515
767
  console.log(
516
- `${color(ROOT_ACCENT, "registered")} ${color(userAccent(client.me.user().userID), username)}`,
768
+ `${color(ROOT_ACCENT, "registered")} ${color(userAccent(client.me.user().userID), accountUsername)}`,
517
769
  );
518
770
  printWhoami(client);
519
771
  return;
@@ -521,12 +773,19 @@ async function loginWithDeviceApproval(ctx, username) {
521
773
  if (!isDeviceApprovalRequired(registerErr)) {
522
774
  throw registerErr;
523
775
  }
524
- await waitForDeviceApproval(ctx, client, config, username, privateKey, {
525
- challenge: registerErr.challenge,
526
- expiresAt: registerErr.expiresAt,
527
- requestID: registerErr.requestID,
528
- userID: registerErr.userID,
529
- });
776
+ await waitForDeviceApproval(
777
+ ctx,
778
+ client,
779
+ config,
780
+ accountUsername,
781
+ privateKey,
782
+ {
783
+ challenge: registerErr.challenge,
784
+ expiresAt: registerErr.expiresAt,
785
+ requestID: registerErr.requestID,
786
+ userID: registerErr.userID,
787
+ },
788
+ );
530
789
  } finally {
531
790
  await client.close().catch(() => {});
532
791
  }
@@ -591,7 +850,7 @@ async function waitForDeviceApproval(
591
850
  requestID: pending.requestID,
592
851
  })
593
852
  .catch(() => {});
594
- delete config.accounts[username];
853
+ deleteLocalAccount(ctx, config, username);
595
854
  await writeConfig(ctx.configPath, config);
596
855
  throw new Error("Device login cancelled.");
597
856
  }
@@ -672,7 +931,7 @@ async function waitForDeviceApproval(
672
931
  );
673
932
  }
674
933
  }
675
- delete config.accounts[username];
934
+ deleteLocalAccount(ctx, config, username);
676
935
  await writeConfig(ctx.configPath, config);
677
936
  throw new Error(`Device login ${current.status}.`);
678
937
  }
@@ -812,18 +1071,21 @@ function formatDeviceRequestLine(request) {
812
1071
  }
813
1072
 
814
1073
  async function useAccount(ctx, args) {
815
- const username = requireArg(args, 0, "username").toLowerCase();
1074
+ const requestedUsername = requireArg(args, 0, "username");
816
1075
  const config = await readConfig(ctx.configPath);
817
- if (!config.accounts[username]) {
1076
+ const accountRef = resolveAccountEntry(ctx, config, requestedUsername);
1077
+ await writeConfigIfChanged(ctx, config, accountRef.changed);
1078
+ assertAccountHostMatches(ctx, accountRef);
1079
+ if (!accountRef.account) {
818
1080
  throw new Error(
819
- `No local account for ${username}. Run vex auth register ${username} first.`,
1081
+ `No local account for ${accountRef.username}. Run vex auth register ${accountRef.username} first.`,
820
1082
  );
821
1083
  }
822
- config.lastUsername = username;
1084
+ config.lastUsername = accountRef.key;
823
1085
  await writeConfig(ctx.configPath, config);
824
- const account = config.accounts[username];
1086
+ const account = config.accounts[accountRef.key];
825
1087
  console.log(
826
- `${color(ROOT_ACCENT, "using")} ${color(userAccent(account.userID), username)}`,
1088
+ `${color(ROOT_ACCENT, "using")} ${color(userAccent(account.userID), accountRef.key)}`,
827
1089
  );
828
1090
  }
829
1091
 
@@ -890,14 +1152,14 @@ async function channelCommand(ctx, args) {
890
1152
  await useChannel(ctx, args);
891
1153
  return;
892
1154
  }
893
- await withReadyClient(ctx, args, async (client, rest) => {
1155
+ await withReadyClient(ctx, args, async (client, rest, meta) => {
894
1156
  if (sub === "list" || sub === "ls") {
895
1157
  const serverID = requireArg(rest, 0, "server id");
896
1158
  printChannels(await client.channels.retrieve(serverID));
897
1159
  return;
898
1160
  }
899
1161
  if (sub === "history") {
900
- const accountState = accountUiState(meta.config, meta.account);
1162
+ const accountState = accountUiState(ctx, meta.config, meta.account);
901
1163
  const channelID = rest[0] ?? accountState.lastChannel;
902
1164
  if (!channelID)
903
1165
  throw new Error(
@@ -976,7 +1238,7 @@ async function groupCommand(ctx, args) {
976
1238
 
977
1239
  async function sendCommand(ctx, args) {
978
1240
  await withReadyClient(ctx, args, async (client, rest, meta) => {
979
- const accountState = accountUiState(meta.config, meta.account);
1241
+ const accountState = accountUiState(ctx, meta.config, meta.account);
980
1242
  let channelID = rest[0];
981
1243
  let messageParts = rest.slice(1);
982
1244
  if (messageParts.length === 0 && accountState.lastChannel) {
@@ -1043,7 +1305,7 @@ async function createServerInChat(ctx, client, state, name, rl) {
1043
1305
 
1044
1306
  async function createInviteInteractive(ctx, client, state, args, rl) {
1045
1307
  const config = await readConfig(ctx.configPath);
1046
- const accountState = accountUiState(config, state.account);
1308
+ const accountState = accountUiState(ctx, config, state.account);
1047
1309
  let serverID =
1048
1310
  state.target?.type === "channel" && state.target.serverID
1049
1311
  ? state.target.serverID
@@ -2140,7 +2402,7 @@ async function chat(ctx, args) {
2140
2402
  username,
2141
2403
  );
2142
2404
  attachDebugClientEvents(ctx, client, `chat:${account.username}`);
2143
- const accountState = accountUiState(config, account);
2405
+ const accountState = accountUiState(ctx, config, account);
2144
2406
  const state = {
2145
2407
  account,
2146
2408
  avatarMarkers: new Map(),
@@ -2703,13 +2965,20 @@ async function withReadyClient(ctx, args, fn) {
2703
2965
 
2704
2966
  async function authenticate(ctx, explicitUsername) {
2705
2967
  const config = await readConfig(ctx.configPath);
2706
- const username = (explicitUsername ?? config.lastUsername)?.toLowerCase();
2968
+ const accountRef = resolveAccountEntry(
2969
+ ctx,
2970
+ config,
2971
+ explicitUsername ?? config.lastUsername,
2972
+ );
2973
+ await writeConfigIfChanged(ctx, config, accountRef.changed);
2974
+ assertAccountHostMatches(ctx, accountRef);
2975
+ const { username } = accountRef;
2707
2976
  if (!username) {
2708
2977
  throw new Error(
2709
2978
  "No local account selected. Use --username or run register/login first.",
2710
2979
  );
2711
2980
  }
2712
- const account = config.accounts[username];
2981
+ const account = accountRef.account;
2713
2982
  if (!account) {
2714
2983
  throw new Error(
2715
2984
  `No local account for ${username}. Run register/login first.`,
@@ -2736,6 +3005,11 @@ async function authenticate(ctx, explicitUsername) {
2736
3005
  account.pendingApproval,
2737
3006
  );
2738
3007
  }
3008
+ if (deviceErr && isMissingStoredDeviceError(deviceErr)) {
3009
+ await client.close().catch(() => {});
3010
+ await removeStoredDeviceAccount(ctx, config, accountRef);
3011
+ throw removedStoredDeviceError(ctx, username);
3012
+ }
2739
3013
  if (deviceErr && ctx.password) {
2740
3014
  const loginResult = await client.login(username, ctx.password);
2741
3015
  if (!loginResult.ok)
@@ -2750,9 +3024,13 @@ async function authenticate(ctx, explicitUsername) {
2750
3024
  }
2751
3025
  account.userID = client.me.user().userID;
2752
3026
  account.username = client.me.user().username ?? username;
2753
- config.accounts[username] = account;
3027
+ config.accounts[accountRef.key] = account;
2754
3028
  await writeConfig(ctx.configPath, config);
2755
- return { account, client, config };
3029
+ return {
3030
+ account: { ...account, accountKey: accountRef.key },
3031
+ client,
3032
+ config,
3033
+ };
2756
3034
  }
2757
3035
 
2758
3036
  async function resolveStoredDeviceID(ctx, client, account, username) {
@@ -2775,20 +3053,45 @@ async function resolveStoredDeviceID(ctx, client, account, username) {
2775
3053
 
2776
3054
  async function authenticateOrRegister(ctx, explicitUsername) {
2777
3055
  const config = await readConfig(ctx.configPath);
2778
- const username = (explicitUsername ?? config.lastUsername)?.toLowerCase();
2779
- if (username && config.accounts[username]) {
2780
- return authenticate(ctx, username);
3056
+ const accountRef = resolveAccountEntry(
3057
+ ctx,
3058
+ config,
3059
+ explicitUsername ?? config.lastUsername,
3060
+ );
3061
+ await writeConfigIfChanged(ctx, config, accountRef.changed);
3062
+ assertAccountHostMatches(ctx, accountRef);
3063
+ if (accountRef.account) {
3064
+ try {
3065
+ return await authenticate(ctx, accountRef.key);
3066
+ } catch (err) {
3067
+ if (!isRemovedStoredDeviceError(err)) {
3068
+ throw err;
3069
+ }
3070
+ await removeStoredDeviceAccount(ctx, config, accountRef);
3071
+ console.log(
3072
+ color(
3073
+ "yellow",
3074
+ `local device removed; setting up ${accountRef.username} as a new device`,
3075
+ ),
3076
+ );
3077
+ }
2781
3078
  }
2782
3079
 
2783
3080
  const rl = createInterface({ input, output });
2784
3081
  try {
2785
3082
  console.log("Welcome to vex.");
2786
- const entered = (username ?? (await rl.question("username: ")))
3083
+ const enteredRaw = (
3084
+ accountRef.username || (await rl.question("username: "))
3085
+ )
2787
3086
  .trim()
2788
3087
  .toLowerCase();
3088
+ const enteredRef = resolveAccountEntry(ctx, config, enteredRaw);
3089
+ await writeConfigIfChanged(ctx, config, enteredRef.changed);
3090
+ assertAccountHostMatches(ctx, enteredRef);
3091
+ const entered = enteredRef.username;
2789
3092
  if (!entered) throw new Error("username is required");
2790
- if (config.accounts[entered]) {
2791
- return authenticate(ctx, entered);
3093
+ if (enteredRef.account) {
3094
+ return authenticate(ctx, enteredRef.key);
2792
3095
  }
2793
3096
  const answer = (await rl.question(`register ${entered}? [Y/n] `))
2794
3097
  .trim()
@@ -2834,7 +3137,10 @@ async function authenticateOrRegister(ctx, explicitUsername) {
2834
3137
 
2835
3138
  async function makeClient(ctx, username) {
2836
3139
  const config = await readConfig(ctx.configPath);
2837
- const account = config.accounts[username];
3140
+ const accountRef = resolveAccountEntry(ctx, config, username);
3141
+ await writeConfigIfChanged(ctx, config, accountRef.changed);
3142
+ assertAccountHostMatches(ctx, accountRef);
3143
+ const account = accountRef.account;
2838
3144
  const privateKey = account?.privateKey ?? Client.generateSecretKey();
2839
3145
  const client = await Client.create(privateKey, ctx.clientOptions);
2840
3146
  return { client, config };
@@ -3523,9 +3829,11 @@ function targetToAccountUi(target) {
3523
3829
  return patch;
3524
3830
  }
3525
3831
 
3526
- function accountUiState(config, account) {
3832
+ function accountUiState(ctx, config, account) {
3527
3833
  if (!account) return {};
3528
- const key = account.username?.toLowerCase();
3834
+ const key =
3835
+ account.accountKey ??
3836
+ (account.username ? accountKeyFor(ctx, account.username) : null);
3529
3837
  const stored = key ? config.accounts?.[key]?.ui : null;
3530
3838
  if (!stored || typeof stored !== "object") return {};
3531
3839
  return {
@@ -3543,9 +3851,14 @@ function accountUiState(config, account) {
3543
3851
 
3544
3852
  async function saveAccountUiState(ctx, account, patch) {
3545
3853
  const config = await readConfig(ctx.configPath);
3546
- const key = account?.username?.toLowerCase();
3854
+ const key =
3855
+ account?.accountKey ??
3856
+ (account?.username ? accountKeyFor(ctx, account.username) : null);
3547
3857
  if (!key || !config.accounts[key]) return;
3548
- const current = accountUiState(config, config.accounts[key]);
3858
+ const current = accountUiState(ctx, config, {
3859
+ ...config.accounts[key],
3860
+ accountKey: key,
3861
+ });
3549
3862
  config.accounts[key] = {
3550
3863
  ...config.accounts[key],
3551
3864
  ui: {