/// One headless CLI run: a local note log, its node id, and a Firebase client. /// /// Built only from Flutter-free pieces of the app (the [NoteRepository], the /// [SyncService] every device runs, and [kProject]) so `dart run` works with /// no Flutter engine — the same constraint `tool/sync_smoke.dart` lives under. /// The writes are therefore ordinary CRDT changes from one more device, never /// records hand-written into RTDB. library; import 'dart:convert'; import 'dart:io'; import 'package:crdt_sync/crdt_sync.dart'; import 'package:crdt_sync/crdt_sync_io.dart'; import 'package:http/http.dart' as http; import 'package:path/path.dart' as p; import 'package:todo/cli/file_credential_store.dart'; import 'package:todo/cli/pull_only_store.dart'; import 'package:todo/data/note_repository.dart'; import 'package:todo/sync/firebase_project.dart'; import 'package:todo/sync/sync_service.dart'; import 'package:uuid/uuid.dart'; /// Default data dir: `$XDG_DATA_HOME/todo-cli`, else `~/.local/share/todo-cli`. String defaultDataDir(Map env) { final xdg = env['XDG_DATA_HOME'] ?? ''; final base = xdg.isNotEmpty ? xdg : p.join(env['HOME'] ?? '', '.local', 'share'); return p.join(base, 'todo-cli'); } /// Node ids of the installs Claude files notes through: the MCP server and /// the CLI under the same data root as [defaultDataDir]. Missing ones are /// skipped. Set claudeNodeIds(Map env) { final cli = defaultDataDir(env); return { for (final dir in [cli, p.join(p.dirname(cli), 'todo-mcp')]) if (File(p.join(dir, CliSession.nodeIdFileName)) case final f when f.existsSync()) f.readAsStringSync().trim(), }..remove(''); } /// The open state of one CLI invocation. Call [close] when done. class CliSession { /// Wraps already-open parts; [open] is the normal way in. Public so a test /// can subclass the session and script [sync]'s outcome. CliSession( this.dataDir, this.repository, this._firebase, { this.passphraseFile, this.sharedPasswordFile, }); /// The fleet-wide crdt-sync password the Python daemons read too /// (`~/.config/crdt-sync/password`), found from the seed's config root. final File? sharedPasswordFile; /// Where the passphrase is read from besides the environment: next to the /// seeded Firebase session, e.g. `~/.config/todo/e2e_passphrase`. final File? passphraseFile; /// File holding this install's persisted CRDT node id. static const nodeIdFileName = 'node_id'; /// File holding the per-peer revision cache (see [SyncService.stateStore]). static const syncStateFileName = 'sync_state.json'; /// The CLI's private copy of the Firebase session. static const credentialsFileName = 'firebase_auth.json'; /// Where the local log, node id and session copy live. final String dataDir; /// The note store, exactly the class the app uses. final NoteRepository repository; final FirebaseRestClient _firebase; /// Opens (creating on first use) the session rooted at [dataDir]. /// /// [seedCredentials] is todo's desktop session, read once and never written. /// [httpClient] replaces the real network client (tests only). static Future open( String dataDir, { required String seedCredentials, String seedApp = 'todo', http.Client? httpClient, }) async { final dir = Directory(dataDir); await dir.create(recursive: true); // Holds a database-write credential: owner-only, like ~/.config/todo. await Process.run('chmod', ['700', dir.path]); final repository = await NoteRepository.openWith( persistence: FileLogPersistence( File(p.join(dataDir, NoteRepository.logFileName)), ), nodeId: await _nodeId(dataDir), ); final auth = FirebaseTokenProvider( apiKey: kProject.apiKey, httpClient: httpClient, store: FileCredentialStore( file: File(p.join(dataDir, credentialsFileName)), seedFile: File(seedCredentials), ), ); if (!await auth.hasSession()) { await repository.close(); throw FirebaseAuthError( 'no Firebase session: $seedCredentials is missing; re-seed it with ' '`python3 ~/src/utils/crdt-sync/tool/seed_session.py --app $seedApp`', ); } final firebase = FirebaseRestClient( databaseUrl: kProject.databaseUrl, auth: auth, httpClient: httpClient, ); return CliSession( dataDir, repository, firebase, passphraseFile: File( p.join(p.dirname(seedCredentials), e2ePassphraseFileName), ), sharedPasswordFile: File( p.join(p.dirname(p.dirname(seedCredentials)), 'crdt-sync', 'password'), ), ); } /// Pull every peer's log and merge it; with [push], also publish this /// node's merged log and its revision, exactly as a device's sync tick does. /// /// Without [push] nothing is written anywhere remote, and the revision cache /// is bypassed so a read always sees every peer's current log. Future sync({required bool push}) async { final plainService = SyncService( stateStore: push ? PersistedSyncStateStore( FileLogPersistence(File(p.join(dataDir, syncStateFileName))), ) : null, ); final e2e = await openEncryptedStore( store: _remote(push: push), oldPrefix: plainService.notesDir, readPassphrase: _readPassphrase, // Argon2id once per install, not on every CLI/MCP call. keyCache: E2eKeyCache(read: _readCachedKey, write: _writeCachedKey), ); if (!push) { // Read-only during the migration: old builds still write the plaintext // prefix, so merge it, then the encrypted one when this host has a key. final plain = await const SyncService().sync( repository, _remote(push: false), ); if (e2e is! E2eReady) return plain; final layout = E2eLayout.of(plainService.notesDir); return await SyncService( notesDir: layout.prefix, revsPath: layout.revsPath, ).sync(repository, e2e.store); } if (e2e is! E2eReady) { throw StateError( 'sync is locked: put the crdt-sync passphrase in ' '$e2ePassphraseEnv or ${passphraseFile?.path ?? e2ePassphraseFileName}', ); } final layout = E2eLayout.of(plainService.notesDir); return await SyncService( notesDir: layout.prefix, revsPath: layout.revsPath, stateStore: plainService.stateStore, ).sync(repository, e2e.store); } RemoteStore _remote({required bool push}) => push ? _firebase : PullOnlyStore(_firebase); /// Owner-only file in the data dir caching the derived E2E key. static const e2eKeyFileName = 'e2e_key'; File get _keyFile => File(p.join(dataDir, e2eKeyFileName)); Future?> _readCachedKey() async { final file = _keyFile; if (!file.existsSync()) return null; try { return base64.decode((await file.readAsString()).trim()); } on FormatException { return null; } } Future _writeCachedKey(List bytes) async { final file = _keyFile; // Restrict before the key is written, never after. await file.writeAsString(''); await Process.run('chmod', ['600', file.path]); await file.writeAsString(base64.encode(bytes), flush: true); } /// Environment variable holding the shared crdt-sync passphrase. static const e2ePassphraseEnv = 'TODO_E2E_PASSPHRASE'; /// File name of the passphrase, beside the seeded Firebase session. static const e2ePassphraseFileName = 'e2e_passphrase'; Future _readPassphrase() async { final fromEnv = Platform.environment[e2ePassphraseEnv]; if (fromEnv != null && fromEnv.isNotEmpty) return fromEnv; for (final file in [passphraseFile, sharedPasswordFile]) { if (file != null && file.existsSync()) { final text = (await file.readAsString()).trim(); if (text.isNotEmpty) return text; } } return null; } /// Releases the log and the HTTP client. Future close() async { _firebase.close(); await repository.close(); } /// Reads this install's node id, minting and persisting a uuid on first run. /// /// Never a fixed name: two installs sharing an id would overwrite each /// other's file under `todo-sync/notes/` and lose writes. static Future _nodeId(String dataDir) async { final file = File(p.join(dataDir, nodeIdFileName)); if (file.existsSync()) { final stored = (await file.readAsString()).trim(); if (stored.isNotEmpty) return stored; } final minted = const Uuid().v4(); await file.writeAsString('$minted\n'); return minted; } }