mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-14 08:18:04 +00:00
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:
1 parent
d9f78ad490
commit
2830c2ae9e
23 files changed
+1131
-25
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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 {};
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+43
@@ -1,6 +1,7 @@
|
||||
import './utils/fetch-proxy-setup';
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { detectClaudeCli } from './utils/claude-detector';
|
||||
import {
|
||||
getSettingsPath,
|
||||
@@ -64,6 +65,10 @@ import { tryHandleRootCommand } from './commands/root-command-router';
|
||||
// Import extracted utility functions
|
||||
import { execClaude } from './utils/shell-executor';
|
||||
import { isDeprecatedGlmtProfileName, normalizeDeprecatedGlmtEnv } from './utils/glmt-deprecation';
|
||||
import {
|
||||
parseResumeFlagIntent,
|
||||
resolveRuntimePlainCcsResumeLane,
|
||||
} from './auth/resume-lane-diagnostics';
|
||||
|
||||
// Import target adapter system
|
||||
import {
|
||||
@@ -259,6 +264,43 @@ function shouldPassthroughNativeCodexFlagCommand(args: string[]): boolean {
|
||||
return getNativeCodexPassthroughArgs(args) !== null;
|
||||
}
|
||||
|
||||
async function maybeWarnAboutResumeLaneMismatch(
|
||||
profileName: string,
|
||||
accountConfigDir: string,
|
||||
args: string[]
|
||||
): Promise<void> {
|
||||
const resumeIntent = parseResumeFlagIntent(args);
|
||||
if (!resumeIntent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const plainLane = await resolveRuntimePlainCcsResumeLane();
|
||||
if (path.resolve(plainLane.configDir) === path.resolve(accountConfigDir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(
|
||||
warn(
|
||||
`Resume for account "${profileName}" will search that account lane, not the current plain ccs lane.`
|
||||
)
|
||||
);
|
||||
console.error(info(` Account lane: ${accountConfigDir}`));
|
||||
console.error(info(` Plain ccs lane: ${plainLane.label} (${plainLane.configDir})`));
|
||||
if (resumeIntent.explicitSessionId) {
|
||||
console.error(
|
||||
info(
|
||||
` This explicit session ID may have been created in a different lane, so Claude may not find it here.`
|
||||
)
|
||||
);
|
||||
}
|
||||
console.error(info(` Recover the original lane first: ccs -r`));
|
||||
console.error(info(` Back it up before changing setup: ccs auth backup default`));
|
||||
console.error(
|
||||
info(` For future work, align plain ccs with this account: ccs auth default ${profileName}`)
|
||||
);
|
||||
console.error('');
|
||||
}
|
||||
|
||||
function getNativeCodexPassthroughArgs(args: string[]): string[] | null {
|
||||
const targetArgs = stripTargetFlag(args);
|
||||
if (resolveTargetType(args) !== 'codex' || targetArgs.length === 0) {
|
||||
@@ -1269,6 +1311,7 @@ async function main(): Promise<void> {
|
||||
CCS_WEBSEARCH_SKIP: '1',
|
||||
CCS_IMAGE_ANALYSIS_SKIP: '1',
|
||||
};
|
||||
await maybeWarnAboutResumeLaneMismatch(profileInfo.name, instancePath, remainingArgs);
|
||||
const launchArgs = resolveNativeClaudeLaunchArgs(remainingArgs, 'account', instancePath);
|
||||
execClaude(claudeCli, launchArgs, envVars);
|
||||
} else {
|
||||
|
||||
@@ -215,6 +215,7 @@ export const ROOT_COMMAND_FLAGS = [
|
||||
] as const;
|
||||
export const AUTH_SUBCOMMANDS = [
|
||||
'create',
|
||||
'backup',
|
||||
'list',
|
||||
'show',
|
||||
'remove',
|
||||
|
||||
@@ -94,6 +94,8 @@ function getSuggestionsForCommand(tokensBeforeCurrent: string[]): CompletionSugg
|
||||
return completeSubcommands(ROOT_HELP_TOPICS.map((topic) => topic.name));
|
||||
case 'auth':
|
||||
if (!subcommand) return completeSubcommands(AUTH_SUBCOMMANDS);
|
||||
if (subcommand === 'backup')
|
||||
return completeSubcommands(['default', ...getProfileNames('accounts')], ['--json']);
|
||||
if (subcommand === 'show')
|
||||
return completeSubcommands(getProfileNames('accounts'), ['--json']);
|
||||
if (subcommand === 'remove')
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
type MergedAccountEntry,
|
||||
} from './account-route-helpers';
|
||||
import type { AccountConfig } from '../../config/unified-config-types';
|
||||
import { resolveConfiguredPlainCcsResumeLane } from '../../auth/resume-lane-diagnostics';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -59,7 +60,7 @@ function hasAuthAccount(name: string): boolean {
|
||||
/**
|
||||
* GET /api/accounts - List accounts from both profiles.json and config.yaml
|
||||
*/
|
||||
router.get('/', (_req: Request, res: Response): void => {
|
||||
router.get('/', async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const registry = createProfileRegistry();
|
||||
|
||||
@@ -147,8 +148,19 @@ router.get('/', (_req: Request, res: Response): void => {
|
||||
|
||||
// Get default from unified config first, fallback to legacy
|
||||
const defaultProfile = registry.getDefaultUnified() ?? registry.getDefaultProfile() ?? null;
|
||||
const plainCcsLane = await resolveConfiguredPlainCcsResumeLane();
|
||||
|
||||
res.json({ accounts, default: defaultProfile });
|
||||
res.json({
|
||||
accounts,
|
||||
default: defaultProfile,
|
||||
plain_ccs_lane: {
|
||||
kind: plainCcsLane.kind,
|
||||
label: plainCcsLane.label,
|
||||
account_name: plainCcsLane.accountName ?? null,
|
||||
profile_name: plainCcsLane.profileName ?? null,
|
||||
project_count: plainCcsLane.projectCount,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user