mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-12 22:50:48 +01:00
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Argon2id ran on every CLI/MCP call; the derived key is now cached owner-only in the data dir. Todo: fda71ed1 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
249 lines
8.6 KiB
Dart
249 lines
8.6 KiB
Dart
/// 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<String, String> 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<String> claudeNodeIds(Map<String, String> 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<CliSession> 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<SyncResult> 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<List<int>?> _readCachedKey() async {
|
|
final file = _keyFile;
|
|
if (!file.existsSync()) return null;
|
|
try {
|
|
return base64.decode((await file.readAsString()).trim());
|
|
} on FormatException {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
Future<void> _writeCachedKey(List<int> 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<String?> _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<void> 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<String> _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;
|
|
}
|
|
}
|