@imdeadpool/codex-account-switcher 0.1.11 → 0.1.15

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
@@ -25,6 +25,11 @@ During global install, the package asks for permission to add an optional shell
25
25
  (`~/.bashrc` or `~/.zshrc`) that auto-runs a silent snapshot sync after successful
26
26
  official `codex login`.
27
27
 
28
+ On later global updates, if the hook is already installed, `codex-auth` refreshes the
29
+ hook block automatically to the latest template (no manual remove/setup needed).
30
+ The refreshed hook always wraps `codex` in your shell so sync/restore still run even if
31
+ another shell config already defined `codex` as a function.
32
+
28
33
  - Choose `y` to enable fully automatic login snapshot capture.
29
34
  - Choose `n` (default) to skip.
30
35
  - Set `CODEX_AUTH_SKIP_POSTINSTALL=1` to always suppress this prompt.
@@ -123,7 +128,7 @@ codex-auth remove-login-hook
123
128
  - `codex-auth config auto ...` – Enables/disables managed auto-switch and updates threshold percentages.
124
129
  - `codex-auth config api enable|disable` – Chooses usage source mode (`api` or `local`).
125
130
  - `codex-auth daemon --once|--watch` – Runs the auto-switch loop once or continuously.
126
- - `codex-auth setup-login-hook [-f <path>]` – Installs an optional shell hook in your rc file to restore session-pinned snapshot before each `codex` run, refresh codex-auth session memory after each `codex` exit, and restore common terminal modes before returning to your prompt.
131
+ - `codex-auth setup-login-hook [-f <path>]` – Installs or refreshes an optional shell hook in your rc file to restore session-pinned snapshot before each `codex` run, refresh codex-auth session memory after each `codex` exit, and restore common terminal modes before returning to your prompt.
127
132
  - `codex-auth hook-status [-f <path>]` – Shows whether the optional login auto-snapshot hook is installed for the selected rc file.
128
133
  - `codex-auth remove-login-hook [-f <path>]` – Removes the optional shell hook.
129
134
 
@@ -154,3 +159,4 @@ Notes:
154
159
  - Works on macOS/Linux/Windows (regular-file auth snapshot activation).
155
160
  - Requires Node 18+.
156
161
  - Running bare `codex-auth` shows the help screen and also displays an update notice when a newer npm release is available.
162
+ - Running bare `codex-auth` now prompts to install updates immediately when a newer npm release is available (`[Y/n]`, Enter defaults to yes).
@@ -24,7 +24,7 @@ class RemoveLoginHookCommand extends base_command_1.BaseCommand {
24
24
  });
25
25
  }
26
26
  }
27
- RemoveLoginHookCommand.description = "Remove the shell hook that auto-syncs snapshots after `codex login`";
27
+ RemoveLoginHookCommand.description = "Remove the shell hook that keeps terminal snapshot memory in sync";
28
28
  RemoveLoginHookCommand.flags = {
29
29
  shellRc: core_1.Flags.string({
30
30
  char: "f",
@@ -17,6 +17,9 @@ class SetupLoginHookCommand extends base_command_1.BaseCommand {
17
17
  if (result === "already-installed") {
18
18
  this.log(`Login auto-snapshot hook is already installed in ${rcPath}.`);
19
19
  }
20
+ else if (result === "updated") {
21
+ this.log(`Updated login auto-snapshot hook in ${rcPath}.`);
22
+ }
20
23
  else {
21
24
  this.log(`Installed login auto-snapshot hook in ${rcPath}.`);
22
25
  }
@@ -24,7 +27,7 @@ class SetupLoginHookCommand extends base_command_1.BaseCommand {
24
27
  });
25
28
  }
26
29
  }
27
- SetupLoginHookCommand.description = "Install a shell hook that auto-syncs snapshots after successful `codex login`";
30
+ SetupLoginHookCommand.description = "Install or refresh a shell hook that keeps terminal snapshot memory in sync";
28
31
  SetupLoginHookCommand.flags = {
29
32
  shellRc: core_1.Flags.string({
30
33
  char: "f",
@@ -1,5 +1,9 @@
1
1
  "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
2
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ const promises_1 = __importDefault(require("node:readline/promises"));
3
7
  const update_check_1 = require("../../lib/update-check");
4
8
  const hook = async function (options) {
5
9
  if (options.id)
@@ -18,6 +22,27 @@ const hook = async function (options) {
18
22
  if (summary.state !== "update-available")
19
23
  return;
20
24
  this.log((0, update_check_1.formatUpdateSummaryInline)(summary));
21
- this.log("Run `codex-auth self-update` to install the latest version.");
25
+ const rl = promises_1.default.createInterface({
26
+ input: process.stdin,
27
+ output: process.stdout,
28
+ });
29
+ let shouldUpdate = false;
30
+ try {
31
+ const answer = await rl.question("Install the update now? [Y/n] ");
32
+ shouldUpdate = (0, update_check_1.shouldProceedWithYesDefault)(answer);
33
+ }
34
+ finally {
35
+ rl.close();
36
+ }
37
+ if (!shouldUpdate) {
38
+ this.log("Skipped update. Run `codex-auth self-update` anytime.");
39
+ return;
40
+ }
41
+ const exitCode = await (0, update_check_1.runGlobalNpmInstall)(update_check_1.PACKAGE_NAME);
42
+ if (exitCode === 0) {
43
+ this.log(`✓ codex-auth updated to ${latestVersion}.`);
44
+ return;
45
+ }
46
+ this.log(`Update failed (exit code ${exitCode}). Run: npm i -g ${update_check_1.PACKAGE_NAME}@latest`);
22
47
  };
23
48
  exports.default = hook;
@@ -75,10 +75,14 @@ export declare class AccountService {
75
75
  private isExternalSyncForced;
76
76
  private resolveSessionScopeKey;
77
77
  private getSessionAccountName;
78
+ private getActiveSessionAccountName;
78
79
  private setSessionAccountName;
79
80
  private clearSessionAccountName;
80
81
  private readSessionMap;
81
82
  private writeSessionMap;
83
+ private isSessionPinnedToActiveCodex;
84
+ private readChildPids;
85
+ private isCodexProcess;
82
86
  private snapshotsShareIdentity;
83
87
  private renderSnapshotIdentity;
84
88
  }
@@ -16,6 +16,7 @@ const service_manager_1 = require("./service-manager");
16
16
  const ACCOUNT_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._@+-]*$/;
17
17
  const EXTERNAL_SYNC_FORCE_ENV = "CODEX_AUTH_FORCE_EXTERNAL_SYNC";
18
18
  const SESSION_KEY_ENV = "CODEX_AUTH_SESSION_KEY";
19
+ const SESSION_ACTIVE_OVERRIDE_ENV = "CODEX_AUTH_SESSION_ACTIVE_OVERRIDE";
19
20
  class AccountService {
20
21
  async syncExternalAuthSnapshotIfNeeded() {
21
22
  const authPath = (0, paths_1.resolveAuthPath)();
@@ -33,7 +34,7 @@ class AccountService {
33
34
  autoSwitchDisabled: false,
34
35
  };
35
36
  }
36
- const sessionAccountName = await this.getSessionAccountName();
37
+ const sessionAccountName = await this.getActiveSessionAccountName();
37
38
  if (sessionAccountName) {
38
39
  const sessionSnapshotPath = this.accountFilePath(sessionAccountName);
39
40
  if (await this.pathExists(sessionSnapshotPath)) {
@@ -84,7 +85,7 @@ class AccountService {
84
85
  };
85
86
  }
86
87
  async restoreSessionSnapshotIfNeeded() {
87
- const sessionAccountName = await this.getSessionAccountName();
88
+ const sessionAccountName = await this.getActiveSessionAccountName();
88
89
  if (!sessionAccountName) {
89
90
  return { restored: false };
90
91
  }
@@ -191,7 +192,7 @@ class AccountService {
191
192
  });
192
193
  }
193
194
  async getCurrentAccountName() {
194
- const sessionAccountName = await this.getSessionAccountName();
195
+ const sessionAccountName = await this.getActiveSessionAccountName();
195
196
  if (sessionAccountName) {
196
197
  const sessionSnapshotPath = this.accountFilePath(sessionAccountName);
197
198
  if (await this.pathExists(sessionSnapshotPath)) {
@@ -740,6 +741,16 @@ class AccountService {
740
741
  return null;
741
742
  }
742
743
  }
744
+ async getActiveSessionAccountName() {
745
+ const sessionAccountName = await this.getSessionAccountName();
746
+ if (!sessionAccountName)
747
+ return null;
748
+ const sessionIsActive = await this.isSessionPinnedToActiveCodex();
749
+ if (sessionIsActive)
750
+ return sessionAccountName;
751
+ await this.clearSessionAccountName();
752
+ return null;
753
+ }
743
754
  async setSessionAccountName(accountName) {
744
755
  const sessionKey = this.resolveSessionScopeKey();
745
756
  if (!sessionKey)
@@ -808,6 +819,70 @@ class AccountService {
808
819
  await this.ensureDir(node_path_1.default.dirname(sessionMapPath));
809
820
  await promises_1.default.writeFile(sessionMapPath, `${JSON.stringify(sessionMap, null, 2)}\n`, "utf8");
810
821
  }
822
+ async isSessionPinnedToActiveCodex() {
823
+ var _a;
824
+ const override = (_a = process.env[SESSION_ACTIVE_OVERRIDE_ENV]) === null || _a === void 0 ? void 0 : _a.trim().toLowerCase();
825
+ if (override) {
826
+ if (["1", "true", "yes", "on"].includes(override))
827
+ return true;
828
+ if (["0", "false", "no", "off"].includes(override))
829
+ return false;
830
+ }
831
+ const sessionKey = this.resolveSessionScopeKey();
832
+ if (!sessionKey)
833
+ return false;
834
+ if (sessionKey.startsWith("session:")) {
835
+ return true;
836
+ }
837
+ if (process.platform !== "linux") {
838
+ return true;
839
+ }
840
+ const ppidMatch = sessionKey.match(/^ppid:(\d+)$/);
841
+ if (!ppidMatch)
842
+ return false;
843
+ const parentPid = Number(ppidMatch[1]);
844
+ if (!Number.isFinite(parentPid) || parentPid <= 1)
845
+ return false;
846
+ const childPids = await this.readChildPids(parentPid);
847
+ if (childPids.length === 0)
848
+ return false;
849
+ for (const childPid of childPids) {
850
+ if (await this.isCodexProcess(childPid)) {
851
+ return true;
852
+ }
853
+ }
854
+ return false;
855
+ }
856
+ async readChildPids(parentPid) {
857
+ try {
858
+ const childrenRaw = await promises_1.default.readFile(`/proc/${parentPid}/task/${parentPid}/children`, "utf8");
859
+ return childrenRaw
860
+ .split(/\s+/)
861
+ .map((value) => Number(value))
862
+ .filter((value) => Number.isInteger(value) && value > 1);
863
+ }
864
+ catch {
865
+ return [];
866
+ }
867
+ }
868
+ async isCodexProcess(pid) {
869
+ try {
870
+ const cmdline = await promises_1.default.readFile(`/proc/${pid}/cmdline`, "utf8");
871
+ const normalized = cmdline.replace(/\0/g, " ").trim();
872
+ if (!normalized)
873
+ return false;
874
+ if (/\bcodex-auth\b/.test(normalized))
875
+ return false;
876
+ if (/(^|\s|\/)codex(\s|$)/.test(normalized))
877
+ return true;
878
+ if (/(^|\s|\/)codex-linux-[^\s]*($|\s)/.test(normalized))
879
+ return true;
880
+ return false;
881
+ }
882
+ catch {
883
+ return false;
884
+ }
885
+ }
811
886
  snapshotsShareIdentity(a, b) {
812
887
  var _a, _b;
813
888
  if (a.authMode !== "chatgpt" || b.authMode !== "chatgpt") {
@@ -1,6 +1,6 @@
1
1
  export declare const LOGIN_HOOK_MARK_START = "# >>> codex-auth-login-auto-snapshot >>>";
2
2
  export declare const LOGIN_HOOK_MARK_END = "# <<< codex-auth-login-auto-snapshot <<<";
3
- export type HookInstallStatus = "installed" | "already-installed";
3
+ export type HookInstallStatus = "installed" | "updated" | "already-installed";
4
4
  export type HookRemoveStatus = "removed" | "not-installed";
5
5
  export interface LoginHookStatus {
6
6
  installed: boolean;
@@ -22,6 +22,10 @@ function hookBlockRegex() {
22
22
  const end = escapeRegex(exports.LOGIN_HOOK_MARK_END);
23
23
  return new RegExp(`\\n?${start}[\\s\\S]*?${end}\\n?`, "g");
24
24
  }
25
+ function normalizeRcContents(contents) {
26
+ const collapsed = contents.replace(/\n{3,}/g, "\n\n");
27
+ return `${collapsed.replace(/\s*$/, "")}\n`;
28
+ }
25
29
  function resolveDefaultShellRcPath() {
26
30
  var _a;
27
31
  const shell = ((_a = process.env.SHELL) !== null && _a !== void 0 ? _a : "").toLowerCase();
@@ -41,22 +45,20 @@ function renderLoginHookBlock() {
41
45
  " [[ -w \"$__tty_target\" ]] || __tty_target=/dev/stdout",
42
46
  " printf '\\033[>4m\\033[<u\\033[?2026l\\033[?1004l\\033[?1l\\033[?2004l\\033[?1000l\\033[?1002l\\033[?1003l\\033[?1006l\\033[?1015l\\033[?1049l\\033[0m\\033[?25h\\033[H\\033>' >\"$__tty_target\" 2>/dev/null || true",
43
47
  "}",
44
- "if ! typeset -f codex >/dev/null 2>&1; then",
45
- " codex() {",
46
- " if command -v codex-auth >/dev/null 2>&1; then",
47
- " command codex-auth restore-session >/dev/null 2>&1 || true",
48
- " fi",
49
- " command codex \"$@\"",
50
- " local __codex_exit=$?",
51
- " if command -v codex-auth >/dev/null 2>&1; then",
52
- " CODEX_AUTH_FORCE_EXTERNAL_SYNC=1 command codex-auth status >/dev/null 2>&1 || true",
53
- " fi",
54
- " if [[ -z \"${CODEX_AUTH_SKIP_TTY_RESTORE:-}\" ]]; then",
55
- " __codex_auth_restore_tty",
56
- " fi",
57
- " return $__codex_exit",
58
- " }",
59
- "fi",
48
+ "codex() {",
49
+ " if command -v codex-auth >/dev/null 2>&1; then",
50
+ " command codex-auth restore-session >/dev/null 2>&1 || true",
51
+ " fi",
52
+ " command codex \"$@\"",
53
+ " local __codex_exit=$?",
54
+ " if command -v codex-auth >/dev/null 2>&1; then",
55
+ " CODEX_AUTH_FORCE_EXTERNAL_SYNC=1 command codex-auth status >/dev/null 2>&1 || true",
56
+ " fi",
57
+ " if [[ -z \"${CODEX_AUTH_SKIP_TTY_RESTORE:-}\" ]]; then",
58
+ " __codex_auth_restore_tty",
59
+ " fi",
60
+ " return $__codex_exit",
61
+ "}",
60
62
  exports.LOGIN_HOOK_MARK_END,
61
63
  ].join("\n");
62
64
  }
@@ -72,9 +74,14 @@ async function installLoginHook(rcPath = resolveDefaultShellRcPath()) {
72
74
  throw error;
73
75
  }
74
76
  if (existing.includes(exports.LOGIN_HOOK_MARK_START) && existing.includes(exports.LOGIN_HOOK_MARK_END)) {
75
- return "already-installed";
77
+ const refreshed = normalizeRcContents(existing.replace(hookBlockRegex(), `\n${renderLoginHookBlock()}\n`));
78
+ if (refreshed === normalizeRcContents(existing)) {
79
+ return "already-installed";
80
+ }
81
+ await promises_1.default.writeFile(rcPath, refreshed, "utf8");
82
+ return "updated";
76
83
  }
77
- const next = `${existing.replace(/\s*$/, "")}\n\n${renderLoginHookBlock()}\n`;
84
+ const next = normalizeRcContents(`${existing}\n\n${renderLoginHookBlock()}\n`);
78
85
  await promises_1.default.writeFile(rcPath, next, "utf8");
79
86
  return "installed";
80
87
  }
@@ -95,7 +102,7 @@ async function removeLoginHook(rcPath = resolveDefaultShellRcPath()) {
95
102
  if (stripped === existing) {
96
103
  return "not-installed";
97
104
  }
98
- await promises_1.default.writeFile(rcPath, stripped.replace(/\n{3,}/g, "\n\n"), "utf8");
105
+ await promises_1.default.writeFile(rcPath, normalizeRcContents(stripped), "utf8");
99
106
  return "removed";
100
107
  }
101
108
  async function getLoginHookStatus(rcPath = resolveDefaultShellRcPath()) {
@@ -10,5 +10,6 @@ export declare function isVersionNewer(currentVersion: string, latestVersion: st
10
10
  export declare function getUpdateSummary(currentVersion: string, latestVersion: string): UpdateSummary;
11
11
  export declare function formatUpdateSummaryCard(summary: UpdateSummary): string[];
12
12
  export declare function formatUpdateSummaryInline(summary: UpdateSummary): string;
13
+ export declare function shouldProceedWithYesDefault(answer: string): boolean;
13
14
  export declare function fetchLatestNpmVersion(packageName: string, timeoutMs?: number): Promise<string | null>;
14
15
  export declare function runGlobalNpmInstall(packageName: string, version?: "latest" | string): Promise<number>;
@@ -6,6 +6,7 @@ exports.isVersionNewer = isVersionNewer;
6
6
  exports.getUpdateSummary = getUpdateSummary;
7
7
  exports.formatUpdateSummaryCard = formatUpdateSummaryCard;
8
8
  exports.formatUpdateSummaryInline = formatUpdateSummaryInline;
9
+ exports.shouldProceedWithYesDefault = shouldProceedWithYesDefault;
9
10
  exports.fetchLatestNpmVersion = fetchLatestNpmVersion;
10
11
  exports.runGlobalNpmInstall = runGlobalNpmInstall;
11
12
  const node_child_process_1 = require("node:child_process");
@@ -68,6 +69,16 @@ function formatUpdateSummaryInline(summary) {
68
69
  }
69
70
  return `ℹ Update status unknown (current: ${summary.currentVersion}, latest: ${summary.latestVersion})`;
70
71
  }
72
+ function shouldProceedWithYesDefault(answer) {
73
+ const normalized = answer.trim().toLowerCase();
74
+ if (!normalized)
75
+ return true;
76
+ if (normalized === "y" || normalized === "yes")
77
+ return true;
78
+ if (normalized === "n" || normalized === "no")
79
+ return false;
80
+ return false;
81
+ }
71
82
  async function fetchLatestNpmVersion(packageName, timeoutMs = 2500) {
72
83
  return new Promise((resolve) => {
73
84
  const child = (0, node_child_process_1.spawn)("npm", ["view", packageName, "version", "--json"], {
@@ -38,6 +38,24 @@ async function withTempRcFile(t, fn) {
38
38
  strict_1.default.equal(startCount, 1);
39
39
  });
40
40
  });
41
+ (0, node_test_1.default)("installLoginHook refreshes an existing legacy hook block", async (t) => {
42
+ await withTempRcFile(t, async (rcPath) => {
43
+ const legacyBlock = [
44
+ login_hook_1.LOGIN_HOOK_MARK_START,
45
+ "# legacy",
46
+ login_hook_1.LOGIN_HOOK_MARK_END,
47
+ ].join("\n");
48
+ await promises_1.default.writeFile(rcPath, `# test bashrc\n\n${legacyBlock}\n`, "utf8");
49
+ const result = await (0, login_hook_1.installLoginHook)(rcPath);
50
+ strict_1.default.equal(result, "updated");
51
+ const contents = await promises_1.default.readFile(rcPath, "utf8");
52
+ strict_1.default.ok(contents.includes("command codex-auth restore-session"));
53
+ strict_1.default.ok(contents.includes("CODEX_AUTH_FORCE_EXTERNAL_SYNC=1 command codex-auth status"));
54
+ strict_1.default.ok(!contents.includes("# legacy"));
55
+ const startCount = contents.split(login_hook_1.LOGIN_HOOK_MARK_START).length - 1;
56
+ strict_1.default.equal(startCount, 1);
57
+ });
58
+ });
41
59
  (0, node_test_1.default)("removeLoginHook removes installed marker block", async (t) => {
42
60
  await withTempRcFile(t, async (rcPath) => {
43
61
  await (0, login_hook_1.installLoginHook)(rcPath);
@@ -68,9 +86,11 @@ async function withTempRcFile(t, fn) {
68
86
  (0, node_test_1.default)("renderLoginHookBlock includes terminal-mode restore guard", () => {
69
87
  const hook = (0, login_hook_1.renderLoginHookBlock)();
70
88
  strict_1.default.ok(hook.includes("__codex_auth_restore_tty"));
89
+ strict_1.default.ok(hook.includes("codex() {"));
71
90
  strict_1.default.ok(hook.includes("command codex-auth restore-session"));
72
91
  strict_1.default.ok(hook.includes("CODEX_AUTH_FORCE_EXTERNAL_SYNC=1 command codex-auth status"));
73
92
  strict_1.default.ok(!hook.includes("__first_non_flag"));
93
+ strict_1.default.ok(!hook.includes("if ! typeset -f codex"));
74
94
  strict_1.default.ok(hook.includes("\\033[>4m"));
75
95
  strict_1.default.ok(hook.includes("\\033[<u"));
76
96
  strict_1.default.ok(hook.includes("\\033[?2026l"));
@@ -51,6 +51,7 @@ async function withIsolatedCodexDir(t, fn) {
51
51
  CODEX_AUTH_ACCOUNTS_DIR: process.env.CODEX_AUTH_ACCOUNTS_DIR,
52
52
  CODEX_AUTH_JSON_PATH: process.env.CODEX_AUTH_JSON_PATH,
53
53
  CODEX_AUTH_CURRENT_PATH: process.env.CODEX_AUTH_CURRENT_PATH,
54
+ CODEX_AUTH_SESSION_ACTIVE_OVERRIDE: process.env.CODEX_AUTH_SESSION_ACTIVE_OVERRIDE,
54
55
  };
55
56
  process.env.CODEX_AUTH_CODEX_DIR = codexDir;
56
57
  delete process.env.CODEX_AUTH_ACCOUNTS_DIR;
@@ -61,6 +62,7 @@ async function withIsolatedCodexDir(t, fn) {
61
62
  process.env.CODEX_AUTH_ACCOUNTS_DIR = previousEnv.CODEX_AUTH_ACCOUNTS_DIR;
62
63
  process.env.CODEX_AUTH_JSON_PATH = previousEnv.CODEX_AUTH_JSON_PATH;
63
64
  process.env.CODEX_AUTH_CURRENT_PATH = previousEnv.CODEX_AUTH_CURRENT_PATH;
65
+ process.env.CODEX_AUTH_SESSION_ACTIVE_OVERRIDE = previousEnv.CODEX_AUTH_SESSION_ACTIVE_OVERRIDE;
64
66
  await promises_1.default.rm(codexDir, { recursive: true, force: true });
65
67
  });
66
68
  await fn({ codexDir, accountsDir, authPath });
@@ -426,12 +428,42 @@ async function withIsolatedCodexDir(t, fn) {
426
428
  strict_1.default.equal(authStat.isSymbolicLink(), false);
427
429
  });
428
430
  });
429
- (0, node_test_1.default)("getCurrentAccountName prefers session-scoped snapshot over global current pointer", async (t) => {
431
+ (0, node_test_1.default)("getCurrentAccountName falls back to global current pointer when codex is not active in this terminal", async (t) => {
430
432
  await withIsolatedCodexDir(t, async ({ codexDir, accountsDir }) => {
431
433
  const service = new account_service_1.AccountService();
432
434
  const currentPath = node_path_1.default.join(codexDir, "current");
433
435
  const sessionMapPath = node_path_1.default.join(accountsDir, "sessions.json");
434
436
  const sessionKey = `ppid:${process.ppid}`;
437
+ process.env.CODEX_AUTH_SESSION_ACTIVE_OVERRIDE = "0";
438
+ await promises_1.default.writeFile(node_path_1.default.join(accountsDir, "odin@megkapja.hu.json"), buildAuthPayload("odin@megkapja.hu", {
439
+ accountId: "acct-odin",
440
+ userId: "user-odin",
441
+ }));
442
+ await promises_1.default.writeFile(node_path_1.default.join(accountsDir, "lajos@edix.hu.json"), buildAuthPayload("lajos@edix.hu", {
443
+ accountId: "acct-lajos",
444
+ userId: "user-lajos",
445
+ }));
446
+ await promises_1.default.writeFile(currentPath, "lajos@edix.hu\n", "utf8");
447
+ await promises_1.default.writeFile(sessionMapPath, `${JSON.stringify({
448
+ version: 1,
449
+ sessions: {
450
+ [sessionKey]: {
451
+ accountName: "odin@megkapja.hu",
452
+ updatedAt: new Date().toISOString(),
453
+ },
454
+ },
455
+ }, null, 2)}\n`, "utf8");
456
+ const active = await service.getCurrentAccountName();
457
+ strict_1.default.equal(active, "lajos@edix.hu");
458
+ });
459
+ });
460
+ (0, node_test_1.default)("getCurrentAccountName prefers session-scoped snapshot when codex is active in this terminal", async (t) => {
461
+ await withIsolatedCodexDir(t, async ({ codexDir, accountsDir }) => {
462
+ const service = new account_service_1.AccountService();
463
+ const currentPath = node_path_1.default.join(codexDir, "current");
464
+ const sessionMapPath = node_path_1.default.join(accountsDir, "sessions.json");
465
+ const sessionKey = `ppid:${process.ppid}`;
466
+ process.env.CODEX_AUTH_SESSION_ACTIVE_OVERRIDE = "1";
435
467
  await promises_1.default.writeFile(node_path_1.default.join(accountsDir, "odin@megkapja.hu.json"), buildAuthPayload("odin@megkapja.hu", {
436
468
  accountId: "acct-odin",
437
469
  userId: "user-odin",
@@ -454,12 +486,49 @@ async function withIsolatedCodexDir(t, fn) {
454
486
  strict_1.default.equal(active, "odin@megkapja.hu");
455
487
  });
456
488
  });
457
- (0, node_test_1.default)("syncExternalAuthSnapshotIfNeeded ignores external login from another terminal when session snapshot differs", async (t) => {
489
+ (0, node_test_1.default)("syncExternalAuthSnapshotIfNeeded follows global sync when codex is not active in this terminal", async (t) => {
490
+ await withIsolatedCodexDir(t, async ({ codexDir, accountsDir, authPath }) => {
491
+ const service = new account_service_1.AccountService();
492
+ const currentPath = node_path_1.default.join(codexDir, "current");
493
+ const sessionMapPath = node_path_1.default.join(accountsDir, "sessions.json");
494
+ const sessionKey = `ppid:${process.ppid}`;
495
+ process.env.CODEX_AUTH_SESSION_ACTIVE_OVERRIDE = "0";
496
+ await promises_1.default.writeFile(node_path_1.default.join(accountsDir, "odin@megkapja.hu.json"), buildAuthPayload("odin@megkapja.hu", {
497
+ accountId: "acct-odin",
498
+ userId: "user-odin",
499
+ }));
500
+ await promises_1.default.writeFile(currentPath, "odin@megkapja.hu\n", "utf8");
501
+ await promises_1.default.writeFile(sessionMapPath, `${JSON.stringify({
502
+ version: 1,
503
+ sessions: {
504
+ [sessionKey]: {
505
+ accountName: "odin@megkapja.hu",
506
+ updatedAt: new Date().toISOString(),
507
+ },
508
+ },
509
+ }, null, 2)}\n`, "utf8");
510
+ await promises_1.default.writeFile(authPath, buildAuthPayload("lajos@edix.hu", {
511
+ accountId: "acct-lajos",
512
+ userId: "user-lajos",
513
+ }), "utf8");
514
+ const result = await service.syncExternalAuthSnapshotIfNeeded();
515
+ strict_1.default.deepEqual(result, {
516
+ synchronized: true,
517
+ savedName: "lajos@edix.hu",
518
+ autoSwitchDisabled: false,
519
+ });
520
+ const parsed = await (0, auth_parser_1.parseAuthSnapshotFile)(node_path_1.default.join(accountsDir, "lajos@edix.hu.json"));
521
+ strict_1.default.equal(parsed.email, "lajos@edix.hu");
522
+ strict_1.default.equal((await promises_1.default.readFile(currentPath, "utf8")).trim(), "lajos@edix.hu");
523
+ });
524
+ });
525
+ (0, node_test_1.default)("syncExternalAuthSnapshotIfNeeded ignores external login from another terminal when codex remains active in this terminal", async (t) => {
458
526
  await withIsolatedCodexDir(t, async ({ codexDir, accountsDir, authPath }) => {
459
527
  const service = new account_service_1.AccountService();
460
528
  const currentPath = node_path_1.default.join(codexDir, "current");
461
529
  const sessionMapPath = node_path_1.default.join(accountsDir, "sessions.json");
462
530
  const sessionKey = `ppid:${process.ppid}`;
531
+ process.env.CODEX_AUTH_SESSION_ACTIVE_OVERRIDE = "1";
463
532
  await promises_1.default.writeFile(node_path_1.default.join(accountsDir, "odin@megkapja.hu.json"), buildAuthPayload("odin@megkapja.hu", {
464
533
  accountId: "acct-odin",
465
534
  userId: "user-odin",
@@ -525,12 +594,51 @@ async function withIsolatedCodexDir(t, fn) {
525
594
  strict_1.default.equal((await promises_1.default.readFile(currentPath, "utf8")).trim(), "lajos@edix.hu");
526
595
  });
527
596
  });
528
- (0, node_test_1.default)("restoreSessionSnapshotIfNeeded re-activates the session-pinned snapshot when auth.json drifts", async (t) => {
597
+ (0, node_test_1.default)("restoreSessionSnapshotIfNeeded skips restore when codex is not active in this terminal", async (t) => {
598
+ await withIsolatedCodexDir(t, async ({ codexDir, accountsDir, authPath }) => {
599
+ const service = new account_service_1.AccountService();
600
+ const currentPath = node_path_1.default.join(codexDir, "current");
601
+ const sessionMapPath = node_path_1.default.join(accountsDir, "sessions.json");
602
+ const sessionKey = `ppid:${process.ppid}`;
603
+ process.env.CODEX_AUTH_SESSION_ACTIVE_OVERRIDE = "0";
604
+ await promises_1.default.writeFile(node_path_1.default.join(accountsDir, "odin@megkapja.hu.json"), buildAuthPayload("odin@megkapja.hu", {
605
+ accountId: "acct-odin",
606
+ userId: "user-odin",
607
+ }));
608
+ await promises_1.default.writeFile(node_path_1.default.join(accountsDir, "lajos@edix.hu.json"), buildAuthPayload("lajos@edix.hu", {
609
+ accountId: "acct-lajos",
610
+ userId: "user-lajos",
611
+ }));
612
+ await promises_1.default.writeFile(currentPath, "lajos@edix.hu\n", "utf8");
613
+ await promises_1.default.writeFile(sessionMapPath, `${JSON.stringify({
614
+ version: 1,
615
+ sessions: {
616
+ [sessionKey]: {
617
+ accountName: "odin@megkapja.hu",
618
+ updatedAt: new Date().toISOString(),
619
+ },
620
+ },
621
+ }, null, 2)}\n`, "utf8");
622
+ await promises_1.default.writeFile(authPath, buildAuthPayload("lajos@edix.hu", {
623
+ accountId: "acct-lajos",
624
+ userId: "user-lajos",
625
+ }), "utf8");
626
+ const restored = await service.restoreSessionSnapshotIfNeeded();
627
+ strict_1.default.deepEqual(restored, {
628
+ restored: false,
629
+ });
630
+ strict_1.default.equal((await promises_1.default.readFile(currentPath, "utf8")).trim(), "lajos@edix.hu");
631
+ const parsed = await (0, auth_parser_1.parseAuthSnapshotFile)(authPath);
632
+ strict_1.default.equal(parsed.email, "lajos@edix.hu");
633
+ });
634
+ });
635
+ (0, node_test_1.default)("restoreSessionSnapshotIfNeeded re-activates the session-pinned snapshot while codex stays active in this terminal", async (t) => {
529
636
  await withIsolatedCodexDir(t, async ({ codexDir, accountsDir, authPath }) => {
530
637
  const service = new account_service_1.AccountService();
531
638
  const currentPath = node_path_1.default.join(codexDir, "current");
532
639
  const sessionMapPath = node_path_1.default.join(accountsDir, "sessions.json");
533
640
  const sessionKey = `ppid:${process.ppid}`;
641
+ process.env.CODEX_AUTH_SESSION_ACTIVE_OVERRIDE = "1";
534
642
  await promises_1.default.writeFile(node_path_1.default.join(accountsDir, "odin@megkapja.hu.json"), buildAuthPayload("odin@megkapja.hu", {
535
643
  accountId: "acct-odin",
536
644
  userId: "user-odin",
@@ -65,3 +65,14 @@ const update_check_1 = require("../lib/update-check");
65
65
  strict_1.default.equal(lines[0], "┌─ codex-auth update");
66
66
  strict_1.default.equal(lines[3], "└─ status : update available");
67
67
  });
68
+ (0, node_test_1.default)("shouldProceedWithYesDefault accepts enter and yes responses", () => {
69
+ strict_1.default.equal((0, update_check_1.shouldProceedWithYesDefault)(""), true);
70
+ strict_1.default.equal((0, update_check_1.shouldProceedWithYesDefault)(" "), true);
71
+ strict_1.default.equal((0, update_check_1.shouldProceedWithYesDefault)("y"), true);
72
+ strict_1.default.equal((0, update_check_1.shouldProceedWithYesDefault)("Yes"), true);
73
+ });
74
+ (0, node_test_1.default)("shouldProceedWithYesDefault rejects no and unknown responses", () => {
75
+ strict_1.default.equal((0, update_check_1.shouldProceedWithYesDefault)("n"), false);
76
+ strict_1.default.equal((0, update_check_1.shouldProceedWithYesDefault)("No"), false);
77
+ strict_1.default.equal((0, update_check_1.shouldProceedWithYesDefault)("later"), false);
78
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imdeadpool/codex-account-switcher",
3
- "version": "0.1.11",
3
+ "version": "0.1.15",
4
4
  "description": "A command-line tool that lets you manage and switch between multiple Codex accounts instantly, no more constant logins and logouts.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -44,11 +44,11 @@
44
44
  "dependencies": {
45
45
  "@oclif/core": "^3.0.0",
46
46
  "prompts": "^2.4.2",
47
- "tslib": "^2.8.1"
47
+ "tslib": "^2.8.1",
48
+ "typescript": "^5.6.3"
48
49
  },
49
50
  "devDependencies": {
50
- "@types/prompts": "^2.4.9",
51
- "typescript": "^5.6.3"
51
+ "@types/prompts": "^2.4.9"
52
52
  },
53
53
  "oclif": {
54
54
  "bin": "codex-auth",
@@ -1,8 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  const fs = require("node:fs/promises");
4
+ const fsSync = require("node:fs");
4
5
  const os = require("node:os");
5
6
  const path = require("node:path");
7
+ const { spawnSync } = require("node:child_process");
6
8
  const readline = require("node:readline/promises");
7
9
 
8
10
  const MARK_START = "# >>> codex-auth-login-auto-snapshot >>>";
@@ -21,7 +23,7 @@ function targetShellRc() {
21
23
  function renderHookBlock() {
22
24
  return [
23
25
  MARK_START,
24
- "# Auto-sync codex-auth snapshots after successful official `codex login`.",
26
+ "# Keep terminal-scoped snapshot memory in sync before/after each `codex` run.",
25
27
  "# Also restore common terminal modes to avoid leaked escape sequences after codex exits.",
26
28
  "__codex_auth_restore_tty() {",
27
29
  " [[ -t 1 ]] || return 0",
@@ -29,39 +31,81 @@ function renderHookBlock() {
29
31
  " [[ -w \"$__tty_target\" ]] || __tty_target=/dev/stdout",
30
32
  " printf '\\033[>4m\\033[<u\\033[?2026l\\033[?1004l\\033[?1l\\033[?2004l\\033[?1000l\\033[?1002l\\033[?1003l\\033[?1006l\\033[?1015l\\033[?1049l\\033[0m\\033[?25h\\033[H\\033>' >\"$__tty_target\" 2>/dev/null || true",
31
33
  "}",
32
- "if ! typeset -f codex >/dev/null 2>&1; then",
33
- " codex() {",
34
- " command codex \"$@\"",
35
- " local __codex_exit=$?",
36
- " if [[ $__codex_exit -eq 0 ]]; then",
37
- " local __first_non_flag=\"\"",
38
- " local __arg",
39
- " for __arg in \"$@\"; do",
40
- " case \"$__arg\" in",
41
- " --) break ;;",
42
- " -*) ;;",
43
- " *) __first_non_flag=\"$__arg\"; break ;;",
44
- " esac",
45
- " done",
46
- " if [[ \"$__first_non_flag\" == \"login\" ]] && command -v codex-auth >/dev/null 2>&1; then",
47
- " command codex-auth status >/dev/null 2>&1 || true",
48
- " fi",
49
- " fi",
50
- " if [[ -z \"${CODEX_AUTH_SKIP_TTY_RESTORE:-}\" ]]; then",
51
- " __codex_auth_restore_tty",
52
- " fi",
53
- " return $__codex_exit",
54
- " }",
55
- "fi",
34
+ "codex() {",
35
+ " if command -v codex-auth >/dev/null 2>&1; then",
36
+ " command codex-auth restore-session >/dev/null 2>&1 || true",
37
+ " fi",
38
+ " command codex \"$@\"",
39
+ " local __codex_exit=$?",
40
+ " if command -v codex-auth >/dev/null 2>&1; then",
41
+ " CODEX_AUTH_FORCE_EXTERNAL_SYNC=1 command codex-auth status >/dev/null 2>&1 || true",
42
+ " fi",
43
+ " if [[ -z \"${CODEX_AUTH_SKIP_TTY_RESTORE:-}\" ]]; then",
44
+ " __codex_auth_restore_tty",
45
+ " fi",
46
+ " return $__codex_exit",
47
+ "}",
56
48
  MARK_END,
57
49
  ].join("\n");
58
50
  }
59
51
 
52
+ function escapeRegex(input) {
53
+ return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
54
+ }
55
+
56
+ function hookBlockRegex() {
57
+ const start = escapeRegex(MARK_START);
58
+ const end = escapeRegex(MARK_END);
59
+ return new RegExp(`\\n?${start}[\\s\\S]*?${end}\\n?`, "g");
60
+ }
61
+
62
+ function normalizeRcContents(contents) {
63
+ const collapsed = contents.replace(/\n{3,}/g, "\n\n");
64
+ return `${collapsed.replace(/\s*$/, "")}\n`;
65
+ }
66
+
67
+ function ensureBuiltDist() {
68
+ const projectRoot = path.resolve(__dirname, "..");
69
+ const distEntry = path.join(projectRoot, "dist", "index.js");
70
+ if (fsSync.existsSync(distEntry)) return;
71
+
72
+ const tscPath = path.join(projectRoot, "node_modules", "typescript", "bin", "tsc");
73
+ if (fsSync.existsSync(tscPath)) {
74
+ const result = spawnSync(process.execPath, [tscPath, "-p", "tsconfig.json"], {
75
+ cwd: projectRoot,
76
+ stdio: "inherit",
77
+ });
78
+ if (result.status !== 0) {
79
+ throw new Error(`TypeScript build failed with exit code ${result.status ?? "unknown"}.`);
80
+ }
81
+ return;
82
+ }
83
+
84
+ const npmBinary = process.platform === "win32" ? "npm.cmd" : "npm";
85
+ const fallback = spawnSync(
86
+ npmBinary,
87
+ ["exec", "--yes", "--package", "typescript@5.6.3", "--", "tsc", "-p", "tsconfig.json"],
88
+ {
89
+ cwd: projectRoot,
90
+ stdio: "inherit",
91
+ },
92
+ );
93
+ if (fallback.status !== 0) {
94
+ throw new Error(
95
+ `Missing TypeScript compiler for git install bootstrap (fallback exit ${fallback.status ?? "unknown"}).`,
96
+ );
97
+ }
98
+
99
+ if (!fsSync.existsSync(distEntry)) {
100
+ throw new Error("TypeScript build completed but dist/index.js is still missing.");
101
+ }
102
+ }
103
+
60
104
  async function maybeInstallHook() {
61
105
  if (process.env.npm_config_global !== "true") return;
62
106
  if (isTruthy(process.env.CODEX_AUTH_SKIP_POSTINSTALL)) return;
63
107
  if (isTruthy(process.env.CI)) return;
64
- if (!process.stdin.isTTY || !process.stdout.isTTY) return;
108
+ const canPrompt = process.stdin.isTTY && process.stdout.isTTY;
65
109
 
66
110
  const rcPath = targetShellRc();
67
111
  await fs.mkdir(path.dirname(rcPath), { recursive: true });
@@ -73,7 +117,16 @@ async function maybeInstallHook() {
73
117
  if (error && error.code !== "ENOENT") throw error;
74
118
  }
75
119
 
76
- if (rc.includes(MARK_START) && rc.includes(MARK_END)) return;
120
+ if (rc.includes(MARK_START) && rc.includes(MARK_END)) {
121
+ const refreshed = normalizeRcContents(rc.replace(hookBlockRegex(), `\n${renderHookBlock()}\n`));
122
+ if (refreshed !== normalizeRcContents(rc)) {
123
+ await fs.writeFile(rcPath, refreshed, "utf8");
124
+ process.stdout.write(`\nUpdated shell hook in ${rcPath}. Restart terminal or run: source ${rcPath}\n`);
125
+ }
126
+ return;
127
+ }
128
+
129
+ if (!canPrompt) return;
77
130
 
78
131
  const rl = readline.createInterface({
79
132
  input: process.stdin,
@@ -89,12 +142,20 @@ async function maybeInstallHook() {
89
142
  rl.close();
90
143
  }
91
144
 
92
- const next = `${rc.replace(/\s*$/, "")}\n\n${renderHookBlock()}\n`;
145
+ const next = normalizeRcContents(`${rc}\n\n${renderHookBlock()}\n`);
93
146
  await fs.writeFile(rcPath, next, "utf8");
94
147
  process.stdout.write(`\nInstalled shell hook in ${rcPath}. Restart terminal or run: source ${rcPath}\n`);
95
148
  }
96
149
 
97
- maybeInstallHook().catch((error) => {
98
- const message = error instanceof Error ? error.message : String(error);
99
- process.stderr.write(`\n[codex-auth postinstall] Failed to install login hook: ${message}\n`);
100
- });
150
+ function runPostinstall() {
151
+ ensureBuiltDist();
152
+ return maybeInstallHook();
153
+ }
154
+
155
+ Promise.resolve()
156
+ .then(() => runPostinstall())
157
+ .catch((error) => {
158
+ const message = error instanceof Error ? error.message : String(error);
159
+ process.stderr.write(`\n[codex-auth postinstall] Failed: ${message}\n`);
160
+ process.exitCode = 1;
161
+ });