Foundation for the multi-account epic (T-476): a Flutter-free AccountRegistry
over the app-scope SettingsStore (per-user, never committed to a repo).
- `app.claude.accounts` — the {name, dir} account list (dir = CLAUDE_CONFIG_DIR).
- `app.claude.account.<hash>` — per-workspace binding to an account name; the
hash is the SAME FNV-1a 64-bit hex D-70 uses for the socket path, trailing
separators stripped so /repo and /repo/ map alike.
- accountForWorkspace(cwd) resolves an account or null (a binding to a removed
account degrades to null = Claude's default, never errors).
- probeExistingAccountDirs(home): read-only bootstrap probe for adoptable
~/.claude-* config dirs (welcome-view UX is T-481).
Keys are app.-prefixed because SettingsStore requires a scope prefix; app scope
already provides the per-user persistence T-483 wants (T-356 will consolidate
more state later but isn't a hard blocker). No spawn/UI/CLI — downstream
tickets. Unit tests cover CRUD, the disk round-trip, hash/trailing-slash
mapping, independent bindings, and probe filtering.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
124 lines
5.6 KiB
Dart
124 lines
5.6 KiB
Dart
/// T-483: AccountRegistry — user-scope storage + typed reader/writer for the
|
|
/// per-repo Claude account epic (T-476). Persistence flows through a real
|
|
/// SettingsStore over a temp appDir, so these cover the round-trip (incl. a
|
|
/// reload from disk) without mocking the store.
|
|
library;
|
|
|
|
import 'dart:io';
|
|
|
|
import 'package:clide/builtin/claude/src/account_registry.dart';
|
|
import 'package:clide/kernel/src/settings.dart';
|
|
import 'package:clide/src/ipc/paths.dart' show canonicalWorkspaceKey, fnv1a64Hex;
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
void main() {
|
|
late Directory appDir;
|
|
late SettingsStore store;
|
|
late AccountRegistry reg;
|
|
|
|
setUp(() async {
|
|
appDir = await Directory.systemTemp.createTemp('clide-accounts-');
|
|
store = SettingsStore(appDir: appDir);
|
|
await store.load();
|
|
reg = AccountRegistry(store);
|
|
});
|
|
tearDown(() {
|
|
store.dispose();
|
|
if (appDir.existsSync()) appDir.deleteSync(recursive: true);
|
|
});
|
|
|
|
group('account registry CRUD', () {
|
|
test('register + read back; replace by name; remove', () async {
|
|
expect(reg.accounts, isEmpty);
|
|
await reg.registerAccount('personal', '/home/u/.claude-personal');
|
|
await reg.registerAccount('work', '/home/u/.claude-work');
|
|
expect(reg.accounts, [const Account(name: 'personal', dir: '/home/u/.claude-personal'), const Account(name: 'work', dir: '/home/u/.claude-work')]);
|
|
expect(reg.accountByName('work')?.dir, '/home/u/.claude-work');
|
|
expect(reg.accountByName('nope'), isNull);
|
|
|
|
// Re-register by the same name replaces (no duplicate).
|
|
await reg.registerAccount('personal', '/home/u/.claude-personal2');
|
|
expect(reg.accounts.where((a) => a.name == 'personal'), hasLength(1));
|
|
expect(reg.accountByName('personal')?.dir, '/home/u/.claude-personal2');
|
|
|
|
await reg.removeAccount('personal');
|
|
expect(reg.accountByName('personal'), isNull);
|
|
expect(reg.accounts, hasLength(1));
|
|
});
|
|
|
|
test('the accounts list survives a reload from disk', () async {
|
|
await reg.registerAccount('work', '/home/u/.claude-work');
|
|
// Fresh store over the same appDir, loaded from the YAML on disk.
|
|
final store2 = SettingsStore(appDir: appDir);
|
|
await store2.load();
|
|
addTearDown(store2.dispose);
|
|
final reg2 = AccountRegistry(store2);
|
|
expect(reg2.accounts, [const Account(name: 'work', dir: '/home/u/.claude-work')]);
|
|
});
|
|
});
|
|
|
|
group('workspace binding', () {
|
|
test('bind resolves an account; unbind clears it; unknown name → null', () async {
|
|
await reg.registerAccount('work', '/home/u/.claude-work');
|
|
expect(reg.accountForWorkspace('/repo/a'), isNull);
|
|
|
|
await reg.bindWorkspace('/repo/a', 'work');
|
|
expect(reg.accountForWorkspace('/repo/a')?.dir, '/home/u/.claude-work');
|
|
|
|
// A binding to a removed account degrades to null (Claude default).
|
|
await reg.removeAccount('work');
|
|
expect(reg.accountForWorkspace('/repo/a'), isNull);
|
|
|
|
await reg.registerAccount('work', '/home/u/.claude-work');
|
|
expect(reg.accountForWorkspace('/repo/a')?.dir, '/home/u/.claude-work');
|
|
await reg.unbindWorkspace('/repo/a');
|
|
expect(reg.accountForWorkspace('/repo/a'), isNull);
|
|
});
|
|
|
|
test('workspace hash is the D-70 socket hash and ignores trailing slashes', () {
|
|
expect(AccountRegistry.workspaceHash('/repo/a'), AccountRegistry.workspaceHash('/repo/a/'));
|
|
expect(AccountRegistry.workspaceHash('/repo/a'), AccountRegistry.workspaceHash('/repo/a///'));
|
|
expect(AccountRegistry.workspaceHash('/repo/a'), isNot(AccountRegistry.workspaceHash('/repo/b')));
|
|
// Same algorithm/input as the socket path (single source of truth, D-70).
|
|
expect(AccountRegistry.workspaceHash('/repo/a'), fnv1a64Hex(canonicalWorkspaceKey('/repo/a')));
|
|
// The binding key is namespaced under app.claude.account.<hash>.
|
|
expect(AccountRegistry.bindingKey('/repo/a'), 'app.claude.account.${AccountRegistry.workspaceHash('/repo/a')}');
|
|
});
|
|
|
|
test('two workspaces bind independently', () async {
|
|
await reg.registerAccount('personal', '/p');
|
|
await reg.registerAccount('work', '/w');
|
|
await reg.bindWorkspace('/repo/a', 'personal');
|
|
await reg.bindWorkspace('/repo/b', 'work');
|
|
expect(reg.accountForWorkspace('/repo/a')?.name, 'personal');
|
|
expect(reg.accountForWorkspace('/repo/b')?.name, 'work');
|
|
});
|
|
});
|
|
|
|
group('bootstrap probe', () {
|
|
test('returns ~/.claude-* dirs that look like config dirs, skipping the rest', () async {
|
|
final home = await Directory.systemTemp.createTemp('clide-home-');
|
|
addTearDown(() => home.deleteSync(recursive: true));
|
|
// A config dir marked by .claude.json.
|
|
Directory('${home.path}/.claude-personal').createSync();
|
|
File('${home.path}/.claude-personal/.claude.json').writeAsStringSync('{}');
|
|
// A config dir marked by sessions/.
|
|
Directory('${home.path}/.claude-work/sessions').createSync(recursive: true);
|
|
// A .claude-* dir that is NOT a config dir (no marker) — skipped.
|
|
Directory('${home.path}/.claude-empty').createSync();
|
|
// The real ~/.claude (no `-suffix`) — not a candidate.
|
|
Directory('${home.path}/.claude').createSync();
|
|
// An unrelated dir — skipped.
|
|
Directory('${home.path}/projects').createSync();
|
|
|
|
final found = probeExistingAccountDirs(home.path);
|
|
expect(found.map((d) => d.name), ['personal', 'work']);
|
|
expect(found.first.dir, '${home.path}/.claude-personal');
|
|
});
|
|
|
|
test('missing home returns empty, mutates nothing', () {
|
|
expect(probeExistingAccountDirs('/no/such/home/${DateTime.now().microsecondsSinceEpoch}'), isEmpty);
|
|
});
|
|
});
|
|
}
|