@vex-chat/cli 0.1.3 → 0.1.4

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 +312 -65
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.4",
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.6.3"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@types/node": "^25.6.0",
package/src/vex-chat.js CHANGED
@@ -359,14 +359,207 @@ 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
+
362
551
  async function register(ctx, args) {
363
- const username = (args[0] ?? ctx.username)?.toLowerCase();
552
+ const requestedUsername = args[0] ?? ctx.username;
364
553
  const password = args[1] ?? ctx.password;
365
- if (!username) {
554
+ if (!requestedUsername) {
366
555
  throw new Error("Usage: vex-chat register <username> [password]");
367
556
  }
368
557
  const config = await readConfig(ctx.configPath);
369
- if (config.accounts[username]) {
558
+ const accountRef = resolveAccountEntry(ctx, config, requestedUsername);
559
+ await writeConfigIfChanged(ctx, config, accountRef.changed);
560
+ assertAccountHostMatches(ctx, accountRef);
561
+ const { username } = accountRef;
562
+ if (accountRef.account) {
370
563
  throw new Error(
371
564
  `Local account already exists for ${username}. Use login or remove it from ${ctx.configPath}.`,
372
565
  );
@@ -414,15 +607,17 @@ async function persistNewLocalAccount(
414
607
  client,
415
608
  deviceID = client.me.device().deviceID,
416
609
  ) {
417
- config.accounts[username] = {
610
+ const accountRef = parseAccountSelector(ctx, username);
611
+ const storedUsername = client.me.user().username ?? accountRef.username;
612
+ config.accounts[accountRef.key] = {
418
613
  deviceID,
419
614
  privateKey,
420
615
  userID: client.me.user().userID,
421
- username,
616
+ username: storedUsername,
422
617
  };
423
- config.lastUsername = username;
618
+ config.lastUsername = accountRef.key;
424
619
  await writeConfig(ctx.configPath, config);
425
- return config.accounts[username];
620
+ return { ...config.accounts[accountRef.key], accountKey: accountRef.key };
426
621
  }
427
622
 
428
623
  async function persistPendingLocalAccount(
@@ -432,8 +627,9 @@ async function persistPendingLocalAccount(
432
627
  privateKey,
433
628
  pending,
434
629
  ) {
435
- const previous = config.accounts[username] ?? {};
436
- config.accounts[username] = {
630
+ const accountRef = parseAccountSelector(ctx, username);
631
+ const previous = config.accounts[accountRef.key] ?? {};
632
+ config.accounts[accountRef.key] = {
437
633
  ...previous,
438
634
  privateKey,
439
635
  pendingApproval: {
@@ -442,38 +638,38 @@ async function persistPendingLocalAccount(
442
638
  requestID: pending.requestID,
443
639
  },
444
640
  ...(pending.userID ? { userID: pending.userID } : {}),
445
- username,
641
+ username: accountRef.username,
446
642
  };
447
- config.lastUsername = username;
643
+ config.lastUsername = accountRef.key;
448
644
  await writeConfig(ctx.configPath, config);
449
- return config.accounts[username];
645
+ return { ...config.accounts[accountRef.key], accountKey: accountRef.key };
450
646
  }
451
647
 
452
648
  async function login(ctx, args) {
453
- const username = (args[0] ?? ctx.username)?.toLowerCase();
649
+ const requestedUsername = args[0] ?? ctx.username;
454
650
  const password = args[1] ?? ctx.password;
455
- if (!username) {
651
+ if (!requestedUsername) {
456
652
  throw new Error("Usage: vex-chat login <username> [password]");
457
653
  }
654
+ const { username } = parseAccountSelector(ctx, requestedUsername);
458
655
  if (!password) {
459
- await loginWithDeviceApproval(ctx, username);
656
+ await loginWithDeviceApproval(ctx, requestedUsername);
460
657
  return;
461
658
  }
462
- const { client, config } = await makeClient(ctx, username);
659
+ const { client, config } = await makeClient(ctx, requestedUsername);
463
660
  attachDebugClientEvents(ctx, client, `login:${username}`);
464
661
  try {
465
662
  const loginResult = await client.login(username, password);
466
663
  if (!loginResult.ok)
467
664
  throw new Error(loginResult.error ?? "Login failed.");
468
665
  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,
666
+ await persistNewLocalAccount(
667
+ ctx,
668
+ config,
473
669
  username,
474
- };
475
- config.lastUsername = username;
476
- await writeConfig(ctx.configPath, config);
670
+ client.getKeys().private,
671
+ client,
672
+ );
477
673
  console.log(
478
674
  `${color(ROOT_ACCENT, "logged in")} ${color(userAccent(client.me.user().userID), username)}`,
479
675
  );
@@ -485,11 +681,14 @@ async function login(ctx, args) {
485
681
 
486
682
  async function loginWithDeviceApproval(ctx, username) {
487
683
  const config = await readConfig(ctx.configPath);
488
- if (config.accounts[username]) {
489
- const { client } = await authenticate(ctx, username);
684
+ const accountRef = resolveAccountEntry(ctx, config, username);
685
+ await writeConfigIfChanged(ctx, config, accountRef.changed);
686
+ assertAccountHostMatches(ctx, accountRef);
687
+ if (accountRef.account) {
688
+ const { client } = await authenticate(ctx, accountRef.key);
490
689
  try {
491
690
  console.log(
492
- `${color(ROOT_ACCENT, "using")} ${color(userAccent(client.me.user().userID), username)}`,
691
+ `${color(ROOT_ACCENT, "using")} ${color(userAccent(client.me.user().userID), accountRef.username)}`,
493
692
  );
494
693
  printWhoami(client);
495
694
  } finally {
@@ -498,22 +697,27 @@ async function loginWithDeviceApproval(ctx, username) {
498
697
  return;
499
698
  }
500
699
 
700
+ const { username: accountUsername } = accountRef;
501
701
  const privateKey = Client.generateSecretKey();
502
702
  const client = await Client.create(privateKey, ctx.clientOptions);
503
- attachDebugClientEvents(ctx, client, `login-request:${username}`);
703
+ attachDebugClientEvents(ctx, client, `login-request:${accountUsername}`);
504
704
  try {
505
- const [, registerErr] = await client.register(username);
705
+ const [, registerErr] = await client.register(accountUsername);
506
706
  if (!registerErr) {
507
- await connectAndWait(client, ctx, `login-request:${username}`);
707
+ await connectAndWait(
708
+ client,
709
+ ctx,
710
+ `login-request:${accountUsername}`,
711
+ );
508
712
  await persistNewLocalAccount(
509
713
  ctx,
510
714
  config,
511
- username,
715
+ accountUsername,
512
716
  privateKey,
513
717
  client,
514
718
  );
515
719
  console.log(
516
- `${color(ROOT_ACCENT, "registered")} ${color(userAccent(client.me.user().userID), username)}`,
720
+ `${color(ROOT_ACCENT, "registered")} ${color(userAccent(client.me.user().userID), accountUsername)}`,
517
721
  );
518
722
  printWhoami(client);
519
723
  return;
@@ -521,12 +725,19 @@ async function loginWithDeviceApproval(ctx, username) {
521
725
  if (!isDeviceApprovalRequired(registerErr)) {
522
726
  throw registerErr;
523
727
  }
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
- });
728
+ await waitForDeviceApproval(
729
+ ctx,
730
+ client,
731
+ config,
732
+ accountUsername,
733
+ privateKey,
734
+ {
735
+ challenge: registerErr.challenge,
736
+ expiresAt: registerErr.expiresAt,
737
+ requestID: registerErr.requestID,
738
+ userID: registerErr.userID,
739
+ },
740
+ );
530
741
  } finally {
531
742
  await client.close().catch(() => {});
532
743
  }
@@ -591,7 +802,7 @@ async function waitForDeviceApproval(
591
802
  requestID: pending.requestID,
592
803
  })
593
804
  .catch(() => {});
594
- delete config.accounts[username];
805
+ deleteLocalAccount(ctx, config, username);
595
806
  await writeConfig(ctx.configPath, config);
596
807
  throw new Error("Device login cancelled.");
597
808
  }
@@ -672,7 +883,7 @@ async function waitForDeviceApproval(
672
883
  );
673
884
  }
674
885
  }
675
- delete config.accounts[username];
886
+ deleteLocalAccount(ctx, config, username);
676
887
  await writeConfig(ctx.configPath, config);
677
888
  throw new Error(`Device login ${current.status}.`);
678
889
  }
@@ -812,18 +1023,21 @@ function formatDeviceRequestLine(request) {
812
1023
  }
813
1024
 
814
1025
  async function useAccount(ctx, args) {
815
- const username = requireArg(args, 0, "username").toLowerCase();
1026
+ const requestedUsername = requireArg(args, 0, "username");
816
1027
  const config = await readConfig(ctx.configPath);
817
- if (!config.accounts[username]) {
1028
+ const accountRef = resolveAccountEntry(ctx, config, requestedUsername);
1029
+ await writeConfigIfChanged(ctx, config, accountRef.changed);
1030
+ assertAccountHostMatches(ctx, accountRef);
1031
+ if (!accountRef.account) {
818
1032
  throw new Error(
819
- `No local account for ${username}. Run vex auth register ${username} first.`,
1033
+ `No local account for ${accountRef.username}. Run vex auth register ${accountRef.username} first.`,
820
1034
  );
821
1035
  }
822
- config.lastUsername = username;
1036
+ config.lastUsername = accountRef.key;
823
1037
  await writeConfig(ctx.configPath, config);
824
- const account = config.accounts[username];
1038
+ const account = config.accounts[accountRef.key];
825
1039
  console.log(
826
- `${color(ROOT_ACCENT, "using")} ${color(userAccent(account.userID), username)}`,
1040
+ `${color(ROOT_ACCENT, "using")} ${color(userAccent(account.userID), accountRef.key)}`,
827
1041
  );
828
1042
  }
829
1043
 
@@ -890,14 +1104,14 @@ async function channelCommand(ctx, args) {
890
1104
  await useChannel(ctx, args);
891
1105
  return;
892
1106
  }
893
- await withReadyClient(ctx, args, async (client, rest) => {
1107
+ await withReadyClient(ctx, args, async (client, rest, meta) => {
894
1108
  if (sub === "list" || sub === "ls") {
895
1109
  const serverID = requireArg(rest, 0, "server id");
896
1110
  printChannels(await client.channels.retrieve(serverID));
897
1111
  return;
898
1112
  }
899
1113
  if (sub === "history") {
900
- const accountState = accountUiState(meta.config, meta.account);
1114
+ const accountState = accountUiState(ctx, meta.config, meta.account);
901
1115
  const channelID = rest[0] ?? accountState.lastChannel;
902
1116
  if (!channelID)
903
1117
  throw new Error(
@@ -976,7 +1190,7 @@ async function groupCommand(ctx, args) {
976
1190
 
977
1191
  async function sendCommand(ctx, args) {
978
1192
  await withReadyClient(ctx, args, async (client, rest, meta) => {
979
- const accountState = accountUiState(meta.config, meta.account);
1193
+ const accountState = accountUiState(ctx, meta.config, meta.account);
980
1194
  let channelID = rest[0];
981
1195
  let messageParts = rest.slice(1);
982
1196
  if (messageParts.length === 0 && accountState.lastChannel) {
@@ -1043,7 +1257,7 @@ async function createServerInChat(ctx, client, state, name, rl) {
1043
1257
 
1044
1258
  async function createInviteInteractive(ctx, client, state, args, rl) {
1045
1259
  const config = await readConfig(ctx.configPath);
1046
- const accountState = accountUiState(config, state.account);
1260
+ const accountState = accountUiState(ctx, config, state.account);
1047
1261
  let serverID =
1048
1262
  state.target?.type === "channel" && state.target.serverID
1049
1263
  ? state.target.serverID
@@ -2140,7 +2354,7 @@ async function chat(ctx, args) {
2140
2354
  username,
2141
2355
  );
2142
2356
  attachDebugClientEvents(ctx, client, `chat:${account.username}`);
2143
- const accountState = accountUiState(config, account);
2357
+ const accountState = accountUiState(ctx, config, account);
2144
2358
  const state = {
2145
2359
  account,
2146
2360
  avatarMarkers: new Map(),
@@ -2703,13 +2917,20 @@ async function withReadyClient(ctx, args, fn) {
2703
2917
 
2704
2918
  async function authenticate(ctx, explicitUsername) {
2705
2919
  const config = await readConfig(ctx.configPath);
2706
- const username = (explicitUsername ?? config.lastUsername)?.toLowerCase();
2920
+ const accountRef = resolveAccountEntry(
2921
+ ctx,
2922
+ config,
2923
+ explicitUsername ?? config.lastUsername,
2924
+ );
2925
+ await writeConfigIfChanged(ctx, config, accountRef.changed);
2926
+ assertAccountHostMatches(ctx, accountRef);
2927
+ const { username } = accountRef;
2707
2928
  if (!username) {
2708
2929
  throw new Error(
2709
2930
  "No local account selected. Use --username or run register/login first.",
2710
2931
  );
2711
2932
  }
2712
- const account = config.accounts[username];
2933
+ const account = accountRef.account;
2713
2934
  if (!account) {
2714
2935
  throw new Error(
2715
2936
  `No local account for ${username}. Run register/login first.`,
@@ -2750,9 +2971,13 @@ async function authenticate(ctx, explicitUsername) {
2750
2971
  }
2751
2972
  account.userID = client.me.user().userID;
2752
2973
  account.username = client.me.user().username ?? username;
2753
- config.accounts[username] = account;
2974
+ config.accounts[accountRef.key] = account;
2754
2975
  await writeConfig(ctx.configPath, config);
2755
- return { account, client, config };
2976
+ return {
2977
+ account: { ...account, accountKey: accountRef.key },
2978
+ client,
2979
+ config,
2980
+ };
2756
2981
  }
2757
2982
 
2758
2983
  async function resolveStoredDeviceID(ctx, client, account, username) {
@@ -2775,20 +3000,32 @@ async function resolveStoredDeviceID(ctx, client, account, username) {
2775
3000
 
2776
3001
  async function authenticateOrRegister(ctx, explicitUsername) {
2777
3002
  const config = await readConfig(ctx.configPath);
2778
- const username = (explicitUsername ?? config.lastUsername)?.toLowerCase();
2779
- if (username && config.accounts[username]) {
2780
- return authenticate(ctx, username);
3003
+ const accountRef = resolveAccountEntry(
3004
+ ctx,
3005
+ config,
3006
+ explicitUsername ?? config.lastUsername,
3007
+ );
3008
+ await writeConfigIfChanged(ctx, config, accountRef.changed);
3009
+ assertAccountHostMatches(ctx, accountRef);
3010
+ if (accountRef.account) {
3011
+ return authenticate(ctx, accountRef.key);
2781
3012
  }
2782
3013
 
2783
3014
  const rl = createInterface({ input, output });
2784
3015
  try {
2785
3016
  console.log("Welcome to vex.");
2786
- const entered = (username ?? (await rl.question("username: ")))
3017
+ const enteredRaw = (
3018
+ accountRef.username || (await rl.question("username: "))
3019
+ )
2787
3020
  .trim()
2788
3021
  .toLowerCase();
3022
+ const enteredRef = resolveAccountEntry(ctx, config, enteredRaw);
3023
+ await writeConfigIfChanged(ctx, config, enteredRef.changed);
3024
+ assertAccountHostMatches(ctx, enteredRef);
3025
+ const entered = enteredRef.username;
2789
3026
  if (!entered) throw new Error("username is required");
2790
- if (config.accounts[entered]) {
2791
- return authenticate(ctx, entered);
3027
+ if (enteredRef.account) {
3028
+ return authenticate(ctx, enteredRef.key);
2792
3029
  }
2793
3030
  const answer = (await rl.question(`register ${entered}? [Y/n] `))
2794
3031
  .trim()
@@ -2834,7 +3071,10 @@ async function authenticateOrRegister(ctx, explicitUsername) {
2834
3071
 
2835
3072
  async function makeClient(ctx, username) {
2836
3073
  const config = await readConfig(ctx.configPath);
2837
- const account = config.accounts[username];
3074
+ const accountRef = resolveAccountEntry(ctx, config, username);
3075
+ await writeConfigIfChanged(ctx, config, accountRef.changed);
3076
+ assertAccountHostMatches(ctx, accountRef);
3077
+ const account = accountRef.account;
2838
3078
  const privateKey = account?.privateKey ?? Client.generateSecretKey();
2839
3079
  const client = await Client.create(privateKey, ctx.clientOptions);
2840
3080
  return { client, config };
@@ -3523,9 +3763,11 @@ function targetToAccountUi(target) {
3523
3763
  return patch;
3524
3764
  }
3525
3765
 
3526
- function accountUiState(config, account) {
3766
+ function accountUiState(ctx, config, account) {
3527
3767
  if (!account) return {};
3528
- const key = account.username?.toLowerCase();
3768
+ const key =
3769
+ account.accountKey ??
3770
+ (account.username ? accountKeyFor(ctx, account.username) : null);
3529
3771
  const stored = key ? config.accounts?.[key]?.ui : null;
3530
3772
  if (!stored || typeof stored !== "object") return {};
3531
3773
  return {
@@ -3543,9 +3785,14 @@ function accountUiState(config, account) {
3543
3785
 
3544
3786
  async function saveAccountUiState(ctx, account, patch) {
3545
3787
  const config = await readConfig(ctx.configPath);
3546
- const key = account?.username?.toLowerCase();
3788
+ const key =
3789
+ account?.accountKey ??
3790
+ (account?.username ? accountKeyFor(ctx, account.username) : null);
3547
3791
  if (!key || !config.accounts[key]) return;
3548
- const current = accountUiState(config, config.accounts[key]);
3792
+ const current = accountUiState(ctx, config, {
3793
+ ...config.accounts[key],
3794
+ accountKey: key,
3795
+ });
3549
3796
  config.accounts[key] = {
3550
3797
  ...config.accounts[key],
3551
3798
  ui: {