mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-19 20:14:43 +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
@@ -0,0 +1,99 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import ProfileRegistry from '../../../src/auth/profile-registry';
|
||||
import { InstanceManager } from '../../../src/management/instance-manager';
|
||||
import { handleBackup } from '../../../src/auth/commands/backup-command';
|
||||
|
||||
describe('auth backup command', () => {
|
||||
let tempHome = '';
|
||||
let originalCcsHome: string | undefined;
|
||||
let originalUnified: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-auth-backup-'));
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
originalUnified = process.env.CCS_UNIFIED_CONFIG;
|
||||
process.env.CCS_HOME = tempHome;
|
||||
process.env.CCS_UNIFIED_CONFIG = '1';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
|
||||
else delete process.env.CCS_HOME;
|
||||
|
||||
if (originalUnified !== undefined) process.env.CCS_UNIFIED_CONFIG = originalUnified;
|
||||
else delete process.env.CCS_UNIFIED_CONFIG;
|
||||
|
||||
if (tempHome && fs.existsSync(tempHome)) {
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function writeConfig(): void {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'config.yaml'),
|
||||
[
|
||||
'version: 12',
|
||||
'accounts:',
|
||||
' work:',
|
||||
' created: "2026-04-04T00:00:00.000Z"',
|
||||
' context_mode: shared',
|
||||
' context_group: default',
|
||||
' continuity_mode: deeper',
|
||||
'profiles: {}',
|
||||
'cliproxy:',
|
||||
' oauth_accounts: {}',
|
||||
' providers: {}',
|
||||
' variants: {}',
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
}
|
||||
|
||||
it('creates a JSON backup for an auth account continuity lane', async () => {
|
||||
writeConfig();
|
||||
const instanceMgr = new InstanceManager();
|
||||
const registry = new ProfileRegistry();
|
||||
const instancePath = await instanceMgr.ensureInstance('work', {
|
||||
mode: 'shared',
|
||||
group: 'default',
|
||||
continuityMode: 'deeper',
|
||||
});
|
||||
|
||||
fs.mkdirSync(path.join(instancePath, 'projects', 'demo-project'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(instancePath, 'projects', 'demo-project', 'history.jsonl'),
|
||||
'{}\n',
|
||||
'utf8'
|
||||
);
|
||||
fs.writeFileSync(path.join(instancePath, 'todos', 'todo.md'), '- keep this\n', 'utf8');
|
||||
|
||||
const consoleSpy = spyOn(console, 'log').mockImplementation(() => {});
|
||||
try {
|
||||
await handleBackup(
|
||||
{
|
||||
registry,
|
||||
instanceMgr,
|
||||
version: 'test',
|
||||
},
|
||||
['work', '--json']
|
||||
);
|
||||
} finally {
|
||||
consoleSpy.mockRestore();
|
||||
}
|
||||
|
||||
const backupBase = path.join(tempHome, '.ccs', 'backups', 'auth-continuity', 'work');
|
||||
const [latestBackupDir] = fs.readdirSync(backupBase).sort().reverse();
|
||||
expect(latestBackupDir).toBeTruthy();
|
||||
const backupPath = path.join(backupBase, latestBackupDir as string);
|
||||
expect(fs.existsSync(path.join(backupPath, 'manifest.json'))).toBe(true);
|
||||
expect(
|
||||
fs.existsSync(path.join(backupPath, 'projects', 'demo-project', 'history.jsonl'))
|
||||
).toBe(true);
|
||||
expect(fs.existsSync(path.join(backupPath, 'todos', 'todo.md'))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
parseResumeFlagIntent,
|
||||
resolveConfiguredPlainCcsResumeLane,
|
||||
} from '../../../src/auth/resume-lane-diagnostics';
|
||||
|
||||
describe('resume lane diagnostics', () => {
|
||||
let tempHome = '';
|
||||
let originalCcsHome: string | undefined;
|
||||
let originalUnified: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-resume-lane-'));
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
originalUnified = process.env.CCS_UNIFIED_CONFIG;
|
||||
process.env.CCS_HOME = tempHome;
|
||||
process.env.CCS_UNIFIED_CONFIG = '1';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
|
||||
else delete process.env.CCS_HOME;
|
||||
|
||||
if (originalUnified !== undefined) process.env.CCS_UNIFIED_CONFIG = originalUnified;
|
||||
else delete process.env.CCS_UNIFIED_CONFIG;
|
||||
|
||||
if (tempHome && fs.existsSync(tempHome)) {
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function writeConfig(lines: string[]): void {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(ccsDir, 'config.yaml'), `${lines.join('\n')}\n`, 'utf8');
|
||||
}
|
||||
|
||||
it('parses implicit and explicit resume flags', () => {
|
||||
expect(parseResumeFlagIntent(['-r'])).toEqual({ implicit: true });
|
||||
expect(parseResumeFlagIntent(['--resume'])).toEqual({ implicit: true });
|
||||
expect(parseResumeFlagIntent(['--resume', 'abc123'])).toEqual({
|
||||
implicit: false,
|
||||
explicitSessionId: 'abc123',
|
||||
});
|
||||
expect(parseResumeFlagIntent(['--resume=xyz'])).toEqual({
|
||||
implicit: false,
|
||||
explicitSessionId: 'xyz',
|
||||
});
|
||||
expect(parseResumeFlagIntent(['hello'])).toBeNull();
|
||||
});
|
||||
|
||||
it('defaults to the native lane when no account/default mapping exists', async () => {
|
||||
writeConfig([
|
||||
'version: 12',
|
||||
'accounts: {}',
|
||||
'profiles: {}',
|
||||
'cliproxy:',
|
||||
' oauth_accounts: {}',
|
||||
' providers: {}',
|
||||
' variants: {}',
|
||||
]);
|
||||
|
||||
const lane = await resolveConfiguredPlainCcsResumeLane();
|
||||
expect(lane.kind).toBe('native');
|
||||
expect(lane.configDir).toBe(path.join(tempHome, '.claude'));
|
||||
});
|
||||
|
||||
it('resolves the plain ccs lane to a default account when configured', async () => {
|
||||
writeConfig([
|
||||
'version: 12',
|
||||
'default: work',
|
||||
'accounts:',
|
||||
' work:',
|
||||
' created: "2026-04-04T00:00:00.000Z"',
|
||||
' context_mode: shared',
|
||||
' context_group: default',
|
||||
' continuity_mode: deeper',
|
||||
'profiles: {}',
|
||||
'cliproxy:',
|
||||
' oauth_accounts: {}',
|
||||
' providers: {}',
|
||||
' variants: {}',
|
||||
]);
|
||||
|
||||
const lane = await resolveConfiguredPlainCcsResumeLane();
|
||||
expect(lane.kind).toBe('account-default');
|
||||
expect(lane.accountName).toBe('work');
|
||||
expect(lane.configDir).toBe(path.join(tempHome, '.ccs', 'instances', 'work'));
|
||||
});
|
||||
|
||||
it('resolves the plain ccs lane to inherited account continuity when configured', async () => {
|
||||
writeConfig([
|
||||
'version: 12',
|
||||
'accounts:',
|
||||
' work:',
|
||||
' created: "2026-04-04T00:00:00.000Z"',
|
||||
' context_mode: shared',
|
||||
' context_group: default',
|
||||
' continuity_mode: deeper',
|
||||
'profiles: {}',
|
||||
'continuity:',
|
||||
' inherit_from_account:',
|
||||
' default: work',
|
||||
'cliproxy:',
|
||||
' oauth_accounts: {}',
|
||||
' providers: {}',
|
||||
' variants: {}',
|
||||
]);
|
||||
|
||||
const lane = await resolveConfiguredPlainCcsResumeLane();
|
||||
expect(lane.kind).toBe('account-inherited');
|
||||
expect(lane.accountName).toBe('work');
|
||||
expect(lane.configDir).toBe(path.join(tempHome, '.ccs', 'instances', 'work'));
|
||||
});
|
||||
|
||||
it('resolves a non-account default profile through continuity inheritance', async () => {
|
||||
writeConfig([
|
||||
'version: 12',
|
||||
'default: glm',
|
||||
'accounts:',
|
||||
' work:',
|
||||
' created: "2026-04-04T00:00:00.000Z"',
|
||||
'profiles:',
|
||||
' glm:',
|
||||
' type: api',
|
||||
' settings: ~/.ccs/glm.settings.json',
|
||||
'continuity:',
|
||||
' inherit_from_account:',
|
||||
' glm: work',
|
||||
'cliproxy:',
|
||||
' oauth_accounts: {}',
|
||||
' providers: {}',
|
||||
' variants: {}',
|
||||
]);
|
||||
|
||||
const lane = await resolveConfiguredPlainCcsResumeLane();
|
||||
expect(lane.kind).toBe('account-inherited');
|
||||
expect(lane.accountName).toBe('work');
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,9 @@ function suggestionValues(
|
||||
current = '',
|
||||
shell: 'bash' | 'fish' | 'powershell' | 'zsh' = 'bash'
|
||||
): string[] {
|
||||
return getCompletionSuggestions({ tokensBeforeCurrent, current, shell }).map((entry) => entry.value);
|
||||
return getCompletionSuggestions({ tokensBeforeCurrent, current, shell }).map(
|
||||
(entry) => entry.value
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -100,7 +102,9 @@ describe('completion backend', () => {
|
||||
|
||||
test('suggests lifecycle subcommands for api', () => {
|
||||
const values = suggestionValues(['api']);
|
||||
expect(values).toEqual(expect.arrayContaining(['create', 'discover', 'copy', 'export', 'import', 'remove']));
|
||||
expect(values).toEqual(
|
||||
expect.arrayContaining(['create', 'discover', 'copy', 'export', 'import', 'remove'])
|
||||
);
|
||||
});
|
||||
|
||||
test('suggests account profiles for auth show', () => {
|
||||
@@ -109,6 +113,13 @@ describe('completion backend', () => {
|
||||
expect(values).toContain('--json');
|
||||
});
|
||||
|
||||
test('suggests default lane and account profiles for auth backup', () => {
|
||||
const values = suggestionValues(['auth', 'backup']);
|
||||
expect(values).toContain('default');
|
||||
expect(values).toContain('work');
|
||||
expect(values).toContain('--json');
|
||||
});
|
||||
|
||||
test('suggests cliproxy variants for variant-scoped commands', () => {
|
||||
const values = suggestionValues(['cliproxy', 'edit']);
|
||||
expect(values).toContain('my-codex');
|
||||
@@ -116,7 +127,9 @@ describe('completion backend', () => {
|
||||
|
||||
test('suggests env format values after the format flag', () => {
|
||||
const values = suggestionValues(['env', '--format']);
|
||||
expect(values).toEqual(expect.arrayContaining(['openai', 'anthropic', 'raw', 'claude-extension']));
|
||||
expect(values).toEqual(
|
||||
expect.arrayContaining(['openai', 'anthropic', 'raw', 'claude-extension'])
|
||||
);
|
||||
});
|
||||
|
||||
test('includes live doctor and cliproxy flags from the shared catalog', () => {
|
||||
|
||||
@@ -164,6 +164,104 @@ describe('web-server account-routes context normalization', () => {
|
||||
expect(work?.continuity_inferred).toBe(true);
|
||||
});
|
||||
|
||||
it('reports the plain ccs lane as native when no account default or inheritance exists', async () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'config.yaml'),
|
||||
[
|
||||
'version: 12',
|
||||
'accounts:',
|
||||
' work:',
|
||||
' created: "2026-02-01T00:00:00.000Z"',
|
||||
'profiles: {}',
|
||||
'cliproxy:',
|
||||
' oauth_accounts: {}',
|
||||
' providers: {}',
|
||||
' variants: {}',
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const payload = await getJson<{
|
||||
plain_ccs_lane?: {
|
||||
kind: string;
|
||||
account_name?: string | null;
|
||||
};
|
||||
}>(baseUrl, '/api/accounts');
|
||||
|
||||
expect(payload.plain_ccs_lane?.kind).toBe('native');
|
||||
expect(payload.plain_ccs_lane?.account_name).toBeNull();
|
||||
});
|
||||
|
||||
it('reports the plain ccs lane as an account when default profile is an auth account', async () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'config.yaml'),
|
||||
[
|
||||
'version: 12',
|
||||
'default: work',
|
||||
'accounts:',
|
||||
' work:',
|
||||
' created: "2026-02-01T00:00:00.000Z"',
|
||||
' context_mode: shared',
|
||||
' context_group: default',
|
||||
' continuity_mode: deeper',
|
||||
'profiles: {}',
|
||||
'cliproxy:',
|
||||
' oauth_accounts: {}',
|
||||
' providers: {}',
|
||||
' variants: {}',
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const payload = await getJson<{
|
||||
plain_ccs_lane?: {
|
||||
kind: string;
|
||||
account_name?: string | null;
|
||||
};
|
||||
}>(baseUrl, '/api/accounts');
|
||||
|
||||
expect(payload.plain_ccs_lane?.kind).toBe('account-default');
|
||||
expect(payload.plain_ccs_lane?.account_name).toBe('work');
|
||||
});
|
||||
|
||||
it('does not initialize an account instance as a side effect of listing accounts', async () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'config.yaml'),
|
||||
[
|
||||
'version: 12',
|
||||
'default: work',
|
||||
'accounts:',
|
||||
' work:',
|
||||
' created: "2026-02-01T00:00:00.000Z"',
|
||||
' context_mode: shared',
|
||||
' context_group: default',
|
||||
' continuity_mode: deeper',
|
||||
'profiles: {}',
|
||||
'cliproxy:',
|
||||
' oauth_accounts: {}',
|
||||
' providers: {}',
|
||||
' variants: {}',
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const instancePath = path.join(ccsDir, 'instances', 'work');
|
||||
expect(fs.existsSync(instancePath)).toBe(false);
|
||||
|
||||
await getJson(baseUrl, '/api/accounts');
|
||||
|
||||
expect(fs.existsSync(instancePath)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not delete metadata when instance deletion fails', async () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
|
||||
Reference in new issue
Block a user