@ralphkrauss/codex-account-switcher 0.1.5 → 0.1.7

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/cli.ts CHANGED
@@ -17,9 +17,12 @@ import {
17
17
  renameAccount,
18
18
  runCodex,
19
19
  saveAccount,
20
+ setupOnePasswordProfiles,
20
21
  syncHermesAccount,
21
22
  syncPullAccount,
23
+ syncPullAllAccounts,
22
24
  syncPushAccount,
25
+ syncPushAllAccounts,
23
26
  useAccount,
24
27
  useHermesAccount,
25
28
  validateAccountName,
@@ -32,6 +35,7 @@ import {
32
35
 
33
36
  const PACKAGE_NAME = '@ralphkrauss/codex-account-switcher';
34
37
  const SUBCOMMANDS = new Set([
38
+ '1password',
35
39
  'doctor',
36
40
  'hermes',
37
41
  'help',
@@ -79,6 +83,12 @@ interface ParsedRemoteConfigureArgs {
79
83
  readonly itemPrefix?: string;
80
84
  }
81
85
 
86
+ interface ParsedOnePasswordSetupArgs extends ParsedRemoteConfigureArgs {
87
+ readonly pull: boolean;
88
+ readonly force: boolean;
89
+ readonly use?: string;
90
+ }
91
+
82
92
  function write(stream: NodeJS.WritableStream, text: string): void {
83
93
  stream.write(text.endsWith('\n') ? text : `${text}\n`);
84
94
  }
@@ -112,10 +122,12 @@ Usage:
112
122
  cx hermes use <account> [--profile <name>] [--no-config]
113
123
  cx hermes sync <account> [--profile <name>]
114
124
  cx hermes status [--profile <name>] [--json]
125
+ cx 1password setup --vault <vault> [--item-prefix <prefix>] [--pull] [--force] [--use <account>]
126
+ cx 1password status [--json]
115
127
  cx remote configure 1password --vault <vault> [--item-prefix <prefix>]
116
128
  cx remote status [--json]
117
- cx sync push <account>
118
- cx sync pull <account> [--force]
129
+ cx sync push <account>|--all
130
+ cx sync pull <account>|--all [--force]
119
131
  cx sync status [account] [--json]
120
132
  cx doctor [--json]
121
133
  cx --help
@@ -164,15 +176,32 @@ Notes:
164
176
  The default 1Password item prefix is cx-. Token contents are never printed.`;
165
177
  }
166
178
 
179
+ function onePasswordHelpText(): string {
180
+ return `Usage:
181
+ cx 1password setup --vault <vault> [--item-prefix <prefix>] [--pull] [--force] [--use <account>]
182
+ cx 1password status [--json]
183
+
184
+ Commands:
185
+ setup Configure 1Password as the native profile backend, verify op/vault access,
186
+ optionally pull all remote profiles, and optionally select one profile.
187
+ status Show 1Password backend status and local/remote profile presence.
188
+
189
+ Examples:
190
+ cx 1password setup --vault Private --pull --use gi
191
+ cx 1password setup --vault Private --item-prefix codex-`;
192
+ }
193
+
167
194
  function syncHelpText(): string {
168
195
  return `Usage:
169
- cx sync push <account>
170
- cx sync pull <account> [--force]
196
+ cx sync push <account>|--all
197
+ cx sync pull <account>|--all [--force]
171
198
  cx sync status [account] [--json]
172
199
 
173
200
  Commands:
174
201
  push Upsert CODEX_HOME/accounts/<account>.json into the configured 1Password item.
202
+ With --all, push every local named account except reserved default.
175
203
  pull Read the configured 1Password item into CODEX_HOME/accounts/<account>.json.
204
+ With --all, pull every remote 1Password-backed profile not already local.
176
205
  Refuses to overwrite unless --force is passed.
177
206
  status Compare local account-file presence with remote item presence without printing tokens.`;
178
207
  }
@@ -277,6 +306,62 @@ function parseRemoteConfigureArgs(args: readonly string[]): ParsedRemoteConfigur
277
306
  return { vault, ...(itemPrefix ? { itemPrefix } : {}) };
278
307
  }
279
308
 
309
+ function parseOnePasswordSetupArgs(args: readonly string[]): ParsedOnePasswordSetupArgs {
310
+ let vault: string | undefined;
311
+ let itemPrefix: string | undefined;
312
+ let pull = false;
313
+ let force = false;
314
+ let use: string | undefined;
315
+
316
+ for (let index = 0; index < args.length; index += 1) {
317
+ const arg = args[index] ?? '';
318
+ if (arg === '--vault') {
319
+ const value = args[index + 1];
320
+ if (!value || value.startsWith('-')) {
321
+ throw new CxError('usage: cx 1password setup --vault <vault> [--item-prefix <prefix>] [--pull] [--force] [--use <account>]', 2);
322
+ }
323
+ vault = value;
324
+ index += 1;
325
+ continue;
326
+ }
327
+ if (arg === '--item-prefix') {
328
+ const value = args[index + 1];
329
+ if (!value || value.startsWith('-')) {
330
+ throw new CxError('usage: cx 1password setup --vault <vault> [--item-prefix <prefix>] [--pull] [--force] [--use <account>]', 2);
331
+ }
332
+ itemPrefix = value;
333
+ index += 1;
334
+ continue;
335
+ }
336
+ if (arg === '--pull') {
337
+ pull = true;
338
+ continue;
339
+ }
340
+ if (arg === '--force') {
341
+ force = true;
342
+ continue;
343
+ }
344
+ if (arg === '--use') {
345
+ const value = args[index + 1];
346
+ if (!value || value.startsWith('-')) {
347
+ throw new CxError('usage: cx 1password setup --vault <vault> [--item-prefix <prefix>] [--pull] [--force] [--use <account>]', 2);
348
+ }
349
+ use = value;
350
+ index += 1;
351
+ continue;
352
+ }
353
+ if (arg.startsWith('--')) {
354
+ throw new CxError(`unknown option '${arg}'`, 2);
355
+ }
356
+ throw new CxError('usage: cx 1password setup --vault <vault> [--item-prefix <prefix>] [--pull] [--force] [--use <account>]', 2);
357
+ }
358
+
359
+ if (!vault) {
360
+ throw new CxError('usage: cx 1password setup --vault <vault> [--item-prefix <prefix>] [--pull] [--force] [--use <account>]', 2);
361
+ }
362
+ return { vault, pull, force, ...(itemPrefix ? { itemPrefix } : {}), ...(use ? { use } : {}) };
363
+ }
364
+
280
365
  function parseLoginArgs(args: readonly string[]): ParsedLoginArgs {
281
366
  let force = false;
282
367
  let name: string | null = null;
@@ -473,6 +558,26 @@ async function printList(io: CliIo, env: NodeJS.ProcessEnv): Promise<void> {
473
558
  write(io.stdout, formatAccounts(await listAccounts(getCodexPaths(env))));
474
559
  }
475
560
 
561
+ async function useAccountWithRemoteFallback(name: string, env: NodeJS.ProcessEnv, io: CliIo): Promise<void> {
562
+ const paths = getCodexPaths(env);
563
+ try {
564
+ await useAccount(name, { paths });
565
+ return;
566
+ } catch (error) {
567
+ if (!(error instanceof CxError) || !error.message.includes(`no account '${name}'`)) {
568
+ throw error;
569
+ }
570
+
571
+ try {
572
+ const pulled = await syncPullAccount(name, { env, paths });
573
+ write(io.stdout, `pulled 1Password-backed profile '${pulled.account}'`);
574
+ await useAccount(name, { paths });
575
+ } catch {
576
+ throw error;
577
+ }
578
+ }
579
+ }
580
+
476
581
  function parseRunArgs(args: readonly string[]): { account: string | null; codexArgs: readonly string[] } {
477
582
  const separatorIndex = args.indexOf('--');
478
583
  if (separatorIndex >= 0) {
@@ -591,6 +696,48 @@ async function handleRemoteCommand(
591
696
  }
592
697
  }
593
698
 
699
+ async function handleOnePasswordCommand(
700
+ args: readonly string[],
701
+ env: NodeJS.ProcessEnv,
702
+ io: CliIo,
703
+ ): Promise<number> {
704
+ const [command, ...rest] = args;
705
+ if (!command || command === 'help' || command === '--help' || command === '-h') {
706
+ write(io.stdout, onePasswordHelpText());
707
+ return 0;
708
+ }
709
+
710
+ switch (command) {
711
+ case 'setup': {
712
+ const parsed = parseOnePasswordSetupArgs(rest);
713
+ const result = await setupOnePasswordProfiles(parsed, { env, paths: getCodexPaths(env) });
714
+ write(io.stdout, 'configured 1Password-backed Codex profiles');
715
+ write(io.stdout, `config: ${result.configPath}`);
716
+ write(io.stdout, `vault: ${result.vault}`);
717
+ write(io.stdout, `item prefix: ${result.itemPrefix}`);
718
+ write(io.stdout, `remote profiles: ${result.remoteAccounts.length > 0 ? result.remoteAccounts.join(', ') : '(none)'}`);
719
+ if (parsed.pull) {
720
+ write(io.stdout, `pulled profiles: ${result.pulledAccounts.length > 0 ? result.pulledAccounts.join(', ') : '(none)'}`);
721
+ }
722
+ if (result.usedAccount) {
723
+ write(io.stdout, `active codex account: ${result.usedAccount}`);
724
+ }
725
+ return 0;
726
+ }
727
+
728
+ case 'status': {
729
+ const parsed = parseJsonArgs(rest);
730
+ requireArity('1password status [--json]', parsed.positionals, 0);
731
+ const status = await inspectSyncStatus(undefined, { env, paths: getCodexPaths(env) });
732
+ write(io.stdout, parsed.json ? JSON.stringify(status, null, 2) : formatSyncStatus(status));
733
+ return 0;
734
+ }
735
+
736
+ default:
737
+ throw new CxError(`unknown 1password command '${command}'`, 2);
738
+ }
739
+ }
740
+
594
741
  async function handleSyncCommand(
595
742
  args: readonly string[],
596
743
  env: NodeJS.ProcessEnv,
@@ -604,7 +751,12 @@ async function handleSyncCommand(
604
751
 
605
752
  switch (command) {
606
753
  case 'push': {
607
- requireArity('sync push <account>', rest, 1);
754
+ requireArity('sync push <account>|--all', rest, 1);
755
+ if (rest[0] === '--all') {
756
+ const results = await syncPushAllAccounts({ env, paths: getCodexPaths(env) });
757
+ write(io.stdout, `pushed profiles: ${results.length > 0 ? results.map((entry) => entry.account).join(', ') : '(none)'}`);
758
+ return 0;
759
+ }
608
760
  const account = rest[0] ?? '';
609
761
  const result = await syncPushAccount(account, { env, paths: getCodexPaths(env) });
610
762
  write(io.stdout, `pushed account '${result.account}' to 1Password item '${result.item}'`);
@@ -614,13 +766,33 @@ async function handleSyncCommand(
614
766
  }
615
767
 
616
768
  case 'pull': {
617
- const parsed = parseForceArgs(rest);
618
- requireArity('sync pull <account> [--force]', parsed.positionals, 1);
619
- const account = parsed.positionals[0] ?? '';
769
+ let force = false;
770
+ const positionals: string[] = [];
771
+ for (const arg of rest) {
772
+ if (arg === '--force') {
773
+ force = true;
774
+ continue;
775
+ }
776
+ if (arg !== '--all' && arg.startsWith('--')) {
777
+ throw new CxError(`unknown option '${arg}'`, 2);
778
+ }
779
+ positionals.push(arg);
780
+ }
781
+ requireArity('sync pull <account>|--all [--force]', positionals, 1);
782
+ if (positionals[0] === '--all') {
783
+ const results = await syncPullAllAccounts({
784
+ env,
785
+ paths: getCodexPaths(env),
786
+ force,
787
+ });
788
+ write(io.stdout, `pulled profiles: ${results.length > 0 ? results.map((entry) => entry.account).join(', ') : '(none)'}`);
789
+ return 0;
790
+ }
791
+ const account = positionals[0] ?? '';
620
792
  const result = await syncPullAccount(account, {
621
793
  env,
622
794
  paths: getCodexPaths(env),
623
- force: parsed.force,
795
+ force,
624
796
  });
625
797
  write(io.stdout, `pulled 1Password item '${result.item}' into account '${result.account}'`);
626
798
  write(io.stdout, `account file: ${result.accountFile}`);
@@ -682,7 +854,7 @@ export async function main(
682
854
  }
683
855
 
684
856
  validateAccountName(first);
685
- await useAccount(first, { paths: getCodexPaths(env) });
857
+ await useAccountWithRemoteFallback(first, env, io);
686
858
  write(io.stdout, `→ codex on '${first}'`);
687
859
  return await runCodex(rest, { env });
688
860
  }
@@ -708,7 +880,7 @@ export async function main(
708
880
  case 'use': {
709
881
  requireArity('use <name>', rest, 1);
710
882
  const name = rest[0] ?? '';
711
- await useAccount(name, { paths: getCodexPaths(env) });
883
+ await useAccountWithRemoteFallback(name, env, io);
712
884
  write(io.stdout, `active codex account: ${name}`);
713
885
  return 0;
714
886
  }
@@ -749,7 +921,7 @@ export async function main(
749
921
  case 'run': {
750
922
  const parsed = parseRunArgs(rest);
751
923
  if (parsed.account) {
752
- await useAccount(parsed.account, { paths: getCodexPaths(env) });
924
+ await useAccountWithRemoteFallback(parsed.account, env, io);
753
925
  write(io.stdout, `→ codex on '${parsed.account}'`);
754
926
  }
755
927
  return await runCodex(parsed.codexArgs, { env });
@@ -768,6 +940,9 @@ export async function main(
768
940
  case 'hermes':
769
941
  return await handleHermesCommand(rest, env, io);
770
942
 
943
+ case '1password':
944
+ return await handleOnePasswordCommand(rest, env, io);
945
+
771
946
  case 'remote':
772
947
  return await handleRemoteCommand(rest, env, io);
773
948
 
package/src/index.ts CHANGED
@@ -41,8 +41,12 @@ export {
41
41
  getRemoteConfigPath,
42
42
  inspectRemoteStatus,
43
43
  inspectSyncStatus,
44
+ listRemoteAccountNames,
44
45
  readRemoteConfig,
46
+ setupOnePasswordProfiles,
47
+ syncPullAllAccounts,
45
48
  syncPullAccount,
49
+ syncPushAllAccounts,
46
50
  syncPushAccount,
47
51
  } from './remote.js';
48
52
 
@@ -80,6 +84,8 @@ export type {
80
84
  RemotePathOptions,
81
85
  RemotePresence,
82
86
  RemoteStatus,
87
+ SetupOnePasswordProfilesInput,
88
+ SetupOnePasswordProfilesResult,
83
89
  SyncPullResult,
84
90
  SyncPushResult,
85
91
  SyncStatus,
package/src/remote.ts CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  getCodexPaths,
18
18
  listAccountNames,
19
19
  resolveExecutable,
20
+ useAccount,
20
21
  validateAccountName,
21
22
  writebackCurrentAccount,
22
23
  type CodexPaths,
@@ -113,6 +114,23 @@ export interface SyncStatus {
113
114
  readonly accounts: readonly SyncStatusAccount[];
114
115
  }
115
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
+
116
134
  interface OpResult {
117
135
  readonly exitCode: number;
118
136
  readonly stdout: string;
@@ -415,6 +433,68 @@ function itemTitle(config: RemoteConfig, account: string): string {
415
433
  return `${config.itemPrefix}${account}`;
416
434
  }
417
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
+
418
498
  function validateRemoteSyncAccountName(account: string): string {
419
499
  const safeAccount = validateAccountName(account);
420
500
  if (safeAccount === 'default') {
@@ -685,6 +765,73 @@ export async function syncPullAccount(
685
765
  };
686
766
  }
687
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
+
688
835
  export async function inspectSyncStatus(
689
836
  account?: string,
690
837
  options: RemoteCliOptions = {},
@@ -693,9 +840,17 @@ export async function inspectSyncStatus(
693
840
  const paths = remotePaths(options);
694
841
  const config = await readRemoteConfig({ paths });
695
842
  const opPath = await resolveExecutable('op', env);
696
- const accounts = account ? [validateRemoteSyncAccountName(account)] : await listRemoteSyncAccountNames(paths);
843
+ let accounts = account ? [validateRemoteSyncAccountName(account)] : await listRemoteSyncAccountNames(paths);
697
844
  const statuses: SyncStatusAccount[] = [];
698
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
+
699
854
  for (const accountName of accounts) {
700
855
  const accountFile = accountPathForName(paths, accountName);
701
856
  let presence: RemotePresence = 'unknown';