@ralphkrauss/codex-account-switcher 0.1.4 → 0.1.6

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/src/remote.ts CHANGED
@@ -17,7 +17,9 @@ import {
17
17
  getCodexPaths,
18
18
  listAccountNames,
19
19
  resolveExecutable,
20
+ useAccount,
20
21
  validateAccountName,
22
+ writebackCurrentAccount,
21
23
  type CodexPaths,
22
24
  } from './accounts.js';
23
25
 
@@ -112,6 +114,23 @@ export interface SyncStatus {
112
114
  readonly accounts: readonly SyncStatusAccount[];
113
115
  }
114
116
 
117
+ export interface SetupOnePasswordProfilesInput extends ConfigureOnePasswordRemoteInput {
118
+ readonly pull?: boolean;
119
+ readonly force?: boolean;
120
+ readonly use?: string;
121
+ }
122
+
123
+ export interface SetupOnePasswordProfilesResult {
124
+ readonly configPath: string;
125
+ readonly remoteConfigured: boolean;
126
+ readonly opAvailable: boolean;
127
+ readonly vault: string;
128
+ readonly itemPrefix: string;
129
+ readonly remoteAccounts: readonly string[];
130
+ readonly pulledAccounts: readonly string[];
131
+ readonly usedAccount: string | null;
132
+ }
133
+
115
134
  interface OpResult {
116
135
  readonly exitCode: number;
117
136
  readonly stdout: string;
@@ -414,6 +433,68 @@ function itemTitle(config: RemoteConfig, account: string): string {
414
433
  return `${config.itemPrefix}${account}`;
415
434
  }
416
435
 
436
+ function accountNameFromItemTitle(config: RemoteConfig, title: string): string | null {
437
+ if (!title.startsWith(config.itemPrefix)) {
438
+ return null;
439
+ }
440
+ const account = title.slice(config.itemPrefix.length);
441
+ try {
442
+ const safeAccount = validateAccountName(account);
443
+ return safeAccount === 'default' ? null : safeAccount;
444
+ } catch {
445
+ return null;
446
+ }
447
+ }
448
+
449
+ function sortedUnique(values: Iterable<string>): string[] {
450
+ return [...new Set(values)].sort((left, right) => left.localeCompare(right));
451
+ }
452
+
453
+ function parseOnePasswordItemTitles(stdout: string, action: string): string[] {
454
+ let parsed: unknown;
455
+ try {
456
+ parsed = JSON.parse(stdout) as unknown;
457
+ } catch (error) {
458
+ throw new CxError(`1Password item list JSON could not be parsed while ${action}: ${errorMessage(error)}`, 1);
459
+ }
460
+ if (!Array.isArray(parsed)) {
461
+ throw new CxError(`1Password item list did not return an array while ${action}`, 1);
462
+ }
463
+ return parsed
464
+ .map((item) => (isRecord(item) && typeof item.title === 'string' ? item.title : null))
465
+ .filter((title): title is string => title !== null);
466
+ }
467
+
468
+ async function listOnePasswordAccountNames(
469
+ config: RemoteConfig,
470
+ env: NodeJS.ProcessEnv,
471
+ ): Promise<string[]> {
472
+ const result = await runOp([
473
+ 'item',
474
+ 'list',
475
+ '--vault',
476
+ config.vault,
477
+ '--format',
478
+ 'json',
479
+ ], env, `listing 1Password items in vault '${config.vault}'`);
480
+
481
+ return sortedUnique(
482
+ parseOnePasswordItemTitles(result.stdout, `listing 1Password items in vault '${config.vault}'`)
483
+ .map((title) => accountNameFromItemTitle(config, title))
484
+ .filter((account): account is string => account !== null),
485
+ );
486
+ }
487
+
488
+ async function verifyOnePasswordVault(config: RemoteConfig, env: NodeJS.ProcessEnv): Promise<void> {
489
+ await runOp([
490
+ 'vault',
491
+ 'get',
492
+ config.vault,
493
+ '--format',
494
+ 'json',
495
+ ], env, `checking 1Password vault '${config.vault}'`);
496
+ }
497
+
417
498
  function validateRemoteSyncAccountName(account: string): string {
418
499
  const safeAccount = validateAccountName(account);
419
500
  if (safeAccount === 'default') {
@@ -518,12 +599,64 @@ function decodeAuthJsonField(stdout: string, item: string): string {
518
599
  }
519
600
  }
520
601
 
602
+ function decodeAuthJsonFieldFromItemJson(stdout: string, item: string): string {
603
+ let parsed: unknown;
604
+ try {
605
+ parsed = JSON.parse(stdout) as unknown;
606
+ } catch (error) {
607
+ throw new CxError(`1Password item '${item}' JSON could not be parsed: ${errorMessage(error)}`, 1);
608
+ }
609
+
610
+ if (!isRecord(parsed) || !Array.isArray(parsed.fields)) {
611
+ throw new CxError(`1Password item '${item}' does not contain fields metadata`, 1);
612
+ }
613
+
614
+ const field = parsed.fields.find((candidate: unknown): candidate is Record<string, unknown> => (
615
+ isRecord(candidate) && candidate.label === ONEPASSWORD_AUTH_FIELD
616
+ ));
617
+ if (!field) {
618
+ throw new CxError(`1Password item '${item}' does not contain a revealable '${ONEPASSWORD_AUTH_FIELD}' field`, 1);
619
+ }
620
+ if (typeof field.value !== 'string') {
621
+ throw new CxError(`1Password item '${item}' field '${ONEPASSWORD_AUTH_FIELD}' did not contain a string value`, 1);
622
+ }
623
+
624
+ parseAuthJsonString(field.value, `auth_json field in 1Password item '${item}'`);
625
+ return field.value;
626
+ }
627
+
521
628
  async function readOnePasswordAuthJson(
522
629
  config: RemoteConfig,
523
630
  item: string,
524
631
  env: NodeJS.ProcessEnv,
525
632
  ): Promise<string> {
526
- const result = await runOpRaw([
633
+ const jsonResult = await runOpRaw([
634
+ 'item',
635
+ 'get',
636
+ item,
637
+ '--vault',
638
+ config.vault,
639
+ '--format',
640
+ 'json',
641
+ ], env);
642
+
643
+ if (jsonResult.exitCode === 0) {
644
+ try {
645
+ return decodeAuthJsonFieldFromItemJson(jsonResult.stdout, item);
646
+ } catch (error) {
647
+ if (!errorMessage(error).includes(`'${ONEPASSWORD_AUTH_FIELD}'`)) {
648
+ throw error;
649
+ }
650
+ // Fall through to the field-specific command for older op/item shapes.
651
+ }
652
+ } else {
653
+ if (looksLikeMissingItem(jsonResult)) {
654
+ throw new CxError(`remote account item '${item}' was not found in 1Password vault '${config.vault}'`, 1);
655
+ }
656
+ throwOpFailure(`reading 1Password item '${item}'`, jsonResult);
657
+ }
658
+
659
+ const fieldResult = await runOpRaw([
527
660
  'item',
528
661
  'get',
529
662
  item,
@@ -534,16 +667,16 @@ async function readOnePasswordAuthJson(
534
667
  '--reveal',
535
668
  ], env);
536
669
 
537
- if (result.exitCode === 0) {
538
- return decodeAuthJsonField(result.stdout, item);
670
+ if (fieldResult.exitCode === 0) {
671
+ return decodeAuthJsonField(fieldResult.stdout, item);
539
672
  }
540
- if (looksLikeMissingField(result)) {
673
+ if (looksLikeMissingField(fieldResult)) {
541
674
  throw new CxError(`1Password item '${item}' does not contain a revealable '${ONEPASSWORD_AUTH_FIELD}' field`, 1);
542
675
  }
543
- if (looksLikeMissingItem(result)) {
676
+ if (looksLikeMissingItem(fieldResult)) {
544
677
  throw new CxError(`remote account item '${item}' was not found in 1Password vault '${config.vault}'`, 1);
545
678
  }
546
- throwOpFailure(`reading '${ONEPASSWORD_AUTH_FIELD}' from 1Password item '${item}'`, result);
679
+ throwOpFailure(`reading '${ONEPASSWORD_AUTH_FIELD}' from 1Password item '${item}'`, fieldResult);
547
680
  }
548
681
 
549
682
  async function readLocalAccountAuthJson(
@@ -590,10 +723,13 @@ export async function syncPushAccount(
590
723
  ): Promise<SyncPushResult> {
591
724
  const env = options.env ?? process.env;
592
725
  const paths = remotePaths(options);
593
- const [config, local] = await Promise.all([
594
- requireRemoteConfig({ paths }),
595
- readLocalAccountAuthJson(account, paths),
596
- ]);
726
+ const config = await requireRemoteConfig({ paths });
727
+ const safeAccount = validateRemoteSyncAccountName(account);
728
+ const writeback = await writebackCurrentAccount({ paths });
729
+ if (writeback.performed === true && writeback.account !== safeAccount) {
730
+ throw new CxError(`unexpected writeback account '${writeback.account}' while syncing '${safeAccount}'`, 1);
731
+ }
732
+ const local = await readLocalAccountAuthJson(safeAccount, paths);
597
733
  const item = itemTitle(config, local.account);
598
734
  const operation = await upsertOnePasswordAuthJson(config, item, local.authJson, env);
599
735
 
@@ -629,6 +765,73 @@ export async function syncPullAccount(
629
765
  };
630
766
  }
631
767
 
768
+ export async function listRemoteAccountNames(options: RemoteCliOptions = {}): Promise<string[]> {
769
+ const env = options.env ?? process.env;
770
+ const paths = remotePaths(options);
771
+ const config = await requireRemoteConfig({ paths });
772
+ return await listOnePasswordAccountNames(config, env);
773
+ }
774
+
775
+ export async function syncPullAllAccounts(options: RemoteForceOptions = {}): Promise<SyncPullResult[]> {
776
+ const paths = remotePaths(options);
777
+ const accounts = await listRemoteAccountNames(options);
778
+ const results: SyncPullResult[] = [];
779
+ for (const account of accounts) {
780
+ const accountFile = accountPathForName(paths, account);
781
+ if (options.force !== true && await pathExists(accountFile)) {
782
+ continue;
783
+ }
784
+ results.push(await syncPullAccount(account, options));
785
+ }
786
+ return results;
787
+ }
788
+
789
+ export async function syncPushAllAccounts(options: RemoteCliOptions = {}): Promise<SyncPushResult[]> {
790
+ const paths = remotePaths(options);
791
+ const accounts = await listRemoteSyncAccountNames(paths);
792
+ const results: SyncPushResult[] = [];
793
+ for (const account of accounts) {
794
+ results.push(await syncPushAccount(account, options));
795
+ }
796
+ return results;
797
+ }
798
+
799
+ export async function setupOnePasswordProfiles(
800
+ input: SetupOnePasswordProfilesInput,
801
+ options: RemoteCliOptions = {},
802
+ ): Promise<SetupOnePasswordProfilesResult> {
803
+ const env = options.env ?? process.env;
804
+ const paths = remotePaths(options);
805
+ const configured = await configureOnePasswordRemote(input, { paths });
806
+ await verifyOnePasswordVault(configured.config, env);
807
+ const remoteAccounts = await listOnePasswordAccountNames(configured.config, env);
808
+ const pulled = input.pull === true
809
+ ? await syncPullAllAccounts({ paths, env, force: input.force })
810
+ : [];
811
+ let usedAccount: string | null = null;
812
+
813
+ if (input.use) {
814
+ const account = validateRemoteSyncAccountName(input.use);
815
+ const accountFile = accountPathForName(paths, account);
816
+ if (!await pathExists(accountFile)) {
817
+ await syncPullAccount(account, { paths, env, force: input.force });
818
+ }
819
+ await useAccount(account, { paths });
820
+ usedAccount = account;
821
+ }
822
+
823
+ return {
824
+ configPath: configured.configPath,
825
+ remoteConfigured: true,
826
+ opAvailable: true,
827
+ vault: configured.config.vault,
828
+ itemPrefix: configured.config.itemPrefix,
829
+ remoteAccounts,
830
+ pulledAccounts: pulled.map((entry) => entry.account),
831
+ usedAccount,
832
+ };
833
+ }
834
+
632
835
  export async function inspectSyncStatus(
633
836
  account?: string,
634
837
  options: RemoteCliOptions = {},
@@ -637,9 +840,17 @@ export async function inspectSyncStatus(
637
840
  const paths = remotePaths(options);
638
841
  const config = await readRemoteConfig({ paths });
639
842
  const opPath = await resolveExecutable('op', env);
640
- const accounts = account ? [validateRemoteSyncAccountName(account)] : await listRemoteSyncAccountNames(paths);
843
+ let accounts = account ? [validateRemoteSyncAccountName(account)] : await listRemoteSyncAccountNames(paths);
641
844
  const statuses: SyncStatusAccount[] = [];
642
845
 
846
+ if (!account && config && opPath) {
847
+ try {
848
+ accounts = sortedUnique([...accounts, ...await listOnePasswordAccountNames(config, env)]);
849
+ } catch {
850
+ // Keep local status useful; per-account remote errors are reported below.
851
+ }
852
+ }
853
+
643
854
  for (const accountName of accounts) {
644
855
  const accountFile = accountPathForName(paths, accountName);
645
856
  let presence: RemotePresence = 'unknown';