Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ Expected flow:
4. Return to the terminal when the browser step completes.
5. Confirm the account appears in the saved account list.

If interactive `codex auth login` starts with zero saved accounts and recoverable named backups in your `backups/` directory, the login flow will prompt you to restore before opening OAuth. Confirm to launch the existing restore manager; skip to proceed with a fresh login. The prompt is suppressed in non-interactive/fallback flows and after same-session `fresh` or `reset` actions.

Verify the new account:

```bash
Expand Down
2 changes: 2 additions & 0 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ If the account pool is still not usable:
codex auth login
```

If `codex auth login` starts with no saved accounts and named backups are present, you will be prompted to restore before OAuth. This prompt only appears in interactive terminals and is skipped after intentional reset flows.

---

## Verify Install And Routing
Expand Down
6 changes: 6 additions & 0 deletions docs/upgrade.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,12 @@ After source selection, environment variables still override individual setting
For day-to-day operator use, prefer stable overrides documented in [configuration.md](configuration.md).
For maintainer/debug flows, see advanced/internal controls in [development/CONFIG_FIELDS.md](development/CONFIG_FIELDS.md).

### Startup Recovery Prompt

Interactive `codex auth login` now offers named-backup recovery before OAuth only when the session starts with zero saved accounts and at least one recoverable named backup.

The prompt is intentionally skipped in fallback/non-interactive login paths and after same-session `fresh` or `reset` actions so an intentional wipe does not immediately re-offer restore state.

---

## Legacy Compatibility
Expand Down
6 changes: 5 additions & 1 deletion lib/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ export function isNonInteractiveMode(): boolean {
return false;
}

export function isInteractiveLoginMenuAvailable(): boolean {
return !isNonInteractiveMode() && isTTY();
}

export async function promptAddAnotherAccount(
currentCount: number,
): Promise<boolean> {
Expand Down Expand Up @@ -259,7 +263,7 @@ export async function promptLoginMode(
return { mode: "add" };
}

if (!isTTY()) {
if (!isInteractiveLoginMenuAvailable()) {
return promptLoginModeFallback(existingAccounts);
}

Expand Down
250 changes: 201 additions & 49 deletions lib/codex-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@ import {
} from "./auth/auth.js";
import { startLocalOAuthServer } from "./auth/server.js";
import { copyTextToClipboard, openBrowserUrl } from "./auth/browser.js";
import { promptAddAnotherAccount, promptLoginMode, type ExistingAccountInfo } from "./cli.js";
import {
isInteractiveLoginMenuAvailable,
promptAddAnotherAccount,
promptLoginMode,
type ExistingAccountInfo,
} from "./cli.js";
import {
extractAccountEmail,
extractAccountId,
Expand Down Expand Up @@ -57,6 +62,7 @@ import {
} from "./quota-cache.js";
import {
assessNamedBackupRestore,
getActionableNamedBackupRestores,
getNamedBackupsDirectoryPath,
listNamedBackups,
restoreNamedBackup,
Expand Down Expand Up @@ -3817,42 +3823,63 @@ async function handleManageAction(

async function runAuthLogin(): Promise<number> {
setStoragePath(null);
let suppressRecoveryPrompt = false;
let recoveryPromptAttempted = false;
let allowEmptyStorageMenu = false;
let pendingRecoveryState: Awaited<
ReturnType<typeof getActionableNamedBackupRestores>
> | null = null;
let pendingMenuQuotaRefresh: Promise<void> | null = null;
let menuQuotaRefreshStatus: string | undefined;
loginFlow:
while (true) {
while (true) {
const existingStorage = await loadAccounts();
const currentStorage = existingStorage ?? createEmptyAccountStorage();
const displaySettings = await loadDashboardDisplaySettings();
applyUiThemeFromDashboardSettings(displaySettings);
const quotaCache = await loadQuotaCache();
const shouldAutoFetchLimits = displaySettings.menuAutoFetchLimits ?? true;
const showFetchStatus = displaySettings.menuShowFetchStatus ?? true;
const quotaTtlMs = displaySettings.menuQuotaTtlMs ?? DEFAULT_MENU_QUOTA_REFRESH_TTL_MS;
if (shouldAutoFetchLimits && !pendingMenuQuotaRefresh) {
const staleCount = countMenuQuotaRefreshTargets(currentStorage, quotaCache, quotaTtlMs);
if (staleCount > 0) {
if (showFetchStatus) {
menuQuotaRefreshStatus = `${UI_COPY.mainMenu.loadingLimits} [0/${staleCount}]`;
let existingStorage = await loadAccounts();
const canOpenEmptyStorageMenu =
allowEmptyStorageMenu && isInteractiveLoginMenuAvailable();
if (
(existingStorage && existingStorage.accounts.length > 0) ||
canOpenEmptyStorageMenu
) {
const menuAllowsEmptyStorage = canOpenEmptyStorageMenu;
allowEmptyStorageMenu = false;
pendingRecoveryState = null;
while (true) {
existingStorage = await loadAccounts();
if (!existingStorage || existingStorage.accounts.length === 0) {
if (!menuAllowsEmptyStorage) {
break;
}
}
const currentStorage = existingStorage ?? createEmptyAccountStorage();
const displaySettings = await loadDashboardDisplaySettings();
applyUiThemeFromDashboardSettings(displaySettings);
const quotaCache = await loadQuotaCache();
const shouldAutoFetchLimits = displaySettings.menuAutoFetchLimits ?? true;
const showFetchStatus = displaySettings.menuShowFetchStatus ?? true;
const quotaTtlMs = displaySettings.menuQuotaTtlMs ?? DEFAULT_MENU_QUOTA_REFRESH_TTL_MS;
if (shouldAutoFetchLimits && !pendingMenuQuotaRefresh) {
const staleCount = countMenuQuotaRefreshTargets(currentStorage, quotaCache, quotaTtlMs);
if (staleCount > 0) {
if (showFetchStatus) {
menuQuotaRefreshStatus = `${UI_COPY.mainMenu.loadingLimits} [0/${staleCount}]`;
}
pendingMenuQuotaRefresh = refreshQuotaCacheForMenu(
currentStorage,
quotaCache,
quotaTtlMs,
(current, total) => {
if (!showFetchStatus) return;
menuQuotaRefreshStatus = `${UI_COPY.mainMenu.loadingLimits} [${current}/${total}]`;
},
)
.then(() => undefined)
.catch(() => undefined)
.finally(() => {
menuQuotaRefreshStatus = undefined;
pendingMenuQuotaRefresh = null;
});
}
pendingMenuQuotaRefresh = refreshQuotaCacheForMenu(
currentStorage,
quotaCache,
quotaTtlMs,
(current, total) => {
if (!showFetchStatus) return;
menuQuotaRefreshStatus = `${UI_COPY.mainMenu.loadingLimits} [${current}/${total}]`;
},
)
.then(() => undefined)
.catch(() => undefined)
.finally(() => {
menuQuotaRefreshStatus = undefined;
pendingMenuQuotaRefresh = null;
});
}
}
const flaggedStorage = await loadFlaggedAccounts();

const menuResult = await promptLoginMode(
Expand Down Expand Up @@ -3936,6 +3963,7 @@ async function runAuthLogin(): Promise<number> {
} finally {
destructiveActionInFlight = false;
}
suppressRecoveryPrompt = true;
continue;
}
if (menuResult.mode === "reset") {
Expand Down Expand Up @@ -3967,6 +3995,7 @@ async function runAuthLogin(): Promise<number> {
} finally {
destructiveActionInFlight = false;
}
suppressRecoveryPrompt = true;
continue;
}
if (menuResult.mode === "manage") {
Expand All @@ -3984,9 +4013,82 @@ async function runAuthLogin(): Promise<number> {
break;
}
}
}

const refreshedStorage = await loadAccounts();
const existingCount = refreshedStorage?.accounts.length ?? 0;
const canPromptForRecovery =
!suppressRecoveryPrompt &&
!recoveryPromptAttempted &&
existingCount === 0 &&
isInteractiveLoginMenuAvailable();
if (canPromptForRecovery) {
recoveryPromptAttempted = true;
let recoveryState: Awaited<
ReturnType<typeof getActionableNamedBackupRestores>
> | null = pendingRecoveryState;
pendingRecoveryState = null;
if (recoveryState === null) {
let recoveryScanFailed = false;
try {
recoveryState = await getActionableNamedBackupRestores({
currentStorage: refreshedStorage,
});
} catch (error) {
recoveryScanFailed = true;
const errorLabel = getRedactedFilesystemErrorLabel(error);
console.warn(
`Startup recovery scan failed (${errorLabel}). Continuing with OAuth.`,
);
recoveryState = { assessments: [], totalBackups: 0 };
}
if (recoveryState.assessments.length === 0 && !recoveryScanFailed) {
allowEmptyStorageMenu = true;
continue loginFlow;
}
}
if (recoveryState.assessments.length > 0) {
let promptWasShown = false;
try {
const displaySettings = await loadDashboardDisplaySettings();
applyUiThemeFromDashboardSettings(displaySettings);
const backupDir = getNamedBackupsDirectoryPath();
const sample = recoveryState.assessments[0];
if (sample === undefined) {
continue;
}
const backupLabel =
recoveryState.assessments.length === 1
? sample.backup.name
: `${recoveryState.assessments.length} backups`;
promptWasShown = true;
const restoreNow = await confirm(
`Found ${recoveryState.assessments.length} recoverable backup${
recoveryState.assessments.length === 1 ? "" : "s"
} out of ${recoveryState.totalBackups} total (${backupLabel}) in ${backupDir}. Restore now?`,
);
if (restoreNow) {
const restoreResult = await runBackupRestoreManager(
displaySettings,
recoveryState.assessments,
);
if (restoreResult !== "restored") {
pendingRecoveryState = recoveryState;
recoveryPromptAttempted = false;
}
continue;
}
} catch (error) {
if (!promptWasShown) {
recoveryPromptAttempted = false;
}
const errorLabel = getRedactedFilesystemErrorLabel(error);
console.warn(
`Startup recovery prompt failed (${errorLabel}). Continuing with OAuth.`,
);
}
}
}
let forceNewLogin = existingCount > 0;
while (true) {
const tokenResult = await runOAuthFlow(forceNewLogin);
Expand Down Expand Up @@ -4198,26 +4300,74 @@ export async function autoSyncActiveAccountToCodex(): Promise<boolean> {
type BackupMenuAction =
| {
type: "restore";
assessment: Awaited<ReturnType<typeof assessNamedBackupRestore>>;
assessment: BackupRestoreAssessment;
}
| { type: "back" };

async function runBackupRestoreManager(
displaySettings: DashboardDisplaySettings,
): Promise<void> {
const backupDir = getNamedBackupsDirectoryPath();
type BackupRestoreAssessment = Awaited<
ReturnType<typeof assessNamedBackupRestore>
>;

type BackupRestoreManagerResult = "restored" | "dismissed";

function getRedactedFilesystemErrorLabel(error: unknown): string {
const code = (error as NodeJS.ErrnoException).code;
if (typeof code === "string" && code.trim().length > 0) {
return code;
}
if (error instanceof Error && error.name && error.name !== "Error") {
return error.name;
}
return "UNKNOWN";
}

async function loadBackupRestoreManagerAssessments(): Promise<
BackupRestoreAssessment[]
> {
const backups = await listNamedBackups();
if (backups.length === 0) {
console.log(`No named backups found. Place backup files in ${backupDir}.`);
return;
return [];
}

const currentStorage = await loadAccounts();
const assessments = await Promise.all(
backups.map((backup) =>
assessNamedBackupRestore(backup.name, { currentStorage }),
),
);
const assessments: BackupRestoreAssessment[] = [];
for (const backup of backups) {
try {
assessments.push(
await assessNamedBackupRestore(backup.name, { currentStorage }),
);
} catch (error) {
const errorLabel = getRedactedFilesystemErrorLabel(error);
console.warn(
`Failed to assess backup "${backup.name}" in restore manager (${errorLabel}).`,
);
assessments.push({
backup,
currentAccountCount: currentStorage?.accounts.length ?? 0,
mergedAccountCount: null,
imported: null,
skipped: null,
wouldExceedLimit: false,
valid: false,
error: errorLabel,
});
}
}

return assessments;
}

async function runBackupRestoreManager(
displaySettings: DashboardDisplaySettings,
assessmentsOverride?: BackupRestoreAssessment[],
): Promise<BackupRestoreManagerResult> {
const backupDir = getNamedBackupsDirectoryPath();
const assessments =
assessmentsOverride ?? (await loadBackupRestoreManagerAssessments());
if (assessments.length === 0) {
console.log(`No named backups found. Place backup files in ${backupDir}.`);
return "dismissed";
}

const items: MenuItem<BackupMenuAction>[] = assessments.map((assessment) => {
const status =
Expand Down Expand Up @@ -4265,29 +4415,31 @@ async function runBackupRestoreManager(
});

if (!selection || selection.type === "back") {
return;
return "dismissed";
}

const assessment = selection.assessment;
if (!assessment.valid || assessment.wouldExceedLimit) {
console.log(assessment.error ?? "Backup is not eligible for restore.");
return;
return "dismissed";
}

const confirmMessage = `Restore backup "${assessment.backup.name}"? This will merge ${assessment.backup.accountCount ?? 0} account(s) into ${assessment.currentAccountCount} current (${assessment.mergedAccountCount ?? assessment.currentAccountCount} after dedupe).`;
const confirmed = await confirm(confirmMessage);
if (!confirmed) return;
if (!confirmed) return "dismissed";

try {
const result = await restoreNamedBackup(assessment.backup.name);
console.log(
`Restored backup "${assessment.backup.name}". Imported ${result.imported}, skipped ${result.skipped}, total ${result.total}.`,
);
return "restored";
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(
`Restore failed: ${collapseWhitespace(message) || "unknown error"}`,
const errorLabel = getRedactedFilesystemErrorLabel(error);
console.warn(
`Failed to restore backup "${assessment.backup.name}" (${errorLabel}).`,
);
return "dismissed";
}
}

Expand Down
Loading