feat(auth): add resume lane diagnostics and recovery

- add runtime-aware resume lane diagnostics and auth backup flows

- warn when account resume uses a different plain ccs continuity lane

- surface lane mismatch guidance in the accounts dashboard, docs, and tests
This commit is contained in:
Tam Nhu Tran committed 2026-04-04 12:17:32 -04:00
1 parent d9f78ad490
commit 2830c2ae9e
23 files changed
+1131 -25

No files matched your search

+18
View File
@@ -21,6 +21,7 @@ import packageJson from '../../package.json';
import {
type CommandContext,
handleCreate,
handleBackup,
handleList,
handleShow,
handleRemove,
@@ -68,6 +69,9 @@ class AuthCommands {
console.log('');
console.log(subheader('Commands'));
console.log(` ${color('create <profile>', 'command')} Create new profile and login`);
console.log(
` ${color('backup <profile>', 'command')} Backup local continuity for an account`
);
console.log(` ${color('list', 'command')} List all saved profiles`);
console.log(` ${color('show <profile>', 'command')} Show profile details`);
console.log(` ${color('remove <profile>', 'command')} Remove saved profile`);
@@ -97,6 +101,12 @@ class AuthCommands {
console.log(` ${dim('# Set work as default')}`);
console.log(` ${color('ccs auth default work', 'command')}`);
console.log('');
console.log(` ${dim('# Backup the local continuity lane for an account or plain ccs')}`);
console.log(` ${color('ccs auth backup work', 'command')}`);
console.log(
` ${color('ccs auth backup default', 'command')} ${dim('# backup plain ccs lane')}`
);
console.log('');
console.log(` ${dim('# Restore original CCS behavior')}`);
console.log(` ${color('ccs auth reset-default', 'command')}`);
console.log('');
@@ -162,6 +172,10 @@ class AuthCommands {
return handleCreate(this.getContext(), args);
}
async handleBackup(args: string[]): Promise<void> {
return handleBackup(this.getContext(), args);
}
/**
* List all profiles - delegates to list-command.ts
*/
@@ -227,6 +241,10 @@ class AuthCommands {
await this.handleList(commandArgs);
break;
case 'backup':
await this.handleBackup(commandArgs);
break;
case 'show':
await this.handleShow(commandArgs);
break;
+139
View File
@@ -0,0 +1,139 @@
import * as fs from 'fs';
import * as path from 'path';
import { initUI, color, dim, fail, info, ok } from '../../utils/ui';
import { exitWithError } from '../../errors';
import { ExitCode } from '../../errors/exit-codes';
import {
createTimestampStamp,
getAuthBackupRoot,
getContinuityArtifactNames,
resolveRuntimePlainCcsResumeLane,
} from '../resume-lane-diagnostics';
import { isAccountContextMetadata, resolveAccountContextPolicy } from '../account-context';
import { CommandContext, parseArgs } from './types';
interface BackupManifest {
target: string;
source_config_dir: string;
created_at: string;
copied: string[];
skipped: string[];
}
function copyDirectoryIfPresent(sourcePath: string, targetPath: string): boolean {
if (!fs.existsSync(sourcePath)) {
return false;
}
fs.cpSync(sourcePath, targetPath, {
recursive: true,
dereference: true,
force: false,
errorOnExist: false,
});
return true;
}
export async function handleBackup(ctx: CommandContext, args: string[]): Promise<void> {
await initUI();
const { profileName, json } = parseArgs(args);
if (!profileName) {
console.log(fail('Profile name is required'));
console.log('');
console.log(`Usage: ${color('ccs auth backup <profile|default> [--json]', 'command')}`);
exitWithError('Profile name is required', ExitCode.PROFILE_ERROR);
}
try {
let sourceConfigDir = '';
let backupLabel = profileName;
let artifactNames: string[] = [];
if (profileName === 'default') {
const lane = await resolveRuntimePlainCcsResumeLane();
sourceConfigDir = lane.configDir;
backupLabel = lane.accountName ? `default-${lane.accountName}` : 'default';
artifactNames = getContinuityArtifactNames('default');
} else {
const profiles = ctx.registry.getAllProfilesMerged();
const profile = profiles[profileName];
if (!profile || profile.type !== 'account') {
exitWithError(
`Backup supports auth accounts or "default". Not found: ${profileName}`,
ExitCode.PROFILE_ERROR
);
}
const contextPolicy = resolveAccountContextPolicy(
isAccountContextMetadata(profile) ? profile : undefined
);
sourceConfigDir = await ctx.instanceMgr.ensureInstance(profileName, contextPolicy, {
bare: profile.bare === true,
});
artifactNames = getContinuityArtifactNames('account');
}
const backupRoot = path.join(getAuthBackupRoot(), backupLabel, createTimestampStamp());
fs.mkdirSync(backupRoot, { recursive: true, mode: 0o700 });
const copied: string[] = [];
const skipped: string[] = [];
for (const artifactName of artifactNames) {
const sourcePath = path.join(sourceConfigDir, artifactName);
const targetPath = path.join(backupRoot, artifactName);
if (copyDirectoryIfPresent(sourcePath, targetPath)) {
copied.push(artifactName);
} else {
skipped.push(artifactName);
}
}
const manifest: BackupManifest = {
target: profileName,
source_config_dir: sourceConfigDir,
created_at: new Date().toISOString(),
copied,
skipped,
};
fs.writeFileSync(
path.join(backupRoot, 'manifest.json'),
`${JSON.stringify(manifest, null, 2)}\n`,
{
encoding: 'utf8',
mode: 0o600,
}
);
if (json) {
console.log(
JSON.stringify(
{
target: profileName,
backup_path: backupRoot,
copied,
skipped,
},
null,
2
)
);
return;
}
console.log(ok(`Continuity backup created: ${backupRoot}`));
console.log('');
console.log(info(`Source lane: ${sourceConfigDir}`));
console.log(` ${dim(`Copied: ${copied.length > 0 ? copied.join(', ') : 'none'}`)}`);
if (skipped.length > 0) {
console.log(` ${dim(`Skipped: ${skipped.join(', ')}`)}`);
}
console.log('');
} catch (error) {
exitWithError(
`Failed to create continuity backup: ${(error as Error).message}`,
ExitCode.GENERAL_ERROR
);
}
}
+1
View File
@@ -16,6 +16,7 @@ export {
// Command handlers
export { handleCreate } from './create-command';
export { handleBackup } from './backup-command';
export { handleList } from './list-command';
export { handleShow } from './show-command';
export { handleRemove } from './remove-command';
+17 -10
View File
@@ -100,6 +100,19 @@ function resolveMappedAccount(
return undefined;
}
export function resolveConfiguredContinuitySourceAccount(
profileName: string,
profileType: ProfileType
): string | undefined {
const inheritFromAccount = getContinuityInheritanceMap();
return (
resolveMappedAccount(profileName, profileType, inheritFromAccount) ??
(!isUnifiedMode()
? resolveMappedAccount(profileName, profileType, loadLegacyContinuityInheritanceMap())
: undefined)
);
}
/**
* Resolve optional cross-profile continuity inheritance.
*
@@ -115,16 +128,10 @@ export async function resolveProfileContinuityInheritance(
return {};
}
const inheritFromAccount = getContinuityInheritanceMap();
const sourceAccount =
resolveMappedAccount(input.profileName, input.profileType, inheritFromAccount) ??
(!isUnifiedMode()
? resolveMappedAccount(
input.profileName,
input.profileType,
loadLegacyContinuityInheritanceMap()
)
: undefined);
const sourceAccount = resolveConfiguredContinuitySourceAccount(
input.profileName,
input.profileType
);
if (!sourceAccount) {
return {};
}
+8
View File
@@ -462,6 +462,14 @@ class ProfileDetector {
};
}
/**
* Public wrapper for diagnostics and tooling that need to inspect how
* plain `ccs` resolves without duplicating the default-profile logic.
*/
resolveDefaultProfileResult(): ProfileDetectionResult {
return this.resolveDefaultProfile();
}
/**
* List available profiles (for error messages)
*/
+163
View File
@@ -0,0 +1,163 @@
import * as fs from 'fs';
import * as path from 'path';
import { getDefaultClaudeConfigDir } from '../utils/claude-config-path';
import { getCcsDir } from '../utils/config-manager';
import InstanceManager from '../management/instance-manager';
import ProfileDetector from './profile-detector';
import { resolveConfiguredContinuitySourceAccount } from './profile-continuity-inheritance';
import type { ProfileType } from '../types/profile';
export type ResumeLaneKind =
| 'native'
| 'account-default'
| 'account-inherited'
| 'profile-default'
| 'ambient';
export interface ResumeLaneSummary {
kind: ResumeLaneKind;
label: string;
configDir: string;
accountName?: string;
profileName?: string;
projectCount: number;
}
export interface ResumeFlagIntent {
implicit: boolean;
explicitSessionId?: string;
}
function countTopLevelProjectDirs(projectsDir: string): number {
try {
return fs
.readdirSync(projectsDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory()).length;
} catch {
return 0;
}
}
function resolveNativeLaneSummary(
kind: ResumeLaneKind = 'native',
profileName?: string
): ResumeLaneSummary {
const configDir = getDefaultClaudeConfigDir();
const label =
kind === 'profile-default' && profileName
? `profile "${profileName}" via native Claude lane`
: 'native Claude lane';
return {
kind,
label,
configDir,
profileName,
projectCount: countTopLevelProjectDirs(path.join(configDir, 'projects')),
};
}
function resolveAccountLaneSummary(
kind: Extract<ResumeLaneKind, 'account-default' | 'account-inherited'>,
accountName: string
): ResumeLaneSummary {
const instanceMgr = new InstanceManager();
const configDir = instanceMgr.getInstancePath(accountName);
return {
kind,
label:
kind === 'account-inherited'
? `plain ccs inherits from account "${accountName}"`
: `plain ccs defaults to account "${accountName}"`,
configDir,
accountName,
projectCount: countTopLevelProjectDirs(path.join(configDir, 'projects')),
};
}
export async function resolveConfiguredPlainCcsResumeLane(): Promise<ResumeLaneSummary> {
const detector = new ProfileDetector();
const defaultProfile = detector.resolveDefaultProfileResult();
if (defaultProfile.type === 'account') {
return resolveAccountLaneSummary('account-default', defaultProfile.name);
}
const inheritedAccount = resolveConfiguredContinuitySourceAccount(
defaultProfile.name,
defaultProfile.type
);
if (inheritedAccount) {
return resolveAccountLaneSummary('account-inherited', inheritedAccount);
}
if (defaultProfile.type !== 'default') {
return resolveNativeLaneSummary('profile-default', defaultProfile.name);
}
return resolveNativeLaneSummary();
}
export async function resolveRuntimePlainCcsResumeLane(): Promise<ResumeLaneSummary> {
if (process.env.CLAUDE_CONFIG_DIR) {
const configDir = path.resolve(process.env.CLAUDE_CONFIG_DIR);
return {
kind: 'ambient',
label: 'current shell CLAUDE_CONFIG_DIR',
configDir,
projectCount: countTopLevelProjectDirs(path.join(configDir, 'projects')),
};
}
return resolveConfiguredPlainCcsResumeLane();
}
export function parseResumeFlagIntent(args: string[]): ResumeFlagIntent | null {
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === '-r') {
return { implicit: true };
}
if (arg === '--resume') {
const next = args[index + 1];
if (next && !next.startsWith('-')) {
return { implicit: false, explicitSessionId: next };
}
return { implicit: true };
}
if (arg.startsWith('--resume=')) {
const sessionId = arg.slice('--resume='.length).trim();
return sessionId ? { implicit: false, explicitSessionId: sessionId } : { implicit: true };
}
}
return null;
}
export function getAuthBackupRoot(): string {
return path.join(getCcsDir(), 'backups', 'auth-continuity');
}
export function createTimestampStamp(date = new Date()): string {
const yyyy = date.getFullYear();
const mm = String(date.getMonth() + 1).padStart(2, '0');
const dd = String(date.getDate()).padStart(2, '0');
const hh = String(date.getHours()).padStart(2, '0');
const min = String(date.getMinutes()).padStart(2, '0');
const ss = String(date.getSeconds()).padStart(2, '0');
return `${yyyy}${mm}${dd}_${hh}${min}${ss}`;
}
export function getContinuityArtifactNames(profileType: ProfileType): string[] {
const sharedItems = ['projects'];
const deeperItems = ['session-env', 'file-history', 'shell-snapshots', 'todos'];
if (profileType === 'default' || profileType === 'account') {
return [...sharedItems, ...deeperItems];
}
return sharedItems;
}