Add headless note CLI (bin/todo_note.dart) reusing app CRDT sync
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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-10-16 15:35:46 +02:00
parent f7df3cbbd1
commit 56cb1b3594
15 changed files with 1306 additions and 1 deletions

View File

@ -15,3 +15,25 @@ A few resources to get you started if this is your first Flutter project:
For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev/), which offers tutorials,
samples, guidance on mobile development, and a full API reference.
## Headless note CLI
`bin/todo_note.dart` adds, lists, reads and deletes notes from the PC without
the app, as one more sync node (own `node_id` and CRDT log under
`$XDG_DATA_HOME/todo-cli`, or `~/.local/share/todo-cli`). It needs the desktop
Firebase session at `~/.config/todo/firebase_auth.json` (seed it with
`python3 ~/src/utils/crdt-sync/tool/seed_session.py --app todo`).
```sh
dart run bin/todo_note.dart --text "Title
body" --priority high --status in_progress # prints the new note id
echo "Title" | dart run bin/todo_note.dart # text from stdin
dart run bin/todo_note.dart --list # id, priority, status, title
dart run bin/todo_note.dart --get <id> # full note
dart run bin/todo_note.dart --delete <id>
```
`--list` and `--get` never push; `--data-dir <dir>` selects a throwaway node.
Exit codes: 0 ok, 1 sync/auth failure or note not found, 2 bad arguments.
All logic is in `lib/cli/` (tested in `test/cli/`); `--help` lists every flag.

21
bin/todo_note.dart Normal file
View File

@ -0,0 +1,21 @@
// Headless note CLI: add, list, get or delete a todo note from the PC.
//
// dart run bin/todo_note.dart --text "Title\n\nbody" --priority high
// echo "Title" | dart run bin/todo_note.dart
// dart run bin/todo_note.dart --get <id>
//
// See `kUsage` in lib/cli/cli_args.dart for every flag. All behaviour lives
// in lib/cli/cli_runner.dart, where the tests reach it.
import 'dart:io';
import 'package:todo/cli/cli_runner.dart';
Future<void> main(List<String> argv) async {
exitCode = await runCli(
argv,
env: Platform.environment,
out: stdout,
err: stderr,
readStdin: () => readInput(stdin, hasTerminal: stdin.hasTerminal),
);
}

166
lib/cli/cli_args.dart Normal file
View File

@ -0,0 +1,166 @@
/// Hand-rolled argument parsing for `bin/todo_note.dart`.
///
/// Deliberately not `package:args`: a handful of flags do not justify a new
/// exact-pinned dependency under the freshness gate.
library;
import 'package:todo/data/note.dart';
/// What the CLI was asked to do.
enum CliMode {
/// Create a note and push it.
add,
/// Print every live note, one per line.
list,
/// Print one note in full.
get,
/// Tombstone one note and push the deletion.
delete,
/// Print usage.
help,
}
/// Thrown for a malformed command line; maps to exit code 2.
class UsageError implements Exception {
/// Creates a [UsageError] carrying [message].
UsageError(this.message);
/// What was wrong with the arguments.
final String message;
@override
String toString() => message;
}
/// The parsed command line.
class CliArgs {
/// Creates parsed arguments; see [parse].
const CliArgs({
required this.mode,
this.text,
this.id,
this.priority = Priority.medium,
this.status = Status.todo,
this.dataDir,
});
/// Parses [argv]. Throws [UsageError] on anything it does not understand.
factory CliArgs.parse(List<String> argv) {
String? text;
String? id;
String? dataDir;
var priority = Priority.medium;
var status = Status.todo;
final modes = <CliMode>{};
for (var i = 0; i < argv.length; i++) {
final arg = argv[i];
String value() {
if (i + 1 >= argv.length) throw UsageError('$arg needs a value');
return argv[++i];
}
switch (arg) {
case '-h' || '--help':
modes.add(CliMode.help);
case '--text':
text = value();
case '--priority':
priority = _byName(Priority.values, value(), arg);
case '--status':
status = _byName(Status.values, value(), arg, _statusName);
case '--data-dir':
dataDir = value();
case '--list':
modes.add(CliMode.list);
case '--get':
modes.add(CliMode.get);
id = value();
case '--delete':
modes.add(CliMode.delete);
id = value();
default:
throw UsageError('unknown argument: $arg');
}
}
if (modes.contains(CliMode.help)) return const CliArgs(mode: CliMode.help);
if (modes.length > 1) {
throw UsageError('--list, --get and --delete are mutually exclusive');
}
final mode = modes.isEmpty ? CliMode.add : modes.single;
if (mode != CliMode.add && text != null) {
throw UsageError('--text only applies when adding a note');
}
return CliArgs(
mode: mode,
text: text,
id: id,
priority: priority,
status: status,
dataDir: dataDir,
);
}
/// The selected mode.
final CliMode mode;
/// Note text from `--text`; null means "read stdin" in [CliMode.add].
final String? text;
/// Note id for [CliMode.get] / [CliMode.delete].
final String? id;
/// Priority of a new note.
final Priority priority;
/// Status of a new note.
final Status status;
/// Overrides the default data dir (a second, independent node).
final String? dataDir;
/// The command-line spelling of [status] (`in_progress`, not `inProgress`).
static String statusName(Status status) => _statusName(status);
static String _statusName(Enum value) =>
value == Status.inProgress ? 'in_progress' : value.name;
static T _byName<T extends Enum>(
List<T> values,
String raw,
String flag, [
String Function(Enum) nameOf = _plainName,
]) {
for (final v in values) {
if (nameOf(v) == raw) return v;
}
throw UsageError(
'$flag must be one of ${values.map(nameOf).join('|')}, got "$raw"',
);
}
static String _plainName(Enum value) => value.name;
}
/// Usage text printed by `--help` and after a [UsageError].
const kUsage = r'''
Usage: dart run bin/todo_note.dart [options]
Adds a note to the todo app headlessly: pulls from Firebase RTDB, writes an
ordinary CRDT change as this install's own node, pushes, and prints the id.
--text <markdown> note text (first line = title); default: read stdin
--priority <p> low|medium|high (default medium)
--status <s> todo|in_progress|done|abandoned (default todo)
--list pull, then print live notes: id, priority, status, title
--get <id> pull, then print one note in full (exit 1 if absent)
--delete <id> pull, tombstone the note, push
--data-dir <dir> local log + node id (default
$XDG_DATA_HOME/todo-cli or ~/.local/share/todo-cli)
-h, --help this text
--list and --get never push, so a throwaway --data-dir leaves nothing behind.
Exit codes: 0 ok, 1 sync/auth failure or note not found, 2 bad arguments.''';

186
lib/cli/cli_runner.dart Normal file
View File

@ -0,0 +1,186 @@
/// The body of `bin/todo_note.dart`, with every side effect injected.
///
/// Lives in `lib/` so the tests can drive it with a fake RTDB, a temp data
/// dir and in-memory output sinks; the `bin/` entrypoint only wires the real
/// process streams and environment into [runCli].
library;
import 'dart:convert';
import 'dart:io';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:path/path.dart' as p;
import 'package:todo/cli/cli_args.dart';
import 'package:todo/cli/cli_session.dart';
import 'package:todo/data/note.dart';
import 'package:todo/sync/sync_service.dart';
import 'package:uuid/uuid.dart';
/// Opens a [CliSession]; swapped in tests to point it at a fake backend.
typedef SessionOpener =
Future<CliSession> Function(
String dataDir, {
required String seedCredentials,
});
/// Reads all of [input] as text, or '' when it is an interactive terminal.
Future<String> readInput(
Stream<List<int>> input, {
required bool hasTerminal,
}) async {
if (hasTerminal) return '';
return (await input.transform(utf8.decoder).join()).trimRight();
}
/// Runs the CLI for [argv] and returns its exit code.
Future<int> runCli(
List<String> argv, {
required Map<String, String> env,
required StringSink out,
required StringSink err,
required Future<String> Function() readStdin,
SessionOpener open = CliSession.open,
}) async {
final CliArgs args;
try {
args = CliArgs.parse(argv);
} on UsageError catch (error) {
err
..writeln('error: $error')
..writeln(kUsage);
return 2;
}
if (args.mode == CliMode.help) {
out.writeln(kUsage);
return 0;
}
String? text;
if (args.mode == CliMode.add) {
text = args.text ?? await readStdin();
if (text.trim().isEmpty) {
err.writeln('error: empty note (pass --text or pipe text on stdin)');
return 2;
}
}
CliSession? session;
try {
session = await open(
args.dataDir ?? defaultDataDir(env),
seedCredentials: p.join(
env['HOME'] ?? '',
'.config',
'todo',
'firebase_auth.json',
),
);
final cli = _Cli(session, out, err);
return switch (args.mode) {
CliMode.add => await cli.add(args, text!),
CliMode.list => await cli.list(),
CliMode.get => await cli.get(args.id!),
// Only delete is left: help returned above.
_ => await cli.delete(args.id!),
};
} on RemoteSyncError catch (error) {
err.writeln('error: $error');
return 1;
} on IOException catch (error) {
err.writeln('error: $error');
return 1;
} finally {
await session?.close();
}
}
class _Cli {
_Cli(this.session, this.out, this.err);
final CliSession session;
final StringSink out;
final StringSink err;
Future<int> add(CliArgs args, String text) async {
final now = DateTime.now();
final note = Note(
id: const Uuid().v4(),
text: text,
priority: args.priority,
status: args.status,
createdAt: now,
updatedAt: now,
);
await session.repository.upsert(note);
final result = _report(await session.sync(push: true));
if (!result.pushed) {
err.writeln('error: note saved locally but nothing was pushed');
return 1;
}
out.writeln(note.id);
return 0;
}
Future<int> list() async {
_report(await session.sync(push: false));
for (final note in await session.repository.listNotes()) {
out.writeln(
[
note.id,
note.priority.name,
CliArgs.statusName(note.status),
note.text.split('\n').first,
].join('\t'),
);
}
return 0;
}
Future<int> get(String id) async {
_report(await session.sync(push: false));
final note = await _find(id);
if (note == null) {
err.writeln('error: no live note with id $id');
return 1;
}
out
..writeln('id: ${note.id}')
..writeln('priority: ${note.priority.name}')
..writeln('status: ${CliArgs.statusName(note.status)}')
..writeln('created: ${note.createdAt.toIso8601String()}')
..writeln('updated: ${note.updatedAt.toIso8601String()}')
..writeln('---')
..writeln(note.text);
return 0;
}
Future<int> delete(String id) async {
// Pull first: the note may have been created on another device, and a
// tombstone for an id this node has never seen would only be noise.
_report(await session.sync(push: true));
if (await _find(id) == null) {
err.writeln('error: no live note with id $id');
return 1;
}
await session.repository.delete(id);
if (!_report(await session.sync(push: true)).pushed) {
err.writeln('error: deletion saved locally but nothing was pushed');
return 1;
}
out.writeln('deleted $id');
return 0;
}
Future<Note?> _find(String id) async {
final notes = await session.repository.listNotes();
return notes.where((n) => n.id == id).firstOrNull;
}
/// Surfaces peers that could not be merged instead of dropping them
/// silently.
SyncResult _report(SyncResult result) {
for (final name in result.skippedFiles) {
err.writeln('warning: could not merge peer file $name');
}
return result;
}
}

133
lib/cli/cli_session.dart Normal file
View File

@ -0,0 +1,133 @@
/// 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: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');
}
/// 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);
/// 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,
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 todo`',
);
}
final firebase = FirebaseRestClient(
databaseUrl: kProject.databaseUrl,
auth: auth,
httpClient: httpClient,
);
return CliSession(dataDir, repository, firebase);
}
/// 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}) {
if (!push) {
return const SyncService().sync(repository, PullOnlyStore(_firebase));
}
return SyncService(
stateStore: PersistedSyncStateStore(
FileLogPersistence(File(p.join(dataDir, syncStateFileName))),
),
).sync(repository, _firebase);
}
/// 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;
}
}

View File

@ -0,0 +1,52 @@
/// The headless CLI's Firebase session, kept in a file of its own.
library;
import 'dart:convert';
import 'dart:io';
import 'package:crdt_sync/crdt_sync.dart';
/// A file-backed [FirebaseCredentialStore], seeded once from [seedFile].
///
/// [seedFile] is todo's desktop session (`~/.config/todo/firebase_auth.json`,
/// written by `crdt-sync/tool/seed_session.py --app todo`) and is only ever
/// read. Every write goes to [file], a private copy in the CLI's data dir.
/// That split matters: [FirebaseTokenProvider] clears its store when a refresh
/// token is revoked, and a shared file would let one bad CLI run delete the
/// seed the desktop wrapper provisions itself from.
class FileCredentialStore implements FirebaseCredentialStore {
/// Creates a store that persists to [file], falling back to [seedFile].
FileCredentialStore({required this.file, required this.seedFile});
/// The CLI's own copy, written on every token refresh.
final File file;
/// The read-only seed used until [file] exists.
final File seedFile;
@override
Future<FirebaseCredentials?> load() async {
final source = file.existsSync() ? file : seedFile;
if (!source.existsSync()) return null;
final decoded = jsonDecode(await source.readAsString());
if (decoded is! Map<String, dynamic>) return null;
return FirebaseCredentials.fromJson(decoded);
}
@override
Future<void> save(FirebaseCredentials credentials) async {
await file.parent.create(recursive: true);
final tmp = File('${file.path}.$pid.tmp');
// Owner-only before the token is written, not after: dart:io has no
// chmod, and a umask-default file would briefly expose a DB credential.
await tmp.writeAsString('');
await Process.run('chmod', ['600', tmp.path]);
await tmp.writeAsString(jsonEncode(credentials.toJson()));
await tmp.rename(file.path);
}
@override
Future<void> clear() async {
if (file.existsSync()) await file.delete();
}
}

View File

@ -0,0 +1,44 @@
/// A read-only view of a [RemoteStore], for the CLI's verification modes.
library;
import 'package:crdt_sync/crdt_sync.dart';
/// Delegates every read to [inner] and silently drops every write.
///
/// Lets `--list` / `--get` run the app's own [SyncService] merge path without
/// publishing anything. Without it, each throwaway data dir (a fresh node id)
/// would leave a permanent full-log peer file in RTDB that every device then
/// downloads on every sync.
class PullOnlyStore implements RemoteStore, BulkMapReader {
/// Wraps [inner], which must also be able to read revision maps.
PullOnlyStore(this.inner);
/// The real backend reads go to.
final FirebaseRestClient inner;
@override
Future<List<String>> listDirectory(String path) => inner.listDirectory(path);
@override
Future<String?> getFileText(String path) => inner.getFileText(path);
@override
Future<Map<String, String>> getStringMap(String path) =>
inner.getStringMap(path);
@override
Future<void> putFileText(
String path,
String text, {
required String message,
}) async {}
@override
Future<void> deleteFile(String path, {String message = ''}) async {}
@override
Future<bool> canAccessRemote() => inner.canAccessRemote();
@override
void close() => inner.close();
}

View File

@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.42+1
version: 1.0.43+1
environment:
sdk: ^3.12.2

View File

@ -0,0 +1,80 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:todo/cli/cli_args.dart';
import 'package:todo/data/note.dart';
void main() {
group('CliArgs.parse', () {
test('no arguments means add with defaults', () {
final args = CliArgs.parse([]);
expect(args.mode, CliMode.add);
expect(args.text, isNull);
expect(args.priority, Priority.medium);
expect(args.status, Status.todo);
expect(args.dataDir, isNull);
});
test('reads every add option', () {
final args = CliArgs.parse([
'--text',
'Title',
'--priority',
'high',
'--status',
'in_progress',
'--data-dir',
'/tmp/x',
]);
expect(args.mode, CliMode.add);
expect(args.text, 'Title');
expect(args.priority, Priority.high);
expect(args.status, Status.inProgress);
expect(args.dataDir, '/tmp/x');
});
test('accepts plain status names', () {
expect(CliArgs.parse(['--status', 'done']).status, Status.done);
});
test('selects list, get and delete', () {
expect(CliArgs.parse(['--list']).mode, CliMode.list);
final get = CliArgs.parse(['--get', 'abc']);
expect(get.mode, CliMode.get);
expect(get.id, 'abc');
final del = CliArgs.parse(['--delete', 'xyz']);
expect(del.mode, CliMode.delete);
expect(del.id, 'xyz');
});
test('help wins over everything else', () {
expect(CliArgs.parse(['--list', '-h']).mode, CliMode.help);
expect(CliArgs.parse(['--help']).mode, CliMode.help);
});
test('rejects malformed command lines', () {
void expectUsage(List<String> argv, String fragment) {
expect(
() => CliArgs.parse(argv),
throwsA(
isA<UsageError>().having(
(e) => e.toString(),
'message',
contains(fragment),
),
),
);
}
expectUsage(['--text'], '--text needs a value');
expectUsage(['--bogus'], 'unknown argument: --bogus');
expectUsage(['--priority', 'urgent'], 'low|medium|high');
expectUsage(['--status', 'inProgress'], 'todo|in_progress|done');
expectUsage(['--list', '--get', 'a'], 'mutually exclusive');
expectUsage(['--list', '--text', 'x'], 'only applies when adding');
});
});
test('statusName spells inProgress with an underscore', () {
expect(CliArgs.statusName(Status.inProgress), 'in_progress');
expect(CliArgs.statusName(Status.abandoned), 'abandoned');
});
}

View File

@ -0,0 +1,20 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:todo/cli/cli_runner.dart';
void main() {
group('readInput', () {
test('returns empty for an interactive terminal', () async {
expect(await readInput(const Stream.empty(), hasTerminal: true), '');
});
test('decodes the whole stream and trims trailing whitespace', () async {
final input = Stream<List<int>>.fromIterable([
utf8.encode('ab'),
utf8.encode('c\n\n'),
]);
expect(await readInput(input, hasTerminal: false), 'abc');
});
});
}

View File

@ -0,0 +1,242 @@
import 'dart:io';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:todo/cli/cli_runner.dart';
import 'package:todo/cli/cli_session.dart';
import 'package:todo/data/note.dart';
import 'package:todo/data/note_repository.dart';
import 'package:todo/sync/sync_service.dart';
import 'fake_rtdb.dart';
/// A session over an in-memory log whose [sync] reports a scripted `pushed`
/// flag per call, optionally holding a note that already exists.
class _ScriptedSession extends CliSession {
_ScriptedSession._(
NoteRepository repo,
FirebaseRestClient firebase,
this._pushed,
) : super('unused', repo, firebase);
static Future<_ScriptedSession> create(
http.Client client,
List<bool> pushed, {
String? noteId,
}) async {
final repo = await NoteRepository.openInMemory();
if (noteId != null) {
final now = DateTime.utc(2026);
await repo.upsert(
Note(
id: noteId,
text: 'Stuck',
priority: Priority.medium,
status: Status.todo,
createdAt: now,
updatedAt: now,
),
);
}
final firebase = FirebaseRestClient(
databaseUrl: 'https://example.test',
auth: FirebaseTokenProvider(
apiKey: 'k',
store: InMemoryCredentialStore(
FirebaseCredentials(
idToken: 'id',
refreshToken: 'rt',
expiresAt: DateTime.utc(2099),
),
),
),
httpClient: client,
);
return _ScriptedSession._(repo, firebase, pushed);
}
final List<bool> _pushed;
@override
Future<SyncResult> sync({required bool push}) async =>
SyncResult(mergedDevices: 0, pushed: _pushed.removeAt(0));
}
void main() {
late Directory tmp;
late FakeRtdb db;
late Map<String, String> env;
late StringBuffer out;
late StringBuffer err;
setUp(() {
tmp = Directory.systemTemp.createTempSync('cli_runner_test');
db = FakeRtdb();
final home = Directory('${tmp.path}/home')..createSync();
Directory('${home.path}/.config/todo').createSync(recursive: true);
writeSeedCredentials(
tmp,
).copySync('${home.path}/.config/todo/firebase_auth.json');
env = {'HOME': home.path, 'XDG_DATA_HOME': '${tmp.path}/xdg'};
out = StringBuffer();
err = StringBuffer();
});
tearDown(() => tmp.deleteSync(recursive: true));
Future<CliSession> open(String dir, {required String seedCredentials}) =>
CliSession.open(
dir,
seedCredentials: seedCredentials,
httpClient: db.client,
);
Future<int> run(
List<String> argv, {
String stdin = '',
SessionOpener? opener,
}) async {
out.clear();
err.clear();
return await runCli(
argv,
env: env,
out: out,
err: err,
readStdin: () async => stdin,
open: opener ?? open,
);
}
String dir(String name) => '${tmp.path}/$name';
Future<String> add(
String name,
String text, [
List<String> extra = const [],
]) async {
expect(await run(['--data-dir', dir(name), '--text', text, ...extra]), 0);
return out.toString().trim();
}
group('argument handling', () {
test('--help prints usage and exits 0', () async {
expect(await run(['--help']), 0);
expect(out.toString(), contains('Usage: dart run bin/todo_note.dart'));
});
test('a bad flag prints the error and usage, exits 2', () async {
expect(await run(['--nope']), 2);
expect(err.toString(), contains('unknown argument: --nope'));
expect(err.toString(), contains('Usage:'));
});
test('an empty note is refused before touching the network', () async {
expect(await run(['--data-dir', dir('d')], stdin: ' \n'), 2);
expect(err.toString(), contains('empty note'));
expect(db.methods, isEmpty);
});
});
group('add', () {
test('pushes an ordinary CRDT log and prints the new id', () async {
final id = await add('a', 'Title\n\nbody', ['--priority', 'high']);
expect(id, matches(RegExp(r'^[0-9a-f-]{36}$')));
expect(db.files.keys.where((k) => k.contains('notes/')), hasLength(1));
expect(err.toString(), isEmpty);
});
test('reads the text from stdin when --text is absent', () async {
expect(await run(['--data-dir', dir('a')], stdin: 'From stdin'), 0);
expect(await run(['--list', '--data-dir', dir('a')]), 0);
expect(out.toString(), contains('\tmedium\ttodo\tFrom stdin'));
});
test('uses the XDG data dir by default', () async {
expect(await run(['--text', 'Default dir']), 0);
expect(Directory('${tmp.path}/xdg/todo-cli').existsSync(), isTrue);
});
test('fails when the push reports nothing published', () async {
Future<CliSession> opener(String d, {required String seedCredentials}) =>
_ScriptedSession.create(db.client, [false]);
expect(await run(['--text', 'x'], opener: opener), 1);
expect(err.toString(), contains('saved locally but nothing was pushed'));
});
});
group('list and get', () {
test('a second node lists and gets what the first one added', () async {
final id = await add('a', 'Title\n\nbody', [
'--priority',
'high',
'--status',
'in_progress',
]);
expect(await run(['--list', '--data-dir', dir('b')]), 0);
expect(out.toString().trim(), '$id\thigh\tin_progress\tTitle');
expect(await run(['--get', id, '--data-dir', dir('b')]), 0);
expect(out.toString(), contains('id: $id'));
expect(out.toString(), contains('priority: high'));
expect(out.toString(), contains('status: in_progress'));
expect(out.toString(), contains('---\nTitle\n\nbody'));
});
test('--get of an unknown id exits 1', () async {
expect(await run(['--get', 'nope', '--data-dir', dir('b')]), 1);
expect(err.toString(), contains('no live note with id nope'));
});
test('an unmergeable peer file is reported as a warning', () async {
db.files['todo-sync/notes/bad~2Ejson'] = 'not json';
expect(await run(['--list', '--data-dir', dir('b')]), 0);
expect(err.toString(), contains('could not merge peer file bad.json'));
});
});
group('delete', () {
test('tombstones a note created elsewhere and pushes', () async {
final id = await add('a', 'Gone soon');
expect(await run(['--delete', id, '--data-dir', dir('b')]), 0);
expect(out.toString().trim(), 'deleted $id');
expect(await run(['--list', '--data-dir', dir('c')]), 0);
expect(out.toString().trim(), isEmpty);
});
test('an unknown id exits 1', () async {
expect(await run(['--delete', 'nope', '--data-dir', dir('b')]), 1);
expect(err.toString(), contains('no live note with id nope'));
});
test('fails when the deletion push reports nothing published', () async {
Future<CliSession> opener(String d, {required String seedCredentials}) =>
_ScriptedSession.create(db.client, [true, false], noteId: 'stuck');
expect(await run(['--delete', 'stuck'], opener: opener), 1);
expect(err.toString(), contains('deletion saved locally'));
});
});
group('failures', () {
test('a missing Firebase session exits 1 with re-seed advice', () async {
File('${env['HOME']}/.config/todo/firebase_auth.json').deleteSync();
expect(await run(['--list', '--data-dir', dir('a')]), 1);
expect(err.toString(), contains('seed_session.py'));
});
test('a rejected database exits 1', () async {
db.failWith = 403;
expect(await run(['--list', '--data-dir', dir('a')]), 1);
expect(err.toString(), contains('rejected: HTTP 403'));
});
test('a filesystem failure exits 1', () async {
Future<CliSession> boom(String d, {required String seedCredentials}) =>
throw const FileSystemException('disk full');
expect(await run(['--list'], opener: boom), 1);
expect(err.toString(), contains('disk full'));
});
});
}

View File

@ -0,0 +1,145 @@
import 'dart:io';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:todo/cli/cli_session.dart';
import 'package:todo/data/note.dart';
import 'fake_rtdb.dart';
void main() {
late Directory tmp;
late FakeRtdb db;
late String seed;
setUp(() {
tmp = Directory.systemTemp.createTempSync('cli_session_test');
db = FakeRtdb();
seed = writeSeedCredentials(tmp).path;
});
tearDown(() => tmp.deleteSync(recursive: true));
Future<CliSession> open(String name, {String? seedPath}) => CliSession.open(
'${tmp.path}/$name',
seedCredentials: seedPath ?? seed,
httpClient: db.client,
);
Note note(String id) {
final now = DateTime.utc(2026);
return Note(
id: id,
text: 'Title\nbody',
priority: Priority.medium,
status: Status.todo,
createdAt: now,
updatedAt: now,
);
}
group('defaultDataDir', () {
test('prefers XDG_DATA_HOME', () {
expect(
defaultDataDir({'XDG_DATA_HOME': '/x', 'HOME': '/h'}),
'/x/todo-cli',
);
});
test('falls back to ~/.local/share', () {
expect(defaultDataDir({'HOME': '/h'}), '/h/.local/share/todo-cli');
expect(
defaultDataDir({'XDG_DATA_HOME': '', 'HOME': '/h'}),
'/h/.local/share/todo-cli',
);
});
test('survives a missing HOME', () {
expect(defaultDataDir({}), '.local/share/todo-cli');
});
});
group('open', () {
test('creates an owner-only data dir and persists one node id', () async {
final first = await open('data');
final nodeId = first.repository.nodeId;
await first.close();
final dir = Directory('${tmp.path}/data');
expect(dir.statSync().mode & 0x1ff, 448); // 0700
expect(
File('${dir.path}/${CliSession.nodeIdFileName}').readAsStringSync(),
'$nodeId\n',
);
final second = await open('data');
expect(second.repository.nodeId, nodeId);
await second.close();
});
test('mints a fresh id when the stored one is blank', () async {
Directory('${tmp.path}/blank').createSync();
File(
'${tmp.path}/blank/${CliSession.nodeIdFileName}',
).writeAsStringSync(' \n');
final session = await open('blank');
expect(session.repository.nodeId, isNotEmpty);
await session.close();
});
test('fails without a Firebase session, naming the seed', () async {
final missing = '${tmp.path}/missing.json';
await expectLater(
open('nosession', seedPath: missing),
throwsA(
isA<FirebaseAuthError>().having(
(e) => e.toString(),
'message',
contains(missing),
),
),
);
});
});
group('sync', () {
test('a pull-only sync never writes to the database', () async {
final session = await open('pull');
await session.repository.upsert(note('n1'));
final result = await session.sync(push: false);
expect(result.pushed, isTrue); // SyncService reports its own intent.
expect(db.methods, isNot(contains('PUT')));
expect(db.files, isEmpty);
await session.close();
});
test('a push publishes the log, then skips an unchanged one', () async {
final session = await open('push');
await session.repository.upsert(note('n1'));
final first = await session.sync(push: true);
expect(first.pushed, isTrue);
final node = session.repository.nodeId;
expect(db.files.keys, contains('todo-sync/notes/$node~2Ejson'));
expect(db.files.keys, contains('todo-sync/revs/$node'));
expect(
File('${tmp.path}/push/${CliSession.syncStateFileName}').existsSync(),
isTrue,
);
final second = await session.sync(push: true);
expect(second.pushed, isFalse);
await session.close();
});
test('a second node sees the first node note', () async {
final a = await open('a');
await a.repository.upsert(note('shared'));
await a.sync(push: true);
await a.close();
final b = await open('b');
final result = await b.sync(push: false);
expect(result.mergedDevices, 1);
expect((await b.repository.listNotes()).single.id, 'shared');
await b.close();
});
});
}

74
test/cli/fake_rtdb.dart Normal file
View File

@ -0,0 +1,74 @@
/// An in-memory Firebase Realtime Database speaking the REST dialect that
/// `FirebaseRestClient` uses, plus a seed-credentials helper.
///
/// Not named `*_test.dart`: the runner would collect it and fail on the
/// missing `main()`.
library;
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart' as http_testing;
/// The fake database: text blobs keyed by their (still key-escaped) path.
class FakeRtdb {
/// Every stored leaf, e.g. `todo-sync/notes/<node>~2Ejson` -> log JSON.
final Map<String, String> files = {};
/// Methods of every request seen, in order (`GET`, `PUT`, ...).
final List<String> methods = [];
/// When set, every request answers with this status and an empty body.
int? failWith;
/// The client to hand to the code under test.
late final http.Client client = http_testing.MockClient(_handle);
Future<http.Response> _handle(http.Request request) async {
methods.add(request.method);
final status = failWith;
if (status != null) return http.Response('denied', status);
final path = request.url.path.replaceFirst(RegExp('^/'), '');
final key = path.endsWith('.json')
? path.substring(0, path.length - '.json'.length)
: path;
switch (request.method) {
case 'PUT':
files[key] = jsonDecode(request.body) as String;
return http.Response('null', 200);
case 'DELETE':
files.remove(key);
return http.Response('null', 200);
default:
return http.Response(_read(key, request.url.queryParameters), 200);
}
}
String _read(String key, Map<String, String> query) {
final children = <String, String>{
for (final entry in files.entries)
if (key.isEmpty || entry.key.startsWith('$key/'))
entry.key.substring(key.isEmpty ? 0 : key.length + 1): entry.value,
};
if (query['shallow'] == 'true') {
return children.isEmpty
? 'null'
: jsonEncode({for (final k in children.keys) k: true});
}
final leaf = files[key];
if (leaf != null) return jsonEncode(leaf);
return children.isEmpty ? 'null' : jsonEncode(children);
}
}
/// Writes a Firebase session that does not expire during a test run.
File writeSeedCredentials(Directory dir, {String name = 'seed.json'}) {
return File('${dir.path}/$name')..writeAsStringSync(
jsonEncode({
'id_token': 'seed-id-token',
'refresh_token': 'seed-refresh-token',
'expires_at': '2099-01-01T00:00:00Z',
}),
);
}

View File

@ -0,0 +1,76 @@
import 'dart:convert';
import 'dart:io';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:todo/cli/file_credential_store.dart';
import 'fake_rtdb.dart';
void main() {
late Directory tmp;
late File own;
late File seed;
late FileCredentialStore store;
setUp(() {
tmp = Directory.systemTemp.createTempSync('cred_store_test');
own = File('${tmp.path}/nested/own.json');
seed = File('${tmp.path}/seed.json');
store = FileCredentialStore(file: own, seedFile: seed);
});
tearDown(() => tmp.deleteSync(recursive: true));
test('load is null when neither file exists', () async {
expect(await store.load(), isNull);
});
test('load falls back to the seed, then prefers its own copy', () async {
writeSeedCredentials(tmp);
expect((await store.load())!.refreshToken, 'seed-refresh-token');
await store.save(
FirebaseCredentials(
idToken: 'new-id',
refreshToken: 'new-refresh',
expiresAt: DateTime.utc(2099),
),
);
expect((await store.load())!.refreshToken, 'new-refresh');
// The seed is read-only: saving never touches it.
expect(
jsonDecode(seed.readAsStringSync()),
containsPair('refresh_token', 'seed-refresh-token'),
);
});
test('save writes an owner-only file and leaves no temp file', () async {
await store.save(
FirebaseCredentials(
idToken: 'i',
refreshToken: 'r',
expiresAt: DateTime.utc(2099),
),
);
expect(own.statSync().mode & 0x1ff, 384); // 0600
expect(own.parent.listSync().map((e) => e.path), [own.path]);
});
test('load is null for a non-object document', () async {
seed.writeAsStringSync('[1, 2]');
expect(await store.load(), isNull);
});
test('clear deletes its own copy and tolerates a missing one', () async {
await store.clear();
await store.save(
FirebaseCredentials(
idToken: 'i',
refreshToken: 'r',
expiresAt: DateTime.utc(2099),
),
);
await store.clear();
expect(own.existsSync(), isFalse);
});
}

View File

@ -0,0 +1,44 @@
import 'dart:io';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:todo/cli/pull_only_store.dart';
import 'fake_rtdb.dart';
void main() {
test('reads pass through and writes are dropped', () async {
final tmp = Directory.systemTemp.createTempSync('pull_only_test');
addTearDown(() => tmp.deleteSync(recursive: true));
final db = FakeRtdb()
..files['todo-sync/notes/a~2Ejson'] = 'log-a'
..files['todo-sync/revs/a'] = 'rev-a';
final inner = FirebaseRestClient(
databaseUrl: 'https://example.test/',
auth: FirebaseTokenProvider(
apiKey: 'k',
store: InMemoryCredentialStore(
FirebaseCredentials(
idToken: 'id',
refreshToken: 'rt',
expiresAt: DateTime.utc(2099),
),
),
),
httpClient: db.client,
);
final store = PullOnlyStore(inner);
expect(await store.listDirectory('todo-sync/notes'), ['a.json']);
expect(await store.getFileText('todo-sync/notes/a.json'), 'log-a');
expect(await store.getStringMap('todo-sync/revs'), {'a': 'rev-a'});
expect(await store.canAccessRemote(), isTrue);
final before = db.methods.length;
await store.putFileText('todo-sync/notes/b.json', 'x', message: 'm');
await store.deleteFile('todo-sync/notes/a.json');
expect(db.methods.length, before);
expect(db.files.keys, hasLength(2));
store.close();
});
}