Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
31 changes: 31 additions & 0 deletions packages/api/src/config/catalog-accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,15 +329,46 @@ function migrateProjectAccountsToGlobal(projectRoot: string): void {
}
}

// ── Homedir legacy migration (picks up secrets written by pre-F340 installer without --project-dir) ──

const migratedHomedirLegacy = new Set<string>();

function migrateHomedirLegacyProviderProfiles(projectRoot?: string): void {
const globalRoot = resolveGlobalRoot(projectRoot);
const resolvedTarget = resolve(globalRoot);
if (migratedHomedirLegacy.has(resolvedTarget)) return;
const home = homedir();
if (resolvedTarget === resolve(home)) {
// Global root IS homedir — already covered by migrateLegacyProviderProfiles.
migratedHomedirLegacy.add(resolvedTarget);
return;
}
try {
migrateLegacyFrom(home, projectRoot);
migratedHomedirLegacy.add(resolvedTarget);
} catch (err) {
// Only swallow parse/read errors (corrupt homedir files). Re-throw account
// conflicts and other migration errors so callers get a fail-fast signal.
if (err instanceof SyntaxError || (err instanceof Error && err.message.includes('ENOENT'))) {
console.error('[catalog-accounts] homedir legacy→global migration failed (corrupt source, skipped):', err);
migratedHomedirLegacy.add(resolvedTarget);
} else {
throw err;
}
}
}

function ensureMigrated(projectRoot: string): void {
migrateLegacyProviderProfiles(projectRoot);
migrateProjectLegacyProviderProfiles(projectRoot);
migrateHomedirLegacyProviderProfiles(projectRoot);
migrateProjectAccountsToGlobal(projectRoot);
}

/** Reset migration state (for tests). */
export function resetMigrationState(): void {
legacyMigrationDone = false;
migratedHomedirLegacy.clear();
migratedProjects.clear();
migratedProjectLegacy.clear();
}
Expand Down
10 changes: 9 additions & 1 deletion packages/api/test/account-resolver.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,13 @@ import { afterEach, beforeEach, describe, it } from 'node:test';
describe('account-resolver (4b unified runtime resolution)', () => {
let projectRoot;
let previousGlobalRoot;
const ENV_KEYS_TO_ISOLATE = ['CAT_CAFE_GLOBAL_CONFIG_ROOT', 'ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'GOOGLE_API_KEY'];
const ENV_KEYS_TO_ISOLATE = [
'CAT_CAFE_GLOBAL_CONFIG_ROOT',
'ANTHROPIC_API_KEY',
'OPENAI_API_KEY',
'GOOGLE_API_KEY',
'HOME',
];
const savedEnv = {};

beforeEach(async () => {
Expand All @@ -18,6 +24,8 @@ describe('account-resolver (4b unified runtime resolution)', () => {
delete process.env[key];
}
process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT = projectRoot;
// Isolate homedir so the homedir migration doesn't pick up real ~/.cat-cafe/ files
process.env.HOME = projectRoot;
await mkdir(join(projectRoot, '.cat-cafe'), { recursive: true });
});

Expand Down
5 changes: 5 additions & 0 deletions packages/api/test/account-startup.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,24 @@ describe('accountStartupHook (F340 fail-fast)', () => {
let globalRoot;
let projectRoot;
let previousGlobalRoot;
let previousHome;

beforeEach(async () => {
globalRoot = await mkdtemp(join(tmpdir(), 'acct-startup-'));
projectRoot = await mkdtemp(join(tmpdir(), 'acct-startup-proj-'));
previousGlobalRoot = process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT;
previousHome = process.env.HOME;
process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT = globalRoot;
// Isolate homedir so the homedir migration doesn't pick up real ~/.cat-cafe/ files
process.env.HOME = globalRoot;
await mkdir(join(globalRoot, '.cat-cafe'), { recursive: true });
await mkdir(join(projectRoot, '.cat-cafe'), { recursive: true });
});

afterEach(async () => {
if (previousGlobalRoot === undefined) delete process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT;
else process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT = previousGlobalRoot;
process.env.HOME = previousHome;
await rm(globalRoot, { recursive: true, force: true });
await rm(projectRoot, { recursive: true, force: true });
});
Expand Down
101 changes: 101 additions & 0 deletions packages/api/test/catalog-accounts.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,24 @@ describe('global accounts (F340)', () => {
let globalRoot;
let projectRoot;
let previousGlobalRoot;
let previousHome;

beforeEach(async () => {
globalRoot = await mkdtemp(join(tmpdir(), 'global-accounts-'));
projectRoot = await mkdtemp(join(tmpdir(), 'project-accounts-'));
previousGlobalRoot = process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT;
previousHome = process.env.HOME;
process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT = globalRoot;
// Isolate homedir so the homedir migration doesn't pick up real ~/.cat-cafe/ files
process.env.HOME = globalRoot;
await mkdir(join(globalRoot, '.cat-cafe'), { recursive: true });
await mkdir(join(projectRoot, '.cat-cafe'), { recursive: true });
});

afterEach(async () => {
if (previousGlobalRoot === undefined) delete process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT;
else process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT = previousGlobalRoot;
process.env.HOME = previousHome;
await rm(globalRoot, { recursive: true, force: true });
await rm(projectRoot, { recursive: true, force: true });
});
Expand Down Expand Up @@ -506,4 +511,100 @@ describe('global accounts (F340)', () => {
assert.equal(creds.shared, undefined, 'legacy secret must NOT be attached to different-source api_key account');
}
});

it('migrates legacy credentials from homedir when globalRoot differs from homedir', async () => {
const { readCatalogAccounts, resetMigrationState } = await import('../dist/config/catalog-accounts.js');
resetMigrationState();

// Simulate: CAT_CAFE_GLOBAL_CONFIG_ROOT is unset, projectRoot != homedir.
// Old installer (pre-F340) wrote secrets to homedir when --project-dir was not given.
delete process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT;

const fakeHome = await mkdtemp(join(tmpdir(), 'fake-home-'));
await mkdir(join(fakeHome, '.cat-cafe'), { recursive: true });
const savedHome = process.env.HOME;
process.env.HOME = fakeHome;

try {
// Legacy provider-profiles + secrets in homedir (old installer output)
await writeFile(
join(fakeHome, '.cat-cafe', 'provider-profiles.json'),
JSON.stringify({
version: 2,
providers: [{ id: 'homedir-account', authType: 'api_key', baseUrl: 'https://home.api/v1' }],
}),
'utf-8',
);
await writeFile(
join(fakeHome, '.cat-cafe', 'provider-profiles.secrets.local.json'),
JSON.stringify({ profiles: { 'homedir-account': { apiKey: 'sk-from-homedir' } } }),
'utf-8',
);

// projectRoot is a separate directory — no legacy files there
const result = readCatalogAccounts(projectRoot);
assert.equal(result['homedir-account']?.baseUrl, 'https://home.api/v1', 'account from homedir must be migrated');

// Credentials should be migrated to globalRoot (= projectRoot when env unset)
const credRaw = await readFile(join(projectRoot, '.cat-cafe', 'credentials.json'), 'utf-8');
const creds = JSON.parse(credRaw);
assert.equal(creds['homedir-account']?.apiKey, 'sk-from-homedir', 'API key from homedir must be migrated');
} finally {
process.env.HOME = savedHome;
// Restore env for subsequent tests
process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT = globalRoot;
await rm(fakeHome, { recursive: true, force: true });
}
});

it('migrates homedir credentials to multiple projects in the same process', async () => {
const { readCatalogAccounts, resetMigrationState } = await import('../dist/config/catalog-accounts.js');
resetMigrationState();

delete process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT;

const fakeHome = await mkdtemp(join(tmpdir(), 'fake-home-'));
await mkdir(join(fakeHome, '.cat-cafe'), { recursive: true });
const projectB = await mkdtemp(join(tmpdir(), 'project-b-'));
await mkdir(join(projectB, '.cat-cafe'), { recursive: true });
const savedHome = process.env.HOME;
process.env.HOME = fakeHome;

try {
await writeFile(
join(fakeHome, '.cat-cafe', 'provider-profiles.json'),
JSON.stringify({
version: 2,
providers: [{ id: 'homedir-account', authType: 'api_key', baseUrl: 'https://home.api/v1' }],
}),
'utf-8',
);
await writeFile(
join(fakeHome, '.cat-cafe', 'provider-profiles.secrets.local.json'),
JSON.stringify({ profiles: { 'homedir-account': { apiKey: 'sk-from-homedir' } } }),
'utf-8',
);

// First project migrates successfully
const resultA = readCatalogAccounts(projectRoot);
assert.equal(resultA['homedir-account']?.baseUrl, 'https://home.api/v1', 'projectA must get homedir account');

// Second project must ALSO get the homedir credentials (not skipped by boolean cache)
const resultB = readCatalogAccounts(projectB);
assert.equal(
resultB['homedir-account']?.baseUrl,
'https://home.api/v1',
'projectB must also get homedir account',
);

const credRawB = await readFile(join(projectB, '.cat-cafe', 'credentials.json'), 'utf-8');
const credsB = JSON.parse(credRawB);
assert.equal(credsB['homedir-account']?.apiKey, 'sk-from-homedir', 'projectB must also get homedir API key');
} finally {
process.env.HOME = savedHome;
process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT = globalRoot;
await rm(fakeHome, { recursive: true, force: true });
await rm(projectB, { recursive: true, force: true });
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,7 @@ describe('ProjectSetupCard IME guard', () => {

await act(async () => {
root.render(
<ProjectSetupCard
projectPath="/tmp/demo"
isEmptyDir
isGitRepo={false}
gitAvailable
onComplete={onComplete}
/>,
<ProjectSetupCard projectPath="/tmp/demo" isEmptyDir isGitRepo={false} gitAvailable onComplete={onComplete} />,
);
});

Expand Down
24 changes: 18 additions & 6 deletions scripts/install-auth-config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,21 @@ function migrateLegacyProfiles(projectDir) {
}
}

/** Run all legacy migration sources: projectDir, globalRoot, and homedir (if different). */
function migrateAllLegacySources(projectDir) {
if (projectDir) migrateLegacyProfiles(projectDir);
migrateLegacyProfiles(null); // reads from globalDir() = resolveGlobalRoot()
// Also try homedir: pre-F340 installer without --project-dir wrote there.
const home = homedir();
if (path.resolve(resolveGlobalRoot()) !== path.resolve(home)) {
try {
migrateLegacyProfiles(home);
} catch {
// Best-effort: don't block operation if homedir legacy files are corrupt.
}
}
}

// ── Commands ──

function setClientAuth(client, mode, options) {
Expand Down Expand Up @@ -453,8 +468,7 @@ try {
// Migrate legacy files before applying
const projDir = getOptional(values, 'project-dir', '');
_activeProjectDir = projDir;
if (projDir) migrateLegacyProfiles(projDir);
migrateLegacyProfiles(null);
migrateAllLegacySources(projDir);
const mode = getRequired(values, 'mode');
if (mode === 'oauth') {
setClientAuth(client, 'oauth', {});
Expand Down Expand Up @@ -486,8 +500,7 @@ try {
const projectDir = getRequired(values, 'project-dir');
_activeProjectDir = projectDir;
// Migrate legacy files before removal so accounts/credentials are in global store
if (projectDir) migrateLegacyProfiles(projectDir);
migrateLegacyProfiles(null);
migrateAllLegacySources(projectDir);
const force = values.get('force')?.[0] === 'true';
removeClientAuth(client, `installer-${client}`, projectDir, { force });
process.exit(0);
Expand All @@ -497,8 +510,7 @@ try {
const projectDir = getOptional(values, 'project-dir', '');
_activeProjectDir = projectDir;
// Migrate legacy files before applying new setting
if (projectDir) migrateLegacyProfiles(projectDir);
migrateLegacyProfiles(null);
migrateAllLegacySources(projectDir);
const apiKey = getOptional(values, 'api-key', '') || process.env._INSTALLER_API_KEY || '';
if (!apiKey) {
console.error('Error: API key required via --api-key or _INSTALLER_API_KEY env var');
Expand Down
Loading