@imdeadpool/codex-account-switcher 0.1.8 → 0.1.10

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
@@ -9,6 +9,8 @@ A command-line tool that lets you manage and switch between multiple Codex accou
9
9
 
10
10
  Codex stores your authentication session in a single `auth.json` file. This tool works by creating named snapshots of that file for each of your accounts. When you want to switch, `codex-auth` swaps the active `~/.codex/auth.json` with the snapshot you select, instantly changing your logged-in account.
11
11
 
12
+ `codex-auth` also keeps a lightweight per-terminal session memory (scoped by shell parent PID by default), so older terminals keep using their original snapshot even if a different terminal logs into another account later.
13
+
12
14
  ## Requirements
13
15
 
14
16
  - Node.js 18 or newer
@@ -26,6 +28,16 @@ official `codex login`.
26
28
  - Choose `y` to enable fully automatic login snapshot capture.
27
29
  - Choose `n` (default) to skip.
28
30
  - Set `CODEX_AUTH_SKIP_POSTINSTALL=1` to always suppress this prompt.
31
+ - Set `CODEX_AUTH_SKIP_TTY_RESTORE=1` to keep the hook from restoring terminal modes after `codex` exits.
32
+ - Set `CODEX_AUTH_SESSION_KEY=<id>` to explicitly scope session-memory identity (optional; default uses shell PPID).
33
+ - For a calmer Codex footer, prefer a focused `[tui] status_line` such as:
34
+
35
+ ```toml
36
+ [tui]
37
+ status_line = ["model-with-reasoning", "git-branch", "context-remaining"]
38
+ ```
39
+
40
+ This remains a manual Codex config choice; `codex-auth` does not rewrite `~/.codex/config.toml`.
29
41
 
30
42
  ## Usage
31
43
 
@@ -60,6 +72,15 @@ codex-auth list --details
60
72
  # show current account name
61
73
  codex-auth current
62
74
 
75
+ # check for a newer release and update globally
76
+ codex-auth self-update
77
+
78
+ # check only (no install)
79
+ codex-auth self-update --check
80
+
81
+ # reinstall latest even if already up to date
82
+ codex-auth self-update --reinstall
83
+
63
84
  # remove accounts (interactive multi-select)
64
85
  codex-auth remove
65
86
 
@@ -96,12 +117,13 @@ codex-auth remove-login-hook
96
117
  - `codex-auth use [name]` – Accepts a name or launches an interactive selector with the current account pre-selected, writes `~/.codex/auth.json` as a regular file from the chosen snapshot, and records the active name.
97
118
  - `codex-auth list [--details]` – Lists all saved snapshots alphabetically and marks the active one with `*`. `--details` adds per-snapshot mapping metadata (email, account id, user id, and usage metadata) for easier session/account troubleshooting.
98
119
  - `codex-auth current` – Prints the active account name, or a friendly message if none is active.
120
+ - `codex-auth self-update [--check] [--reinstall] [-y]` – Checks npm for newer release metadata. `--check` prints current/latest/status only. `--reinstall` forces reinstall even when already up to date. `-y` skips confirmation prompts.
99
121
  - `codex-auth remove [query|--all]` – Removes snapshots interactively or by selector. If the active account is removed, the best remaining account is activated automatically.
100
122
  - `codex-auth status` – Prints auto-switch state, managed service status, active thresholds, and usage mode.
101
123
  - `codex-auth config auto ...` – Enables/disables managed auto-switch and updates threshold percentages.
102
124
  - `codex-auth config api enable|disable` – Chooses usage source mode (`api` or `local`).
103
125
  - `codex-auth daemon --once|--watch` – Runs the auto-switch loop once or continuously.
104
- - `codex-auth setup-login-hook [-f <path>]` – Installs an optional shell hook in your rc file to auto-sync snapshots after successful official `codex login`.
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, auto-sync snapshots after successful official `codex login`, and restore common terminal modes before returning to your prompt.
105
127
  - `codex-auth hook-status [-f <path>]` – Shows whether the optional login auto-snapshot hook is installed for the selected rc file.
106
128
  - `codex-auth remove-login-hook [-f <path>]` – Removes the optional shell hook.
107
129
 
@@ -131,3 +153,4 @@ Notes:
131
153
 
132
154
  - Works on macOS/Linux/Windows (regular-file auth snapshot activation).
133
155
  - Requires Node 18+.
156
+ - Running bare `codex-auth` shows the help screen and also displays an update notice when a newer npm release is available.
@@ -5,4 +5,5 @@ export default class ListCommand extends BaseCommand {
5
5
  readonly details: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
6
6
  };
7
7
  run(): Promise<void>;
8
+ private maybeOfferGlobalUpdate;
8
9
  }
@@ -1,13 +1,19 @@
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 });
3
6
  const core_1 = require("@oclif/core");
7
+ const prompts_1 = __importDefault(require("prompts"));
4
8
  const base_command_1 = require("../lib/base-command");
9
+ const update_check_1 = require("../lib/update-check");
5
10
  class ListCommand extends base_command_1.BaseCommand {
6
11
  async run() {
7
12
  await this.runSafe(async () => {
8
13
  var _a, _b, _c, _d, _e, _f;
9
14
  const { flags } = await this.parse(ListCommand);
10
15
  const detailed = Boolean(flags.details);
16
+ await this.maybeOfferGlobalUpdate();
11
17
  if (!detailed) {
12
18
  const accounts = await this.accounts.listAccountNames();
13
19
  const current = await this.accounts.getCurrentAccountName();
@@ -34,6 +40,36 @@ class ListCommand extends base_command_1.BaseCommand {
34
40
  }
35
41
  });
36
42
  }
43
+ async maybeOfferGlobalUpdate() {
44
+ if (!process.stdin.isTTY || !process.stdout.isTTY)
45
+ return;
46
+ const currentVersion = this.config.version;
47
+ if (!currentVersion || typeof currentVersion !== "string")
48
+ return;
49
+ const latestVersion = await (0, update_check_1.fetchLatestNpmVersion)(update_check_1.PACKAGE_NAME);
50
+ if (!latestVersion)
51
+ return;
52
+ const summary = (0, update_check_1.getUpdateSummary)(currentVersion, latestVersion);
53
+ if (summary.state !== "update-available")
54
+ return;
55
+ this.log((0, update_check_1.formatUpdateSummaryInline)(summary));
56
+ const prompt = await (0, prompts_1.default)({
57
+ type: "confirm",
58
+ name: "install",
59
+ message: "Press Enter to update globally now",
60
+ initial: true,
61
+ });
62
+ if (!prompt.install) {
63
+ this.log(`Skipped update. Run manually: npm i -g ${update_check_1.PACKAGE_NAME}@latest`);
64
+ return;
65
+ }
66
+ const installExitCode = await (0, update_check_1.runGlobalNpmInstall)(update_check_1.PACKAGE_NAME);
67
+ if (installExitCode === 0) {
68
+ this.log("Global update completed.");
69
+ return;
70
+ }
71
+ this.warn(`Global update failed (exit code ${installExitCode}). Try: npm i -g ${update_check_1.PACKAGE_NAME}@latest`);
72
+ }
37
73
  }
38
74
  ListCommand.description = "List accounts managed under ~/.codex";
39
75
  ListCommand.flags = {
@@ -0,0 +1,7 @@
1
+ import { BaseCommand } from "../lib/base-command";
2
+ export default class RestoreSessionCommand extends BaseCommand {
3
+ protected readonly syncExternalAuthBeforeRun = false;
4
+ static hidden: boolean;
5
+ static description: string;
6
+ run(): Promise<void>;
7
+ }
@@ -0,0 +1,17 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const base_command_1 = require("../lib/base-command");
4
+ class RestoreSessionCommand extends base_command_1.BaseCommand {
5
+ constructor() {
6
+ super(...arguments);
7
+ this.syncExternalAuthBeforeRun = false;
8
+ }
9
+ async run() {
10
+ await this.runSafe(async () => {
11
+ await this.accounts.restoreSessionSnapshotIfNeeded();
12
+ });
13
+ }
14
+ }
15
+ RestoreSessionCommand.hidden = true;
16
+ RestoreSessionCommand.description = "Restore auth.json from the session-pinned snapshot (internal helper)";
17
+ exports.default = RestoreSessionCommand;
@@ -0,0 +1,10 @@
1
+ import { BaseCommand } from "../lib/base-command";
2
+ export default class SelfUpdateCommand extends BaseCommand {
3
+ static description: string;
4
+ static flags: {
5
+ readonly check: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
6
+ readonly reinstall: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
7
+ readonly yes: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
8
+ };
9
+ run(): Promise<void>;
10
+ }
@@ -0,0 +1,80 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const core_1 = require("@oclif/core");
7
+ const prompts_1 = __importDefault(require("prompts"));
8
+ const base_command_1 = require("../lib/base-command");
9
+ const update_check_1 = require("../lib/update-check");
10
+ class SelfUpdateCommand extends base_command_1.BaseCommand {
11
+ async run() {
12
+ await this.runSafe(async () => {
13
+ const { flags } = await this.parse(SelfUpdateCommand);
14
+ const currentVersion = this.config.version;
15
+ const latestVersion = await (0, update_check_1.fetchLatestNpmVersion)(update_check_1.PACKAGE_NAME);
16
+ if (!latestVersion) {
17
+ this.warn("Could not check npm for the latest release right now.");
18
+ return;
19
+ }
20
+ const summary = (0, update_check_1.getUpdateSummary)(currentVersion, latestVersion);
21
+ const hasUpdate = summary.state === "update-available";
22
+ if (flags.check) {
23
+ for (const line of (0, update_check_1.formatUpdateSummaryCard)(summary)) {
24
+ this.log(line);
25
+ }
26
+ if (hasUpdate) {
27
+ this.log("Run `codex-auth self-update` to install the latest release.");
28
+ }
29
+ return;
30
+ }
31
+ if (!hasUpdate && !flags.reinstall) {
32
+ this.log((0, update_check_1.formatUpdateSummaryInline)(summary));
33
+ this.log("Use `codex-auth self-update --reinstall` if you want to reinstall anyway.");
34
+ return;
35
+ }
36
+ if (hasUpdate) {
37
+ this.log((0, update_check_1.formatUpdateSummaryInline)(summary));
38
+ }
39
+ else {
40
+ this.log(`↺ Reinstall requested for latest version (${latestVersion}).`);
41
+ }
42
+ if (!flags.yes) {
43
+ const response = await (0, prompts_1.default)({
44
+ type: "confirm",
45
+ name: "proceed",
46
+ message: "Proceed with global npm update now?",
47
+ initial: true,
48
+ });
49
+ if (!response.proceed) {
50
+ this.log("Update cancelled.");
51
+ return;
52
+ }
53
+ }
54
+ const exitCode = await (0, update_check_1.runGlobalNpmInstall)(update_check_1.PACKAGE_NAME);
55
+ if (exitCode === 0) {
56
+ this.log(`✓ Global update completed (installed ${latestVersion}).`);
57
+ return;
58
+ }
59
+ this.warn(`Global update failed (exit code ${exitCode}). Run: npm i -g ${update_check_1.PACKAGE_NAME}@latest`);
60
+ });
61
+ }
62
+ }
63
+ SelfUpdateCommand.description = "Check for updates and upgrade codex-auth globally";
64
+ SelfUpdateCommand.flags = {
65
+ check: core_1.Flags.boolean({
66
+ description: "Only check whether an update is available",
67
+ default: false,
68
+ }),
69
+ reinstall: core_1.Flags.boolean({
70
+ char: "r",
71
+ description: "Reinstall the latest version even when already up to date",
72
+ default: false,
73
+ }),
74
+ yes: core_1.Flags.boolean({
75
+ char: "y",
76
+ description: "Skip confirmation prompts",
77
+ default: false,
78
+ }),
79
+ };
80
+ exports.default = SelfUpdateCommand;
@@ -0,0 +1,3 @@
1
+ import type { Hook } from "@oclif/core";
2
+ declare const hook: Hook.Init;
3
+ export default hook;
@@ -0,0 +1,23 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const update_check_1 = require("../../lib/update-check");
4
+ const hook = async function (options) {
5
+ if (options.id)
6
+ return;
7
+ if (options.argv.length > 0)
8
+ return;
9
+ if (!process.stdin.isTTY || !process.stdout.isTTY)
10
+ return;
11
+ const currentVersion = options.config.version;
12
+ if (!currentVersion)
13
+ return;
14
+ const latestVersion = await (0, update_check_1.fetchLatestNpmVersion)(update_check_1.PACKAGE_NAME);
15
+ if (!latestVersion)
16
+ return;
17
+ const summary = (0, update_check_1.getUpdateSummary)(currentVersion, latestVersion);
18
+ if (summary.state !== "update-available")
19
+ return;
20
+ this.log((0, update_check_1.formatUpdateSummaryInline)(summary));
21
+ this.log("Run `codex-auth self-update` to install the latest version.");
22
+ };
23
+ exports.default = hook;
@@ -26,6 +26,10 @@ export interface ExternalAuthSyncResult {
26
26
  }
27
27
  export declare class AccountService {
28
28
  syncExternalAuthSnapshotIfNeeded(): Promise<ExternalAuthSyncResult>;
29
+ restoreSessionSnapshotIfNeeded(): Promise<{
30
+ restored: boolean;
31
+ accountName?: string;
32
+ }>;
29
33
  listAccountNames(): Promise<string[]>;
30
34
  listAccountChoices(): Promise<AccountChoice[]>;
31
35
  listAccountMappings(): Promise<AccountMapping[]>;
@@ -61,12 +65,20 @@ export declare class AccountService {
61
65
  private writeCurrentName;
62
66
  private readCurrentNameFile;
63
67
  private pathExists;
68
+ private filesMatch;
64
69
  private hydrateSnapshotMetadata;
65
70
  private resolveUniqueInferredName;
66
71
  private loadReconciledRegistry;
67
72
  private persistRegistry;
68
73
  private activateSnapshot;
69
74
  private clearActivePointers;
75
+ private isExternalSyncForced;
76
+ private resolveSessionScopeKey;
77
+ private getSessionAccountName;
78
+ private setSessionAccountName;
79
+ private clearSessionAccountName;
80
+ private readSessionMap;
81
+ private writeSessionMap;
70
82
  private snapshotsShareIdentity;
71
83
  private renderSnapshotIdentity;
72
84
  }
@@ -14,6 +14,8 @@ const registry_1 = require("./registry");
14
14
  const usage_1 = require("./usage");
15
15
  const service_manager_1 = require("./service-manager");
16
16
  const ACCOUNT_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._@+-]*$/;
17
+ const EXTERNAL_SYNC_FORCE_ENV = "CODEX_AUTH_FORCE_EXTERNAL_SYNC";
18
+ const SESSION_KEY_ENV = "CODEX_AUTH_SESSION_KEY";
17
19
  class AccountService {
18
20
  async syncExternalAuthSnapshotIfNeeded() {
19
21
  const authPath = (0, paths_1.resolveAuthPath)();
@@ -31,16 +33,41 @@ class AccountService {
31
33
  autoSwitchDisabled: false,
32
34
  };
33
35
  }
36
+ const sessionAccountName = await this.getSessionAccountName();
37
+ if (sessionAccountName) {
38
+ const sessionSnapshotPath = this.accountFilePath(sessionAccountName);
39
+ if (await this.pathExists(sessionSnapshotPath)) {
40
+ const sessionSnapshot = await (0, auth_parser_1.parseAuthSnapshotFile)(sessionSnapshotPath);
41
+ if (sessionSnapshot.authMode === "chatgpt" &&
42
+ !this.snapshotsShareIdentity(sessionSnapshot, incomingSnapshot) &&
43
+ !this.isExternalSyncForced()) {
44
+ return {
45
+ synchronized: false,
46
+ autoSwitchDisabled: false,
47
+ };
48
+ }
49
+ }
50
+ }
51
+ const resolvedName = await this.resolveLoginAccountNameFromCurrentAuth();
34
52
  const activeName = await this.getCurrentAccountName();
35
53
  if (activeName) {
36
54
  const activeSnapshotPath = this.accountFilePath(activeName);
37
55
  if (await this.pathExists(activeSnapshotPath)) {
38
56
  const activeSnapshot = await (0, auth_parser_1.parseAuthSnapshotFile)(activeSnapshotPath);
39
57
  if (this.snapshotsShareIdentity(activeSnapshot, incomingSnapshot)) {
40
- return {
41
- synchronized: false,
42
- autoSwitchDisabled: false,
43
- };
58
+ if (activeName === resolvedName.name) {
59
+ return {
60
+ synchronized: false,
61
+ autoSwitchDisabled: false,
62
+ };
63
+ }
64
+ const authMatchesActiveSnapshot = await this.filesMatch(authPath, activeSnapshotPath);
65
+ if (authMatchesActiveSnapshot) {
66
+ return {
67
+ synchronized: false,
68
+ autoSwitchDisabled: false,
69
+ };
70
+ }
44
71
  }
45
72
  }
46
73
  }
@@ -49,7 +76,6 @@ class AccountService {
49
76
  if (autoSwitchDisabled) {
50
77
  await this.setAutoSwitchEnabled(false);
51
78
  }
52
- const resolvedName = await this.resolveLoginAccountNameFromCurrentAuth();
53
79
  const savedName = await this.saveAccount(resolvedName.name);
54
80
  return {
55
81
  synchronized: true,
@@ -57,14 +83,50 @@ class AccountService {
57
83
  autoSwitchDisabled,
58
84
  };
59
85
  }
86
+ async restoreSessionSnapshotIfNeeded() {
87
+ const sessionAccountName = await this.getSessionAccountName();
88
+ if (!sessionAccountName) {
89
+ return { restored: false };
90
+ }
91
+ const snapshotPath = this.accountFilePath(sessionAccountName);
92
+ if (!(await this.pathExists(snapshotPath))) {
93
+ await this.clearSessionAccountName();
94
+ return { restored: false };
95
+ }
96
+ const authPath = (0, paths_1.resolveAuthPath)();
97
+ if (await this.pathExists(authPath)) {
98
+ const [sessionSnapshot, activeSnapshot] = await Promise.all([
99
+ (0, auth_parser_1.parseAuthSnapshotFile)(snapshotPath),
100
+ (0, auth_parser_1.parseAuthSnapshotFile)(authPath),
101
+ ]);
102
+ if (this.snapshotsShareIdentity(sessionSnapshot, activeSnapshot)) {
103
+ return {
104
+ restored: false,
105
+ accountName: sessionAccountName,
106
+ };
107
+ }
108
+ }
109
+ await this.activateSnapshot(sessionAccountName);
110
+ return {
111
+ restored: true,
112
+ accountName: sessionAccountName,
113
+ };
114
+ }
60
115
  async listAccountNames() {
61
116
  const accountsDir = (0, paths_1.resolveAccountsDir)();
62
117
  if (!(await this.pathExists(accountsDir))) {
63
118
  return [];
64
119
  }
120
+ const sessionMapPath = (0, paths_1.resolveSessionMapPath)();
121
+ const sessionMapBasename = node_path_1.default.dirname(sessionMapPath) === accountsDir
122
+ ? node_path_1.default.basename(sessionMapPath)
123
+ : undefined;
65
124
  const entries = await promises_1.default.readdir(accountsDir, { withFileTypes: true });
66
125
  return entries
67
- .filter((entry) => entry.isFile() && entry.name.endsWith(".json") && entry.name !== "registry.json")
126
+ .filter((entry) => entry.isFile() &&
127
+ entry.name.endsWith(".json") &&
128
+ entry.name !== "registry.json" &&
129
+ entry.name !== sessionMapBasename)
68
130
  .map((entry) => entry.name.replace(/\.json$/i, ""))
69
131
  .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }));
70
132
  }
@@ -129,10 +191,20 @@ class AccountService {
129
191
  });
130
192
  }
131
193
  async getCurrentAccountName() {
194
+ const sessionAccountName = await this.getSessionAccountName();
195
+ if (sessionAccountName) {
196
+ const sessionSnapshotPath = this.accountFilePath(sessionAccountName);
197
+ if (await this.pathExists(sessionSnapshotPath)) {
198
+ return sessionAccountName;
199
+ }
200
+ await this.clearSessionAccountName();
201
+ }
132
202
  const currentNamePath = (0, paths_1.resolveCurrentNamePath)();
133
203
  const currentName = await this.readCurrentNameFile(currentNamePath);
134
- if (currentName)
204
+ if (currentName) {
205
+ await this.setSessionAccountName(currentName);
135
206
  return currentName;
207
+ }
136
208
  const authPath = (0, paths_1.resolveAuthPath)();
137
209
  if (!(await this.pathExists(authPath)))
138
210
  return null;
@@ -148,7 +220,9 @@ class AccountService {
148
220
  const base = node_path_1.default.basename(resolvedTarget);
149
221
  if (!base.endsWith(".json") || base === "registry.json")
150
222
  return null;
151
- return base.replace(/\.json$/i, "");
223
+ const resolvedName = base.replace(/\.json$/i, "");
224
+ await this.setSessionAccountName(resolvedName);
225
+ return resolvedName;
152
226
  }
153
227
  async saveAccount(rawName, options) {
154
228
  const name = this.normalizeAccountName(rawName);
@@ -526,6 +600,7 @@ class AccountService {
526
600
  const currentNamePath = (0, paths_1.resolveCurrentNamePath)();
527
601
  await this.ensureDir(node_path_1.default.dirname(currentNamePath));
528
602
  await promises_1.default.writeFile(currentNamePath, `${name}\n`, "utf8");
603
+ await this.setSessionAccountName(name);
529
604
  }
530
605
  async readCurrentNameFile(currentNamePath) {
531
606
  try {
@@ -550,6 +625,15 @@ class AccountService {
550
625
  return false;
551
626
  }
552
627
  }
628
+ async filesMatch(firstPath, secondPath) {
629
+ try {
630
+ const [first, second] = await Promise.all([promises_1.default.readFile(firstPath), promises_1.default.readFile(secondPath)]);
631
+ return first.equals(second);
632
+ }
633
+ catch {
634
+ return false;
635
+ }
636
+ }
553
637
  async hydrateSnapshotMetadata(registry, accountName) {
554
638
  var _a;
555
639
  const parsed = await (0, auth_parser_1.parseAuthSnapshotFile)(this.accountFilePath(accountName));
@@ -618,6 +702,111 @@ class AccountService {
618
702
  const authPath = (0, paths_1.resolveAuthPath)();
619
703
  await this.removeIfExists(currentPath);
620
704
  await this.removeIfExists(authPath);
705
+ await this.clearSessionAccountName();
706
+ }
707
+ isExternalSyncForced() {
708
+ const raw = process.env[EXTERNAL_SYNC_FORCE_ENV];
709
+ if (!raw)
710
+ return false;
711
+ const normalized = raw.trim().toLowerCase();
712
+ if (!normalized)
713
+ return false;
714
+ return !["0", "false", "no", "off"].includes(normalized);
715
+ }
716
+ resolveSessionScopeKey() {
717
+ var _a;
718
+ const explicit = (_a = process.env[SESSION_KEY_ENV]) === null || _a === void 0 ? void 0 : _a.trim();
719
+ if (explicit) {
720
+ const sanitized = explicit.replace(/\s+/g, " ").slice(0, 160);
721
+ return `session:${sanitized}`;
722
+ }
723
+ if (typeof process.ppid === "number" && process.ppid > 1) {
724
+ return `ppid:${process.ppid}`;
725
+ }
726
+ return null;
727
+ }
728
+ async getSessionAccountName() {
729
+ const sessionKey = this.resolveSessionScopeKey();
730
+ if (!sessionKey)
731
+ return null;
732
+ const sessionMap = await this.readSessionMap();
733
+ const entry = sessionMap.sessions[sessionKey];
734
+ if (!(entry === null || entry === void 0 ? void 0 : entry.accountName))
735
+ return null;
736
+ try {
737
+ return this.normalizeAccountName(entry.accountName);
738
+ }
739
+ catch {
740
+ return null;
741
+ }
742
+ }
743
+ async setSessionAccountName(accountName) {
744
+ const sessionKey = this.resolveSessionScopeKey();
745
+ if (!sessionKey)
746
+ return;
747
+ const sessionMap = await this.readSessionMap();
748
+ sessionMap.sessions[sessionKey] = {
749
+ accountName,
750
+ updatedAt: new Date().toISOString(),
751
+ };
752
+ await this.writeSessionMap(sessionMap);
753
+ }
754
+ async clearSessionAccountName() {
755
+ const sessionKey = this.resolveSessionScopeKey();
756
+ if (!sessionKey)
757
+ return;
758
+ const sessionMap = await this.readSessionMap();
759
+ if (!sessionMap.sessions[sessionKey])
760
+ return;
761
+ delete sessionMap.sessions[sessionKey];
762
+ await this.writeSessionMap(sessionMap);
763
+ }
764
+ async readSessionMap() {
765
+ const sessionMapPath = (0, paths_1.resolveSessionMapPath)();
766
+ try {
767
+ const raw = await promises_1.default.readFile(sessionMapPath, "utf8");
768
+ const parsed = JSON.parse(raw);
769
+ if (!parsed || typeof parsed !== "object") {
770
+ return {
771
+ version: 1,
772
+ sessions: {},
773
+ };
774
+ }
775
+ const root = parsed;
776
+ const sessionsRaw = root.sessions && typeof root.sessions === "object"
777
+ ? root.sessions
778
+ : {};
779
+ const sessions = {};
780
+ for (const [key, value] of Object.entries(sessionsRaw)) {
781
+ if (!value || typeof value !== "object")
782
+ continue;
783
+ const rawEntry = value;
784
+ const accountName = typeof rawEntry.accountName === "string" ? rawEntry.accountName.trim() : "";
785
+ if (!accountName)
786
+ continue;
787
+ sessions[key] = {
788
+ accountName,
789
+ updatedAt: typeof rawEntry.updatedAt === "string" && rawEntry.updatedAt.length > 0
790
+ ? rawEntry.updatedAt
791
+ : new Date().toISOString(),
792
+ };
793
+ }
794
+ return {
795
+ version: 1,
796
+ sessions,
797
+ };
798
+ }
799
+ catch {
800
+ return {
801
+ version: 1,
802
+ sessions: {},
803
+ };
804
+ }
805
+ }
806
+ async writeSessionMap(sessionMap) {
807
+ const sessionMapPath = (0, paths_1.resolveSessionMapPath)();
808
+ await this.ensureDir(node_path_1.default.dirname(sessionMapPath));
809
+ await promises_1.default.writeFile(sessionMapPath, `${JSON.stringify(sessionMap, null, 2)}\n`, "utf8");
621
810
  }
622
811
  snapshotsShareIdentity(a, b) {
623
812
  var _a, _b;
@@ -34,8 +34,18 @@ function renderLoginHookBlock() {
34
34
  return [
35
35
  exports.LOGIN_HOOK_MARK_START,
36
36
  "# Auto-sync codex-auth snapshots after successful official `codex login`.",
37
+ "# Also restore common terminal modes to avoid leaked escape sequences after codex exits.",
38
+ "__codex_auth_restore_tty() {",
39
+ " [[ -t 1 ]] || return 0",
40
+ " local __tty_target=/dev/tty",
41
+ " [[ -w \"$__tty_target\" ]] || __tty_target=/dev/stdout",
42
+ " 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
+ "}",
37
44
  "if ! typeset -f codex >/dev/null 2>&1; then",
38
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",
39
49
  " command codex \"$@\"",
40
50
  " local __codex_exit=$?",
41
51
  " if [[ $__codex_exit -eq 0 ]]; then",
@@ -49,9 +59,12 @@ function renderLoginHookBlock() {
49
59
  " esac",
50
60
  " done",
51
61
  " if [[ \"$__first_non_flag\" == \"login\" ]] && command -v codex-auth >/dev/null 2>&1; then",
52
- " command codex-auth status >/dev/null 2>&1 || true",
62
+ " CODEX_AUTH_FORCE_EXTERNAL_SYNC=1 command codex-auth status >/dev/null 2>&1 || true",
53
63
  " fi",
54
64
  " fi",
65
+ " if [[ -z \"${CODEX_AUTH_SKIP_TTY_RESTORE:-}\" ]]; then",
66
+ " __codex_auth_restore_tty",
67
+ " fi",
55
68
  " return $__codex_exit",
56
69
  " }",
57
70
  "fi",
@@ -3,8 +3,10 @@ export declare function resolveAccountsDir(): string;
3
3
  export declare function resolveAuthPath(): string;
4
4
  export declare function resolveCurrentNamePath(): string;
5
5
  export declare function resolveRegistryPath(): string;
6
+ export declare function resolveSessionMapPath(): string;
6
7
  export declare const codexDir: string;
7
8
  export declare const accountsDir: string;
8
9
  export declare const authPath: string;
9
10
  export declare const currentNamePath: string;
10
11
  export declare const registryPath: string;
12
+ export declare const sessionMapPath: string;
@@ -3,12 +3,13 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.registryPath = exports.currentNamePath = exports.authPath = exports.accountsDir = exports.codexDir = void 0;
6
+ exports.sessionMapPath = exports.registryPath = exports.currentNamePath = exports.authPath = exports.accountsDir = exports.codexDir = void 0;
7
7
  exports.resolveCodexDir = resolveCodexDir;
8
8
  exports.resolveAccountsDir = resolveAccountsDir;
9
9
  exports.resolveAuthPath = resolveAuthPath;
10
10
  exports.resolveCurrentNamePath = resolveCurrentNamePath;
11
11
  exports.resolveRegistryPath = resolveRegistryPath;
12
+ exports.resolveSessionMapPath = resolveSessionMapPath;
12
13
  const node_os_1 = __importDefault(require("node:os"));
13
14
  const node_path_1 = __importDefault(require("node:path"));
14
15
  function resolvePath(raw) {
@@ -46,8 +47,16 @@ function resolveCurrentNamePath() {
46
47
  function resolveRegistryPath() {
47
48
  return node_path_1.default.join(resolveAccountsDir(), "registry.json");
48
49
  }
50
+ function resolveSessionMapPath() {
51
+ const envPath = process.env.CODEX_AUTH_SESSION_MAP_PATH;
52
+ if (envPath && envPath.trim().length > 0) {
53
+ return resolvePath(envPath.trim());
54
+ }
55
+ return node_path_1.default.join(resolveAccountsDir(), "sessions.json");
56
+ }
49
57
  exports.codexDir = resolveCodexDir();
50
58
  exports.accountsDir = resolveAccountsDir();
51
59
  exports.authPath = resolveAuthPath();
52
60
  exports.currentNamePath = resolveCurrentNamePath();
53
61
  exports.registryPath = resolveRegistryPath();
62
+ exports.sessionMapPath = resolveSessionMapPath();
@@ -0,0 +1,14 @@
1
+ export declare const PACKAGE_NAME = "@imdeadpool/codex-account-switcher";
2
+ export type UpdateState = "update-available" | "up-to-date" | "unknown";
3
+ export interface UpdateSummary {
4
+ currentVersion: string;
5
+ latestVersion: string;
6
+ state: UpdateState;
7
+ }
8
+ export declare function parseVersionTriplet(version: string): [number, number, number] | null;
9
+ export declare function isVersionNewer(currentVersion: string, latestVersion: string): boolean;
10
+ export declare function getUpdateSummary(currentVersion: string, latestVersion: string): UpdateSummary;
11
+ export declare function formatUpdateSummaryCard(summary: UpdateSummary): string[];
12
+ export declare function formatUpdateSummaryInline(summary: UpdateSummary): string;
13
+ export declare function fetchLatestNpmVersion(packageName: string, timeoutMs?: number): Promise<string | null>;
14
+ export declare function runGlobalNpmInstall(packageName: string, version?: "latest" | string): Promise<number>;
@@ -0,0 +1,125 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PACKAGE_NAME = void 0;
4
+ exports.parseVersionTriplet = parseVersionTriplet;
5
+ exports.isVersionNewer = isVersionNewer;
6
+ exports.getUpdateSummary = getUpdateSummary;
7
+ exports.formatUpdateSummaryCard = formatUpdateSummaryCard;
8
+ exports.formatUpdateSummaryInline = formatUpdateSummaryInline;
9
+ exports.fetchLatestNpmVersion = fetchLatestNpmVersion;
10
+ exports.runGlobalNpmInstall = runGlobalNpmInstall;
11
+ const node_child_process_1 = require("node:child_process");
12
+ const SEMVER_TRIPLET = /^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/;
13
+ exports.PACKAGE_NAME = "@imdeadpool/codex-account-switcher";
14
+ function parseVersionTriplet(version) {
15
+ const match = version.trim().match(SEMVER_TRIPLET);
16
+ if (!match)
17
+ return null;
18
+ return [Number(match[1]), Number(match[2]), Number(match[3])];
19
+ }
20
+ function isVersionNewer(currentVersion, latestVersion) {
21
+ const current = parseVersionTriplet(currentVersion);
22
+ const latest = parseVersionTriplet(latestVersion);
23
+ if (!current || !latest)
24
+ return false;
25
+ for (let i = 0; i < 3; i += 1) {
26
+ if (latest[i] > current[i])
27
+ return true;
28
+ if (latest[i] < current[i])
29
+ return false;
30
+ }
31
+ return false;
32
+ }
33
+ function getUpdateSummary(currentVersion, latestVersion) {
34
+ const current = parseVersionTriplet(currentVersion);
35
+ const latest = parseVersionTriplet(latestVersion);
36
+ if (!current || !latest) {
37
+ return {
38
+ currentVersion,
39
+ latestVersion,
40
+ state: "unknown",
41
+ };
42
+ }
43
+ return {
44
+ currentVersion,
45
+ latestVersion,
46
+ state: isVersionNewer(currentVersion, latestVersion) ? "update-available" : "up-to-date",
47
+ };
48
+ }
49
+ function formatUpdateSummaryCard(summary) {
50
+ const statusLabel = summary.state === "update-available"
51
+ ? "update available"
52
+ : summary.state === "up-to-date"
53
+ ? "up to date"
54
+ : "unknown";
55
+ return [
56
+ "┌─ codex-auth update",
57
+ `│ current: ${summary.currentVersion}`,
58
+ `│ latest : ${summary.latestVersion}`,
59
+ `└─ status : ${statusLabel}`,
60
+ ];
61
+ }
62
+ function formatUpdateSummaryInline(summary) {
63
+ if (summary.state === "update-available") {
64
+ return `⬆ Update available: ${summary.currentVersion} -> ${summary.latestVersion}`;
65
+ }
66
+ if (summary.state === "up-to-date") {
67
+ return `✓ Up to date: ${summary.currentVersion}`;
68
+ }
69
+ return `ℹ Update status unknown (current: ${summary.currentVersion}, latest: ${summary.latestVersion})`;
70
+ }
71
+ async function fetchLatestNpmVersion(packageName, timeoutMs = 2500) {
72
+ return new Promise((resolve) => {
73
+ const child = (0, node_child_process_1.spawn)("npm", ["view", packageName, "version", "--json"], {
74
+ stdio: ["ignore", "pipe", "ignore"],
75
+ });
76
+ let output = "";
77
+ const timeout = setTimeout(() => {
78
+ child.kill("SIGTERM");
79
+ resolve(null);
80
+ }, timeoutMs);
81
+ child.stdout.on("data", (chunk) => {
82
+ output += chunk.toString();
83
+ });
84
+ child.on("error", () => {
85
+ clearTimeout(timeout);
86
+ resolve(null);
87
+ });
88
+ child.on("exit", (code) => {
89
+ clearTimeout(timeout);
90
+ if (code !== 0) {
91
+ resolve(null);
92
+ return;
93
+ }
94
+ const trimmed = output.trim();
95
+ if (!trimmed) {
96
+ resolve(null);
97
+ return;
98
+ }
99
+ try {
100
+ const parsed = JSON.parse(trimmed);
101
+ if (typeof parsed === "string" && parsed.trim().length > 0) {
102
+ resolve(parsed.trim());
103
+ return;
104
+ }
105
+ }
106
+ catch {
107
+ // fall through
108
+ }
109
+ resolve(trimmed.replace(/^"+|"+$/g, ""));
110
+ });
111
+ });
112
+ }
113
+ async function runGlobalNpmInstall(packageName, version = "latest") {
114
+ return new Promise((resolve) => {
115
+ const child = (0, node_child_process_1.spawn)("npm", ["i", "-g", `${packageName}@${version}`], {
116
+ stdio: "inherit",
117
+ });
118
+ child.on("error", () => {
119
+ resolve(1);
120
+ });
121
+ child.on("exit", (code) => {
122
+ resolve(typeof code === "number" ? code : 1);
123
+ });
124
+ });
125
+ }
@@ -65,3 +65,17 @@ async function withTempRcFile(t, fn) {
65
65
  strict_1.default.equal(after.rcPath, rcPath);
66
66
  });
67
67
  });
68
+ (0, node_test_1.default)("renderLoginHookBlock includes terminal-mode restore guard", () => {
69
+ const hook = (0, login_hook_1.renderLoginHookBlock)();
70
+ strict_1.default.ok(hook.includes("__codex_auth_restore_tty"));
71
+ strict_1.default.ok(hook.includes("command codex-auth restore-session"));
72
+ strict_1.default.ok(hook.includes("CODEX_AUTH_FORCE_EXTERNAL_SYNC=1 command codex-auth status"));
73
+ strict_1.default.ok(hook.includes("\\033[>4m"));
74
+ strict_1.default.ok(hook.includes("\\033[<u"));
75
+ strict_1.default.ok(hook.includes("\\033[?2026l"));
76
+ strict_1.default.ok(hook.includes("\\033[?1004l"));
77
+ strict_1.default.ok(hook.includes("\\033[?2004l"));
78
+ strict_1.default.ok(hook.includes("\\033[0m"));
79
+ strict_1.default.ok(hook.includes("\\033[?25h"));
80
+ strict_1.default.ok(hook.includes("CODEX_AUTH_SKIP_TTY_RESTORE"));
81
+ });
@@ -19,9 +19,10 @@ function encodeBase64Url(input) {
19
19
  .replace(/=+$/g, "");
20
20
  }
21
21
  function buildAuthPayload(email, options) {
22
- var _a, _b;
22
+ var _a, _b, _c;
23
23
  const accountId = (_a = options === null || options === void 0 ? void 0 : options.accountId) !== null && _a !== void 0 ? _a : "acct-1";
24
24
  const userId = (_b = options === null || options === void 0 ? void 0 : options.userId) !== null && _b !== void 0 ? _b : "user-1";
25
+ const tokenSeed = (_c = options === null || options === void 0 ? void 0 : options.tokenSeed) !== null && _c !== void 0 ? _c : email;
25
26
  const idTokenPayload = {
26
27
  email,
27
28
  "https://api.openai.com/auth": {
@@ -33,8 +34,8 @@ function buildAuthPayload(email, options) {
33
34
  const idToken = `${encodeBase64Url(JSON.stringify({ alg: "none" }))}.${encodeBase64Url(JSON.stringify(idTokenPayload))}.sig`;
34
35
  return JSON.stringify({
35
36
  tokens: {
36
- access_token: `token-${email}`,
37
- refresh_token: `refresh-${email}`,
37
+ access_token: `token-${tokenSeed}`,
38
+ refresh_token: `refresh-${tokenSeed}`,
38
39
  id_token: idToken,
39
40
  account_id: accountId,
40
41
  },
@@ -362,6 +363,34 @@ async function withIsolatedCodexDir(t, fn) {
362
363
  strict_1.default.equal(registry.autoSwitch.enabled, false);
363
364
  });
364
365
  });
366
+ (0, node_test_1.default)("syncExternalAuthSnapshotIfNeeded re-keys active alias to inferred email name when external login identity matches", async (t) => {
367
+ await withIsolatedCodexDir(t, async ({ codexDir, accountsDir, authPath }) => {
368
+ const service = new account_service_1.AccountService();
369
+ const activeAlias = "team-primary";
370
+ const incomingEmail = "admin@kozpontihusbolt.hu";
371
+ const currentPath = node_path_1.default.join(codexDir, "current");
372
+ await promises_1.default.writeFile(node_path_1.default.join(accountsDir, `${activeAlias}.json`), buildAuthPayload(incomingEmail, {
373
+ accountId: "acct-team",
374
+ userId: "user-team",
375
+ tokenSeed: "pre-login",
376
+ }), "utf8");
377
+ await promises_1.default.writeFile(currentPath, `${activeAlias}\n`, "utf8");
378
+ await promises_1.default.writeFile(authPath, buildAuthPayload(incomingEmail, {
379
+ accountId: "acct-team",
380
+ userId: "user-team",
381
+ tokenSeed: "post-login",
382
+ }), "utf8");
383
+ const result = await service.syncExternalAuthSnapshotIfNeeded();
384
+ strict_1.default.deepEqual(result, {
385
+ synchronized: true,
386
+ savedName: incomingEmail,
387
+ autoSwitchDisabled: false,
388
+ });
389
+ strict_1.default.equal((await promises_1.default.readFile(currentPath, "utf8")).trim(), incomingEmail);
390
+ const inferredSnapshot = await (0, auth_parser_1.parseAuthSnapshotFile)(node_path_1.default.join(accountsDir, `${incomingEmail}.json`));
391
+ strict_1.default.equal(inferredSnapshot.email, incomingEmail);
392
+ });
393
+ });
365
394
  (0, node_test_1.default)("syncExternalAuthSnapshotIfNeeded materializes auth symlink so external codex login can no longer overwrite snapshot files", async (t) => {
366
395
  if (process.platform === "win32") {
367
396
  t.skip("symlink conversion behavior is Unix-specific in this test");
@@ -397,3 +426,140 @@ async function withIsolatedCodexDir(t, fn) {
397
426
  strict_1.default.equal(authStat.isSymbolicLink(), false);
398
427
  });
399
428
  });
429
+ (0, node_test_1.default)("getCurrentAccountName prefers session-scoped snapshot over global current pointer", async (t) => {
430
+ await withIsolatedCodexDir(t, async ({ codexDir, accountsDir }) => {
431
+ const service = new account_service_1.AccountService();
432
+ const currentPath = node_path_1.default.join(codexDir, "current");
433
+ const sessionMapPath = node_path_1.default.join(accountsDir, "sessions.json");
434
+ const sessionKey = `ppid:${process.ppid}`;
435
+ await promises_1.default.writeFile(node_path_1.default.join(accountsDir, "odin@megkapja.hu.json"), buildAuthPayload("odin@megkapja.hu", {
436
+ accountId: "acct-odin",
437
+ userId: "user-odin",
438
+ }));
439
+ await promises_1.default.writeFile(node_path_1.default.join(accountsDir, "lajos@edix.hu.json"), buildAuthPayload("lajos@edix.hu", {
440
+ accountId: "acct-lajos",
441
+ userId: "user-lajos",
442
+ }));
443
+ await promises_1.default.writeFile(currentPath, "lajos@edix.hu\n", "utf8");
444
+ await promises_1.default.writeFile(sessionMapPath, `${JSON.stringify({
445
+ version: 1,
446
+ sessions: {
447
+ [sessionKey]: {
448
+ accountName: "odin@megkapja.hu",
449
+ updatedAt: new Date().toISOString(),
450
+ },
451
+ },
452
+ }, null, 2)}\n`, "utf8");
453
+ const active = await service.getCurrentAccountName();
454
+ strict_1.default.equal(active, "odin@megkapja.hu");
455
+ });
456
+ });
457
+ (0, node_test_1.default)("syncExternalAuthSnapshotIfNeeded ignores external login from another terminal when session snapshot differs", async (t) => {
458
+ await withIsolatedCodexDir(t, async ({ codexDir, accountsDir, authPath }) => {
459
+ const service = new account_service_1.AccountService();
460
+ const currentPath = node_path_1.default.join(codexDir, "current");
461
+ const sessionMapPath = node_path_1.default.join(accountsDir, "sessions.json");
462
+ const sessionKey = `ppid:${process.ppid}`;
463
+ await promises_1.default.writeFile(node_path_1.default.join(accountsDir, "odin@megkapja.hu.json"), buildAuthPayload("odin@megkapja.hu", {
464
+ accountId: "acct-odin",
465
+ userId: "user-odin",
466
+ }));
467
+ await promises_1.default.writeFile(currentPath, "odin@megkapja.hu\n", "utf8");
468
+ await promises_1.default.writeFile(sessionMapPath, `${JSON.stringify({
469
+ version: 1,
470
+ sessions: {
471
+ [sessionKey]: {
472
+ accountName: "odin@megkapja.hu",
473
+ updatedAt: new Date().toISOString(),
474
+ },
475
+ },
476
+ }, null, 2)}\n`, "utf8");
477
+ await promises_1.default.writeFile(authPath, buildAuthPayload("lajos@edix.hu", {
478
+ accountId: "acct-lajos",
479
+ userId: "user-lajos",
480
+ }), "utf8");
481
+ const result = await service.syncExternalAuthSnapshotIfNeeded();
482
+ strict_1.default.deepEqual(result, {
483
+ synchronized: false,
484
+ autoSwitchDisabled: false,
485
+ });
486
+ await strict_1.default.rejects(() => promises_1.default.access(node_path_1.default.join(accountsDir, "lajos@edix.hu.json")));
487
+ strict_1.default.equal((await promises_1.default.readFile(currentPath, "utf8")).trim(), "odin@megkapja.hu");
488
+ });
489
+ });
490
+ (0, node_test_1.default)("syncExternalAuthSnapshotIfNeeded can be forced for explicit in-terminal codex login sync", async (t) => {
491
+ await withIsolatedCodexDir(t, async ({ codexDir, accountsDir, authPath }) => {
492
+ const service = new account_service_1.AccountService();
493
+ const currentPath = node_path_1.default.join(codexDir, "current");
494
+ const sessionMapPath = node_path_1.default.join(accountsDir, "sessions.json");
495
+ const sessionKey = `ppid:${process.ppid}`;
496
+ const previousFlag = process.env.CODEX_AUTH_FORCE_EXTERNAL_SYNC;
497
+ await promises_1.default.writeFile(node_path_1.default.join(accountsDir, "odin@megkapja.hu.json"), buildAuthPayload("odin@megkapja.hu", {
498
+ accountId: "acct-odin",
499
+ userId: "user-odin",
500
+ }));
501
+ await promises_1.default.writeFile(currentPath, "odin@megkapja.hu\n", "utf8");
502
+ await promises_1.default.writeFile(sessionMapPath, `${JSON.stringify({
503
+ version: 1,
504
+ sessions: {
505
+ [sessionKey]: {
506
+ accountName: "odin@megkapja.hu",
507
+ updatedAt: new Date().toISOString(),
508
+ },
509
+ },
510
+ }, null, 2)}\n`, "utf8");
511
+ await promises_1.default.writeFile(authPath, buildAuthPayload("lajos@edix.hu", {
512
+ accountId: "acct-lajos",
513
+ userId: "user-lajos",
514
+ }), "utf8");
515
+ process.env.CODEX_AUTH_FORCE_EXTERNAL_SYNC = "1";
516
+ t.after(() => {
517
+ process.env.CODEX_AUTH_FORCE_EXTERNAL_SYNC = previousFlag;
518
+ });
519
+ const result = await service.syncExternalAuthSnapshotIfNeeded();
520
+ strict_1.default.deepEqual(result, {
521
+ synchronized: true,
522
+ savedName: "lajos@edix.hu",
523
+ autoSwitchDisabled: false,
524
+ });
525
+ strict_1.default.equal((await promises_1.default.readFile(currentPath, "utf8")).trim(), "lajos@edix.hu");
526
+ });
527
+ });
528
+ (0, node_test_1.default)("restoreSessionSnapshotIfNeeded re-activates the session-pinned snapshot when auth.json drifts", async (t) => {
529
+ await withIsolatedCodexDir(t, async ({ codexDir, accountsDir, authPath }) => {
530
+ const service = new account_service_1.AccountService();
531
+ const currentPath = node_path_1.default.join(codexDir, "current");
532
+ const sessionMapPath = node_path_1.default.join(accountsDir, "sessions.json");
533
+ const sessionKey = `ppid:${process.ppid}`;
534
+ await promises_1.default.writeFile(node_path_1.default.join(accountsDir, "odin@megkapja.hu.json"), buildAuthPayload("odin@megkapja.hu", {
535
+ accountId: "acct-odin",
536
+ userId: "user-odin",
537
+ }));
538
+ await promises_1.default.writeFile(node_path_1.default.join(accountsDir, "lajos@edix.hu.json"), buildAuthPayload("lajos@edix.hu", {
539
+ accountId: "acct-lajos",
540
+ userId: "user-lajos",
541
+ }));
542
+ await promises_1.default.writeFile(currentPath, "lajos@edix.hu\n", "utf8");
543
+ await promises_1.default.writeFile(sessionMapPath, `${JSON.stringify({
544
+ version: 1,
545
+ sessions: {
546
+ [sessionKey]: {
547
+ accountName: "odin@megkapja.hu",
548
+ updatedAt: new Date().toISOString(),
549
+ },
550
+ },
551
+ }, null, 2)}\n`, "utf8");
552
+ await promises_1.default.writeFile(authPath, buildAuthPayload("lajos@edix.hu", {
553
+ accountId: "acct-lajos",
554
+ userId: "user-lajos",
555
+ }), "utf8");
556
+ const restored = await service.restoreSessionSnapshotIfNeeded();
557
+ strict_1.default.deepEqual(restored, {
558
+ restored: true,
559
+ accountName: "odin@megkapja.hu",
560
+ });
561
+ strict_1.default.equal((await promises_1.default.readFile(currentPath, "utf8")).trim(), "odin@megkapja.hu");
562
+ const parsed = await (0, auth_parser_1.parseAuthSnapshotFile)(authPath);
563
+ strict_1.default.equal(parsed.email, "odin@megkapja.hu");
564
+ });
565
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,67 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const node_test_1 = __importDefault(require("node:test"));
7
+ const strict_1 = __importDefault(require("node:assert/strict"));
8
+ const update_check_1 = require("../lib/update-check");
9
+ (0, node_test_1.default)("parseVersionTriplet parses standard semver triplets", () => {
10
+ strict_1.default.deepEqual((0, update_check_1.parseVersionTriplet)("0.1.9"), [0, 1, 9]);
11
+ strict_1.default.deepEqual((0, update_check_1.parseVersionTriplet)("v1.20.3"), [1, 20, 3]);
12
+ });
13
+ (0, node_test_1.default)("parseVersionTriplet supports pre-release/build suffixes", () => {
14
+ strict_1.default.deepEqual((0, update_check_1.parseVersionTriplet)("1.2.3-beta.1"), [1, 2, 3]);
15
+ strict_1.default.deepEqual((0, update_check_1.parseVersionTriplet)("1.2.3+build"), [1, 2, 3]);
16
+ });
17
+ (0, node_test_1.default)("parseVersionTriplet rejects non-triplet versions", () => {
18
+ strict_1.default.equal((0, update_check_1.parseVersionTriplet)("1.2"), null);
19
+ strict_1.default.equal((0, update_check_1.parseVersionTriplet)("latest"), null);
20
+ });
21
+ (0, node_test_1.default)("isVersionNewer compares semver triplets correctly", () => {
22
+ strict_1.default.equal((0, update_check_1.isVersionNewer)("0.1.8", "0.1.9"), true);
23
+ strict_1.default.equal((0, update_check_1.isVersionNewer)("0.1.9", "0.1.9"), false);
24
+ strict_1.default.equal((0, update_check_1.isVersionNewer)("0.2.0", "0.1.9"), false);
25
+ });
26
+ (0, node_test_1.default)("isVersionNewer returns false when either version is invalid", () => {
27
+ strict_1.default.equal((0, update_check_1.isVersionNewer)("latest", "0.1.9"), false);
28
+ strict_1.default.equal((0, update_check_1.isVersionNewer)("0.1.8", "nightly"), false);
29
+ });
30
+ (0, node_test_1.default)("getUpdateSummary returns update-available state", () => {
31
+ const summary = (0, update_check_1.getUpdateSummary)("0.1.8", "0.1.9");
32
+ strict_1.default.deepEqual(summary, {
33
+ currentVersion: "0.1.8",
34
+ latestVersion: "0.1.9",
35
+ state: "update-available",
36
+ });
37
+ });
38
+ (0, node_test_1.default)("getUpdateSummary returns up-to-date state", () => {
39
+ const summary = (0, update_check_1.getUpdateSummary)("0.1.9", "0.1.9");
40
+ strict_1.default.deepEqual(summary, {
41
+ currentVersion: "0.1.9",
42
+ latestVersion: "0.1.9",
43
+ state: "up-to-date",
44
+ });
45
+ });
46
+ (0, node_test_1.default)("formatUpdateSummaryInline renders human-friendly states", () => {
47
+ strict_1.default.equal((0, update_check_1.formatUpdateSummaryInline)({
48
+ currentVersion: "0.1.8",
49
+ latestVersion: "0.1.9",
50
+ state: "update-available",
51
+ }), "⬆ Update available: 0.1.8 -> 0.1.9");
52
+ strict_1.default.equal((0, update_check_1.formatUpdateSummaryInline)({
53
+ currentVersion: "0.1.9",
54
+ latestVersion: "0.1.9",
55
+ state: "up-to-date",
56
+ }), "✓ Up to date: 0.1.9");
57
+ });
58
+ (0, node_test_1.default)("formatUpdateSummaryCard renders a stable 4-line card", () => {
59
+ const lines = (0, update_check_1.formatUpdateSummaryCard)({
60
+ currentVersion: "0.1.9",
61
+ latestVersion: "0.1.10",
62
+ state: "update-available",
63
+ });
64
+ strict_1.default.equal(lines.length, 4);
65
+ strict_1.default.equal(lines[0], "┌─ codex-auth update");
66
+ strict_1.default.equal(lines[3], "└─ status : update available");
67
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imdeadpool/codex-account-switcher",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
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": {
@@ -52,6 +52,9 @@
52
52
  },
53
53
  "oclif": {
54
54
  "bin": "codex-auth",
55
- "commands": "./dist/commands"
55
+ "commands": "./dist/commands",
56
+ "hooks": {
57
+ "init": "./dist/hooks/init/update-notifier"
58
+ }
56
59
  }
57
60
  }
@@ -22,6 +22,13 @@ function renderHookBlock() {
22
22
  return [
23
23
  MARK_START,
24
24
  "# Auto-sync codex-auth snapshots after successful official `codex login`.",
25
+ "# Also restore common terminal modes to avoid leaked escape sequences after codex exits.",
26
+ "__codex_auth_restore_tty() {",
27
+ " [[ -t 1 ]] || return 0",
28
+ " local __tty_target=/dev/tty",
29
+ " [[ -w \"$__tty_target\" ]] || __tty_target=/dev/stdout",
30
+ " 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
+ "}",
25
32
  "if ! typeset -f codex >/dev/null 2>&1; then",
26
33
  " codex() {",
27
34
  " command codex \"$@\"",
@@ -40,6 +47,9 @@ function renderHookBlock() {
40
47
  " command codex-auth status >/dev/null 2>&1 || true",
41
48
  " fi",
42
49
  " fi",
50
+ " if [[ -z \"${CODEX_AUTH_SKIP_TTY_RESTORE:-}\" ]]; then",
51
+ " __codex_auth_restore_tty",
52
+ " fi",
43
53
  " return $__codex_exit",
44
54
  " }",
45
55
  "fi",