@_nazmiforreal/flutter-ota 0.1.18 → 0.1.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/bin/flutter-ota-linux-x64 +0 -0
  2. package/dart-src/packages/cli-tools/.dart_tool/package_config.json +2 -0
  3. package/dart-src/packages/cli-tools/.dart_tool/package_graph.json +8 -8
  4. package/dart-src/packages/cli-tools/lib/flutter_ota_kit_cli.dart +2 -2
  5. package/dart-src/packages/cli-tools/lib/src/commands/pocketbase.dart +1379 -11
  6. package/dart-src/packages/cli-tools/lib/src/pocketbase/installer.dart +1 -1
  7. package/dart-src/packages/cli-tools/lib/src/pocketbase/process_manager.dart +104 -9
  8. package/dart-src/packages/cli-tools/lib/src/pocketbase/schema_installer.dart +59 -5
  9. package/dart-src/packages/cli-tools/pubspec.lock +7 -7
  10. package/dart-src/packages/cli-tools/pubspec.yaml +8 -8
  11. package/dart-src/packages/cli-tools/test/mocks/mock_pocketbase_client.dart +149 -0
  12. package/dart-src/packages/cli-tools/test/pocketbase_schema_test.dart +14 -9
  13. package/dart-src/packages/core/CHANGELOG.md +6 -1
  14. package/dart-src/packages/core/lib/src/app_update_info.dart +2 -0
  15. package/dart-src/packages/core/pubspec.yaml +1 -1
  16. package/dart-src/plugins/aws/CHANGELOG.md +5 -1
  17. package/dart-src/plugins/aws/pubspec.yaml +3 -3
  18. package/dart-src/plugins/cloudflare/CHANGELOG.md +5 -1
  19. package/dart-src/plugins/cloudflare/pubspec.yaml +3 -3
  20. package/dart-src/plugins/plugin-core/CHANGELOG.md +5 -1
  21. package/dart-src/plugins/plugin-core/pubspec.yaml +2 -2
  22. package/dart-src/plugins/pocketbase/CHANGELOG.md +5 -1
  23. package/dart-src/plugins/pocketbase/lib/flutter_ota_kit_pocketbase.dart +2 -1
  24. package/dart-src/plugins/pocketbase/lib/src/pocketbase_bundle_mapper.dart +10 -2
  25. package/dart-src/plugins/pocketbase/lib/src/pocketbase_client.dart +614 -4
  26. package/dart-src/plugins/pocketbase/lib/src/pocketbase_database.dart +262 -42
  27. package/dart-src/plugins/pocketbase/lib/src/pocketbase_storage.dart +9 -2
  28. package/dart-src/plugins/pocketbase/pubspec.yaml +3 -3
  29. package/dart-src/plugins/postgres/CHANGELOG.md +5 -1
  30. package/dart-src/plugins/postgres/pubspec.yaml +3 -3
  31. package/dart-src/plugins/supabase/CHANGELOG.md +6 -1
  32. package/dart-src/plugins/supabase/lib/src/supabase_database.dart +4 -0
  33. package/dart-src/plugins/supabase/pubspec.yaml +3 -3
  34. package/package.json +2 -2
@@ -2,11 +2,15 @@
2
2
  library;
3
3
 
4
4
  import 'dart:async';
5
+ import 'dart:convert';
5
6
  import 'dart:io';
6
7
 
8
+ import 'package:args/args.dart';
9
+ import 'package:flutter_ota_kit_pocketbase/flutter_ota_kit_pocketbase.dart';
7
10
  import 'package:path/path.dart' as p;
8
11
 
9
12
  import '../cli_base.dart';
13
+ import '../pack.dart';
10
14
  import '../pocketbase/data_bootstrap.dart';
11
15
  import '../pocketbase/installer.dart';
12
16
  import '../pocketbase/process_manager.dart';
@@ -14,11 +18,25 @@ import '../pocketbase/schema_installer.dart';
14
18
  import '../ui/ui.dart';
15
19
 
16
20
  class PocketBaseCommand extends FlutterPatcherCommand {
17
- PocketBaseCommand() {
21
+ PocketBaseCommand() {
18
22
  addSubcommand(PocketBaseInstallCommand());
19
23
  addSubcommand(PocketBaseServeCommand());
20
24
  addSubcommand(PocketBaseStopCommand());
21
25
  addSubcommand(PocketBaseStatusCommand());
26
+ addSubcommand(PocketBaseBackupCommand());
27
+ addSubcommand(PocketBaseExportCommand());
28
+ addSubcommand(PocketBaseImportCommand());
29
+ addSubcommand(PocketBaseAdminCommand());
30
+ addSubcommand(PocketBaseMigrateCommand());
31
+ addSubcommand(PocketBaseHealthCommand());
32
+ addSubcommand(PocketBaseLogsCommand());
33
+ addSubcommand(PocketBaseRecordsCommand());
34
+ addSubcommand(PocketBaseCollectionsCommand());
35
+ addSubcommand(PocketBaseSettingsCommand());
36
+ addSubcommand(PocketBaseSqlCommand());
37
+ addSubcommand(PocketBaseCronsCommand());
38
+ addSubcommand(PocketBaseQueryCommand());
39
+ addSubcommand(PocketBaseConfigCommand());
22
40
  }
23
41
 
24
42
  @override
@@ -26,7 +44,9 @@ class PocketBaseCommand extends FlutterPatcherCommand {
26
44
 
27
45
  @override
28
46
  String get description =>
29
- 'Manage a local PocketBase instance (install / serve / stop / status).';
47
+ 'Manage a local PocketBase instance (install / serve / stop / '
48
+ 'status / backup / export / import / admin / migrate / health / '
49
+ 'logs / records / collections / settings / sql / crons).';
30
50
  }
31
51
 
32
52
  class PocketBaseInstallCommand extends FlutterPatcherCommand {
@@ -136,6 +156,18 @@ class PocketBaseServeCommand extends FlutterPatcherCommand {
136
156
  await manager.start(adminEmail: adminEmail, adminPassword: adminPassword);
137
157
  step('PocketBase is running (pid ${manager.process!.pid}).');
138
158
 
159
+ // Wait for PB to be ready.
160
+ for (var i = 0; i < 20; i++) {
161
+ try {
162
+ final hc = PocketBaseClient('http://$host:$port');
163
+ final ok = await hc.health();
164
+ hc.close();
165
+ if (ok) break;
166
+ } catch (_) {
167
+ await Future<void>.delayed(const Duration(milliseconds: 500));
168
+ }
169
+ }
170
+
139
171
  if (adminEmail != null && adminPassword != null) {
140
172
  step('Installing flutter-ota schema...');
141
173
  final schemaInstaller = PocketBaseSchemaInstaller(
@@ -209,26 +241,1362 @@ class PocketBaseStopCommand extends FlutterPatcherCommand {
209
241
  }
210
242
 
211
243
  class PocketBaseStatusCommand extends FlutterPatcherCommand {
244
+ PocketBaseStatusCommand() {
245
+ argParser.addOption('port', help: 'HTTP port.', defaultsTo: '8090');
246
+ argParser.addOption('host', help: 'Bind address.', defaultsTo: '127.0.0.1');
247
+ }
248
+
212
249
  @override
213
250
  String get name => 'status';
214
251
 
215
252
  @override
216
- String get description => 'Show the installed PocketBase version + path.';
253
+ String get description => 'Show installed version, PID, and health.';
217
254
 
218
255
  @override
219
256
  Future<int> run() => runGuarded(() async {
220
257
  banner('pocketbase · status');
258
+ final r = argResults!;
259
+ final port = int.tryParse(r['port'] as String? ?? '8090') ?? 8090;
260
+ final host = r['host'] as String? ?? '127.0.0.1';
261
+
221
262
  final paths = PocketBaseInstallPaths.resolve();
222
263
  final exists = await paths.binaryPath.exists();
223
- if (exists) {
224
- step(
225
- 'PocketBase v${paths.version}: installed at ${paths.binaryPath.path}',
226
- );
264
+ final manager = PocketBaseProcessManager(
265
+ binaryPath: paths.binaryPath,
266
+ dataDir: paths.installDir,
267
+ port: port,
268
+ host: host,
269
+ );
270
+
271
+ final pid = manager.readPid();
272
+ final pidAlive = pid > 0 ? await manager.isRunningByPidFile() : false;
273
+
274
+ var healthy = false;
275
+ try {
276
+ final client = PocketBaseClient('http://$host:$port');
277
+ healthy = await client.health();
278
+ client.close();
279
+ } catch (_) {}
280
+
281
+ box('status', [
282
+ kv('version', paths.version),
283
+ kv('binary', exists ? green('installed') : red('not installed')),
284
+ kv('pid', pidAlive ? green('$pid') : (pid > 0 ? yellow('$pid (dead)') : dim('-'))),
285
+ kv('health', healthy ? green('healthy') : red('unreachable')),
286
+ kv('url', 'http://$host:$port'),
287
+ ]);
288
+ });
289
+ }
290
+
291
+ // ---------------------------------------------------------------------------
292
+ // Shared helpers
293
+ // ---------------------------------------------------------------------------
294
+
295
+ void _addBackendOptions(ArgParser parser) {
296
+ parser.addOption('url', help: 'PocketBase URL (auto-detected if running).');
297
+ parser.addOption('admin-email', help: 'Admin email.');
298
+ parser.addOption('admin-password', help: 'Admin password.');
299
+ parser.addOption('version', help: 'PocketBase version.');
300
+ parser.addOption('data-dir', help: 'PB data directory.');
301
+ }
302
+
303
+ (String url, String adminEmail, String adminPassword) _resolveBackend(ArgResults r) {
304
+ final url = r['url'] as String? ?? _detectRunningUrl(r);
305
+ if (url == null || url.isEmpty) {
306
+ throw const PackException(
307
+ 'No PocketBase URL. Run `flutter-ota pocketbase serve` or pass --url.',
308
+ 64,
309
+ );
310
+ }
311
+ final admin = r['admin-email'] as String? ??
312
+ Platform.environment['POCKETBASE_ADMIN_EMAIL'] ??
313
+ '';
314
+ final pass = r['admin-password'] as String? ??
315
+ Platform.environment['POCKETBASE_ADMIN_PASSWORD'] ??
316
+ '';
317
+ if (admin.isEmpty || pass.isEmpty) {
318
+ throw const PackException(
319
+ 'Admin credentials required. Use --admin-email/--admin-password '
320
+ 'or set POCKETBASE_ADMIN_EMAIL/POCKETBASE_ADMIN_PASSWORD env vars.',
321
+ 64,
322
+ );
323
+ }
324
+ return (url, admin, pass);
325
+ }
326
+
327
+ Future<PocketBaseClient> _getClient(ArgResults r) async {
328
+ final (url, admin, pass) = _resolveBackend(r);
329
+ final client = PocketBaseClient(url);
330
+ await client.authenticate(admin, pass);
331
+ return client;
332
+ }
333
+
334
+ String? _detectRunningUrl(ArgResults r) {
335
+ final version = r['version'] as String? ?? kDefaultPocketBaseVersion;
336
+ final dataDirOverride = r['data-dir'] as String?;
337
+ final paths = PocketBaseInstallPaths.resolve(version: version);
338
+ final dataDir = dataDirOverride != null
339
+ ? Directory(dataDirOverride)
340
+ : paths.installDir;
341
+ final config = PocketBaseProcessManager(
342
+ binaryPath: paths.binaryPath,
343
+ dataDir: dataDir,
344
+ ).readConfig();
345
+ return config?['url'] as String?;
346
+ }
347
+
348
+ Future<T> _pbStep<T>(
349
+ String label,
350
+ Future<T> Function(PocketBaseClient client) fn,
351
+ ArgResults r,
352
+ ) async {
353
+ final steps = Steps('pocketbase');
354
+ try {
355
+ final client = await _getClient(r);
356
+ try {
357
+ return await steps.run(label, () => fn(client));
358
+ } finally {
359
+ client.close();
360
+ }
361
+ } on PocketBaseException catch (e) {
362
+ final match = RegExp(r'"message":"([^"]+)"').firstMatch(e.message);
363
+ final detail = match?.group(1) ?? e.message;
364
+ throw PackException('$label: $detail', 1);
365
+ }
366
+ }
367
+
368
+ // ---------------------------------------------------------------------------
369
+ // backup
370
+ // ---------------------------------------------------------------------------
371
+
372
+ class PocketBaseBackupCommand extends FlutterPatcherCommand {
373
+ PocketBaseBackupCommand() {
374
+ addSubcommand(PocketBaseBackupCreateCommand());
375
+ addSubcommand(PocketBaseBackupListCommand());
376
+ addSubcommand(PocketBaseBackupDeleteCommand());
377
+ addSubcommand(PocketBaseBackupRestoreCommand());
378
+ addSubcommand(PocketBaseBackupDownloadCommand());
379
+ addSubcommand(PocketBaseBackupUploadCommand());
380
+ }
381
+
382
+ @override
383
+ String get name => 'backup';
384
+
385
+ @override
386
+ String get description => 'Manage PocketBase backups.';
387
+ }
388
+
389
+ class PocketBaseBackupCreateCommand extends FlutterPatcherCommand {
390
+ PocketBaseBackupCreateCommand() {
391
+ _addBackendOptions(argParser);
392
+ argParser.addOption('name', help: 'Backup file name.');
393
+ }
394
+
395
+ @override
396
+ String get name => 'create';
397
+
398
+ @override
399
+ String get description => 'Create a new backup.';
400
+
401
+ @override
402
+ Future<int> run() => runGuarded(() async {
403
+ banner('pocketbase · backup · create');
404
+ await _pbStep(
405
+ 'Creating backup',
406
+ (c) => c.createBackup(name: argResults!['name'] as String?),
407
+ argResults!,
408
+ );
409
+ step(green('Backup created'));
410
+ });
411
+ }
412
+
413
+ class PocketBaseBackupListCommand extends FlutterPatcherCommand {
414
+ PocketBaseBackupListCommand() {
415
+ _addBackendOptions(argParser);
416
+ }
417
+
418
+ @override
419
+ String get name => 'list';
420
+
421
+ @override
422
+ String get description => 'List available backups.';
423
+
424
+ @override
425
+ Future<int> run() => runGuarded(() async {
426
+ banner('pocketbase · backup · list');
427
+ final backups = await _pbStep(
428
+ 'Listing backups',
429
+ (c) => c.listBackups(),
430
+ argResults!,
431
+ );
432
+ if (backups.isEmpty) {
433
+ warn('No backups found.');
434
+ return;
435
+ }
436
+ final rows = backups
437
+ .map((b) => [cyan(b.key), b.sizeFormatted, b.modified])
438
+ .toList();
439
+ table('${backups.length} backups', ['KEY', 'SIZE', 'MODIFIED'], rows);
440
+ });
441
+ }
442
+
443
+ class PocketBaseBackupDeleteCommand extends FlutterPatcherCommand {
444
+ PocketBaseBackupDeleteCommand() {
445
+ _addBackendOptions(argParser);
446
+ argParser.addOption('name', help: 'Backup key to delete.', mandatory: true);
447
+ }
448
+
449
+ @override
450
+ String get name => 'delete';
451
+
452
+ @override
453
+ String get description => 'Delete a backup.';
454
+
455
+ @override
456
+ Future<int> run() => runGuarded(() async {
457
+ banner('pocketbase · backup · delete');
458
+ final key = argResults!['name'] as String;
459
+ await _pbStep('Deleting $key', (c) => c.deleteBackup(key), argResults!);
460
+ step(green('Deleted backup $key'));
461
+ });
462
+ }
463
+
464
+ class PocketBaseBackupRestoreCommand extends FlutterPatcherCommand {
465
+ PocketBaseBackupRestoreCommand() {
466
+ _addBackendOptions(argParser);
467
+ argParser.addOption('name', help: 'Backup key to restore.', mandatory: true);
468
+ }
469
+
470
+ @override
471
+ String get name => 'restore';
472
+
473
+ @override
474
+ String get description => 'Restore a backup (restarts PB).';
475
+
476
+ @override
477
+ Future<int> run() => runGuarded(() async {
478
+ banner('pocketbase · backup · restore');
479
+ final key = argResults!['name'] as String;
480
+ await _pbStep('Restoring $key', (c) => c.restoreBackup(key), argResults!);
481
+ warn('PB will restart. Reconnect in a few seconds.');
482
+ });
483
+ }
484
+
485
+ class PocketBaseBackupDownloadCommand extends FlutterPatcherCommand {
486
+ PocketBaseBackupDownloadCommand() {
487
+ _addBackendOptions(argParser);
488
+ argParser.addOption('name', help: 'Backup key to download.', mandatory: true);
489
+ argParser.addOption('output', help: 'Output directory.', defaultsTo: '.');
490
+ }
491
+
492
+ @override
493
+ String get name => 'download';
494
+
495
+ @override
496
+ String get description => 'Download a backup file.';
497
+
498
+ @override
499
+ Future<int> run() => runGuarded(() async {
500
+ banner('pocketbase · backup · download');
501
+ final key = argResults!['name'] as String;
502
+ final output = argResults!['output'] as String;
503
+ final token = await _pbStep('Getting file token', (c) => c.getFileToken('', ''), argResults!);
504
+ final (url, _, _) = _resolveBackend(argResults!);
505
+ final dlClient = PocketBaseClient(url);
506
+ final url2 = dlClient.backupDownloadUrl(key, token);
507
+ final bytes = await dlClient.downloadFileUnauth(url2);
508
+ dlClient.close();
509
+ final out = File(p.join(output, key));
510
+ await out.parent.create(recursive: true);
511
+ await out.writeAsBytes(bytes);
512
+ step(green('Saved to ${out.path}'));
513
+ });
514
+ }
515
+
516
+ class PocketBaseBackupUploadCommand extends FlutterPatcherCommand {
517
+ PocketBaseBackupUploadCommand() {
518
+ _addBackendOptions(argParser);
519
+ argParser.addOption('file', help: 'Backup zip to upload.', mandatory: true);
520
+ }
521
+
522
+ @override
523
+ String get name => 'upload';
524
+
525
+ @override
526
+ String get description => 'Upload a backup zip file.';
527
+
528
+ @override
529
+ Future<int> run() => runGuarded(() async {
530
+ banner('pocketbase · backup · upload');
531
+ final fp = argResults!['file'] as String;
532
+ final f = File(fp);
533
+ if (!await f.exists()) throw PackException('File not found: $fp', 64);
534
+ final bytes = await f.readAsBytes();
535
+ await _pbStep(
536
+ 'Uploading ${p.basename(fp)}',
537
+ (c) => c.uploadBackup(bytes, name: p.basename(fp)),
538
+ argResults!,
539
+ );
540
+ step(green('Uploaded ${p.basename(fp)}'));
541
+ });
542
+ }
543
+
544
+ // ---------------------------------------------------------------------------
545
+ // export / import
546
+ // ---------------------------------------------------------------------------
547
+
548
+ class PocketBaseExportCommand extends FlutterPatcherCommand {
549
+ PocketBaseExportCommand() {
550
+ _addBackendOptions(argParser);
551
+ argParser.addOption('collection', help: 'Collection to export.');
552
+ argParser.addOption('output', help: 'Output directory.', defaultsTo: '.');
553
+ }
554
+
555
+ @override
556
+ String get name => 'export';
557
+
558
+ @override
559
+ String get description =>
560
+ 'Export collection(s) as JSON. Exports all if --collection omitted.';
561
+
562
+ @override
563
+ Future<int> run() => runGuarded(() async {
564
+ banner('pocketbase · export');
565
+ final r = argResults!;
566
+ final collection = r['collection'] as String?;
567
+ final output = r['output'] as String;
568
+
569
+ final client = PocketBaseClient(_resolveBackend(r).$1);
570
+ await client.authenticate(_resolveBackend(r).$2, _resolveBackend(r).$3);
571
+
572
+ final collections = <String>[];
573
+ if (collection != null && collection.isNotEmpty) {
574
+ collections.add(collection);
227
575
  } else {
228
- step(
229
- 'PocketBase: not installed. Run `flutter-ota pocketbase install`.',
230
- );
576
+ final all = await client.listCollections();
577
+ for (final c in all) {
578
+ final name = c['name'] as String? ?? '';
579
+ if (name.isNotEmpty && !name.startsWith('_') && c['system'] != true) {
580
+ collections.add(name);
581
+ }
582
+ }
583
+ if (collections.isEmpty) {
584
+ collections.addAll(['bundles', 'channels', 'audit_log', 'bundles_patches']);
585
+ }
231
586
  }
232
- return;
587
+
588
+ for (final c in collections) {
589
+ try {
590
+ final records = await client.exportCollection(c);
591
+ final outFile = File(p.join(output, '$c.json'));
592
+ await outFile.parent.create(recursive: true);
593
+ await outFile.writeAsString(
594
+ const JsonEncoder.withIndent(' ').convert(records),
595
+ );
596
+ step('${records.length} records → ${outFile.path}');
597
+ } catch (e) {
598
+ warn('Failed to export $c: $e');
599
+ }
600
+ }
601
+ client.close();
602
+ });
603
+ }
604
+
605
+ class PocketBaseImportCommand extends FlutterPatcherCommand {
606
+ PocketBaseImportCommand() {
607
+ _addBackendOptions(argParser);
608
+ argParser.addOption('file', help: 'JSON file to import.', mandatory: true);
609
+ argParser.addOption('collection', help: 'Target collection name.');
610
+ }
611
+
612
+ @override
613
+ String get name => 'import';
614
+
615
+ @override
616
+ String get description => 'Import records from a JSON file into a collection.';
617
+
618
+ @override
619
+ Future<int> run() => runGuarded(() async {
620
+ banner('pocketbase · import');
621
+ final r = argResults!;
622
+ final fp = r['file'] as String;
623
+ final f = File(fp);
624
+ if (!await f.exists()) throw PackException('File not found: $fp', 64);
625
+ final json = jsonDecode(await f.readAsString()) as List<dynamic>;
626
+ final records = json.cast<Map<String, dynamic>>();
627
+ final target = r['collection'] as String? ?? p.basenameWithoutExtension(fp);
628
+ await _pbStep(
629
+ 'Importing ${records.length} records into $target',
630
+ (c) => c.importCollection(target, records),
631
+ r,
632
+ );
633
+ step(green('${records.length} records imported into $target'));
634
+ });
635
+ }
636
+
637
+ // ---------------------------------------------------------------------------
638
+ // admin
639
+ // ---------------------------------------------------------------------------
640
+
641
+ class PocketBaseAdminCommand extends FlutterPatcherCommand {
642
+ PocketBaseAdminCommand() {
643
+ addSubcommand(PocketBaseAdminCreateCommand());
644
+ addSubcommand(PocketBaseAdminListCommand());
645
+ addSubcommand(PocketBaseAdminUpdateCommand());
646
+ addSubcommand(PocketBaseAdminDeleteCommand());
647
+ }
648
+
649
+ @override
650
+ String get name => 'admin';
651
+
652
+ @override
653
+ String get description => 'Manage PocketBase admin accounts.';
654
+ }
655
+
656
+ class PocketBaseAdminCreateCommand extends FlutterPatcherCommand {
657
+ PocketBaseAdminCreateCommand() {
658
+ _addBackendOptions(argParser);
659
+ argParser.addOption('email', help: 'Admin email.', mandatory: true);
660
+ argParser.addOption('password', help: 'Admin password.', mandatory: true);
661
+ }
662
+
663
+ @override
664
+ String get name => 'create';
665
+
666
+ @override
667
+ String get description => 'Create a new admin account.';
668
+
669
+ @override
670
+ Future<int> run() => runGuarded(() async {
671
+ banner('pocketbase · admin · create');
672
+ final r = argResults!;
673
+ final email = r['email'] as String;
674
+ final password = r['password'] as String;
675
+ final result = await _pbStep(
676
+ 'Creating admin $email',
677
+ (c) => c.createAdmin(email: email, password: password, passwordConfirm: password),
678
+ r,
679
+ );
680
+ box('admin', [kv('id', cyan(result['id'] ?? '?')), kv('email', email)]);
681
+ });
682
+ }
683
+
684
+ class PocketBaseAdminListCommand extends FlutterPatcherCommand {
685
+ PocketBaseAdminListCommand() {
686
+ _addBackendOptions(argParser);
687
+ }
688
+
689
+ @override
690
+ String get name => 'list';
691
+
692
+ @override
693
+ String get description => 'List admin accounts.';
694
+
695
+ @override
696
+ Future<int> run() => runGuarded(() async {
697
+ banner('pocketbase · admin · list');
698
+ final admins = await _pbStep('Listing admins', (c) => c.listAdmins(), argResults!);
699
+ if (admins.isEmpty) {
700
+ warn('No admins found.');
701
+ return;
702
+ }
703
+ final rows = admins
704
+ .map((a) => [cyan(a['id']?.toString() ?? '?'), a['email']?.toString() ?? '?'])
705
+ .toList();
706
+ table('${admins.length} admins', ['ID', 'EMAIL'], rows);
707
+ });
708
+ }
709
+
710
+ class PocketBaseAdminUpdateCommand extends FlutterPatcherCommand {
711
+ PocketBaseAdminUpdateCommand() {
712
+ _addBackendOptions(argParser);
713
+ argParser.addOption('id', help: 'Admin ID to update.', mandatory: true);
714
+ argParser.addOption('body', help: 'JSON fields to update.', mandatory: true);
715
+ }
716
+
717
+ @override
718
+ String get name => 'update';
719
+
720
+ @override
721
+ String get description => 'Update an admin account.';
722
+
723
+ @override
724
+ Future<int> run() => runGuarded(() async {
725
+ banner('pocketbase · admin · update');
726
+ final r = argResults!;
727
+ final id = r['id'] as String;
728
+ final body = jsonDecode(r['body'] as String) as Map<String, dynamic>;
729
+ await _pbStep('Updating admin $id', (c) => c.updateAdmin(id, body), r);
730
+ step(green('Updated admin $id'));
731
+ });
732
+ }
733
+
734
+ class PocketBaseAdminDeleteCommand extends FlutterPatcherCommand {
735
+ PocketBaseAdminDeleteCommand() {
736
+ _addBackendOptions(argParser);
737
+ argParser.addOption('id', help: 'Admin ID to delete.', mandatory: true);
738
+ }
739
+
740
+ @override
741
+ String get name => 'delete';
742
+
743
+ @override
744
+ String get description => 'Delete an admin account.';
745
+
746
+ @override
747
+ Future<int> run() => runGuarded(() async {
748
+ banner('pocketbase · admin · delete');
749
+ final id = argResults!['id'] as String;
750
+ await _pbStep('Deleting admin $id', (c) => c.deleteAdmin(id), argResults!);
751
+ step(green('Deleted admin $id'));
752
+ });
753
+ }
754
+
755
+ // ---------------------------------------------------------------------------
756
+ // migrate
757
+ // ---------------------------------------------------------------------------
758
+
759
+ class PocketBaseMigrateCommand extends FlutterPatcherCommand {
760
+ PocketBaseMigrateCommand() {
761
+ _addBackendOptions(argParser);
762
+ }
763
+
764
+ @override
765
+ String get name => 'migrate';
766
+
767
+ @override
768
+ String get description => 'Re-run schema migration on a running PocketBase.';
769
+
770
+ @override
771
+ Future<int> run() => runGuarded(() async {
772
+ banner('pocketbase · migrate');
773
+ final (url, admin, pass) = _resolveBackend(argResults!);
774
+ final installer = PocketBaseSchemaInstaller(
775
+ url: url,
776
+ adminEmail: admin,
777
+ adminPassword: pass,
778
+ );
779
+ final res = await installer.install();
780
+ if (res.created.isNotEmpty) {
781
+ step(green('Created: ${res.created.join(', ')}'));
782
+ }
783
+ if (res.skipped.isNotEmpty) {
784
+ step('Already present: ${res.skipped.join(', ')}');
785
+ }
786
+ });
787
+ }
788
+
789
+ // ---------------------------------------------------------------------------
790
+ // health
791
+ // ---------------------------------------------------------------------------
792
+
793
+ class PocketBaseHealthCommand extends FlutterPatcherCommand {
794
+ PocketBaseHealthCommand() {
795
+ _addBackendOptions(argParser);
796
+ }
797
+
798
+ @override
799
+ String get name => 'health';
800
+
801
+ @override
802
+ String get description => 'Check PocketBase health.';
803
+
804
+ @override
805
+ Future<int> run() => runGuarded(() async {
806
+ banner('pocketbase · health');
807
+ final health = await _pbStep('health', (c) => c.healthDetailed(), argResults!);
808
+ final code = health['code'] as int? ?? 0;
809
+ final message = health['message'] as String? ?? '';
810
+ final data = health['data'] as Map<String, dynamic>? ?? {};
811
+ box('health', [
812
+ kv('status', code == 200 ? green('healthy') : red('unhealthy')),
813
+ kv('message', message),
814
+ if (data.isNotEmpty) kv('canBackup', '${data['canBackup'] ?? false}'),
815
+ ]);
816
+ });
817
+ }
818
+
819
+ // ---------------------------------------------------------------------------
820
+ // logs
821
+ // ---------------------------------------------------------------------------
822
+
823
+ class PocketBaseLogsCommand extends FlutterPatcherCommand {
824
+ PocketBaseLogsCommand() {
825
+ addSubcommand(PocketBaseLogsListCommand());
826
+ addSubcommand(PocketBaseLogsStatsCommand());
827
+ addSubcommand(PocketBaseLogsTruncateCommand());
828
+ }
829
+
830
+ @override
831
+ String get name => 'logs';
832
+
833
+ @override
834
+ String get description => 'Manage PocketBase logs.';
835
+ }
836
+
837
+ class PocketBaseLogsListCommand extends FlutterPatcherCommand {
838
+ PocketBaseLogsListCommand() {
839
+ _addBackendOptions(argParser);
840
+ argParser.addOption('filter', help: 'PB filter expression.');
841
+ argParser.addOption('sort', help: 'Sort field.', defaultsTo: '-created');
842
+ argParser.addOption('page', help: 'Page number.', defaultsTo: '1');
843
+ argParser.addOption('per-page', help: 'Items per page.', defaultsTo: '30');
844
+ }
845
+
846
+ @override
847
+ String get name => 'list';
848
+
849
+ @override
850
+ String get description => 'List request logs.';
851
+
852
+ @override
853
+ Future<int> run() => runGuarded(() async {
854
+ banner('pocketbase · logs · list');
855
+ final r = argResults!;
856
+ final data = await _pbStep(
857
+ 'Fetching logs',
858
+ (c) => c.listLogs(
859
+ filter: r['filter'] as String?,
860
+ sort: r['sort'] as String?,
861
+ page: int.tryParse(r['page'] as String? ?? '1') ?? 1,
862
+ perPage: int.tryParse(r['per-page'] as String? ?? '30') ?? 30,
863
+ ),
864
+ r,
865
+ );
866
+ final items = (data['items'] as List? ?? []).cast<Map<String, dynamic>>();
867
+ final total = data['totalItems'] as int? ?? 0;
868
+ if (items.isEmpty) {
869
+ warn('No logs found.');
870
+ return;
871
+ }
872
+ final rows = items.map((log) {
873
+ final level = log['level'] as int? ?? 0;
874
+ final lvl = level == 0 ? green('INFO') : level == 1 ? yellow('WARN') : red('ERROR');
875
+ final msg = (log['message'] as String? ?? '').length > 60
876
+ ? '${(log['message'] as String).substring(0, 57)}...'
877
+ : log['message'] as String? ?? '';
878
+ return [lvl, log['created']?.toString().substring(0, 19) ?? '', msg];
879
+ }).toList();
880
+ table('$total logs', ['LEVEL', 'TIME', 'MESSAGE'], rows);
881
+ });
882
+ }
883
+
884
+ class PocketBaseLogsStatsCommand extends FlutterPatcherCommand {
885
+ PocketBaseLogsStatsCommand() {
886
+ _addBackendOptions(argParser);
887
+ argParser.addOption('filter', help: 'PB filter expression.');
888
+ }
889
+
890
+ @override
891
+ String get name => 'stats';
892
+
893
+ @override
894
+ String get description => 'Show hourly log statistics.';
895
+
896
+ @override
897
+ Future<int> run() => runGuarded(() async {
898
+ banner('pocketbase · logs · stats');
899
+ final stats = await _pbStep(
900
+ 'Fetching log stats',
901
+ (c) => c.getLogStats(filter: argResults!['filter'] as String?),
902
+ argResults!,
903
+ );
904
+ if (stats.isEmpty) {
905
+ warn('No stats available.');
906
+ return;
907
+ }
908
+ final rows = stats.map((s) => [
909
+ s['date']?.toString().substring(0, 16) ?? '',
910
+ '${s['total'] ?? 0}',
911
+ ]).toList();
912
+ table('${stats.length} entries', ['TIME', 'COUNT'], rows);
913
+ });
914
+ }
915
+
916
+ class PocketBaseLogsTruncateCommand extends FlutterPatcherCommand {
917
+ PocketBaseLogsTruncateCommand() {
918
+ _addBackendOptions(argParser);
919
+ }
920
+
921
+ @override
922
+ String get name => 'truncate';
923
+
924
+ @override
925
+ String get description => 'Delete all logs.';
926
+
927
+ @override
928
+ Future<int> run() => runGuarded(() async {
929
+ banner('pocketbase · logs · truncate');
930
+ await _pbStep('Truncating logs', (c) => c.truncateLogs(), argResults!);
931
+ step(green('All logs deleted'));
932
+ });
933
+ }
934
+
935
+ // ---------------------------------------------------------------------------
936
+ // records
937
+ // ---------------------------------------------------------------------------
938
+
939
+ class PocketBaseRecordsCommand extends FlutterPatcherCommand {
940
+ PocketBaseRecordsCommand() {
941
+ addSubcommand(PocketBaseRecordsListCommand());
942
+ addSubcommand(PocketBaseRecordsGetCommand());
943
+ addSubcommand(PocketBaseRecordsCreateCommand());
944
+ addSubcommand(PocketBaseRecordsUpdateCommand());
945
+ addSubcommand(PocketBaseRecordsDeleteCommand());
946
+ addSubcommand(PocketBaseRecordsBatchCommand());
947
+ }
948
+
949
+ @override
950
+ String get name => 'records';
951
+
952
+ @override
953
+ String get description => 'CRUD operations on collection records.';
954
+ }
955
+
956
+ class PocketBaseRecordsListCommand extends FlutterPatcherCommand {
957
+ PocketBaseRecordsListCommand() {
958
+ _addBackendOptions(argParser);
959
+ argParser.addOption('collection', help: 'Collection name.', mandatory: true);
960
+ argParser.addOption('filter', help: 'PB filter expression.');
961
+ argParser.addOption('sort', help: 'Sort field.');
962
+ argParser.addOption('page', help: 'Page number.', defaultsTo: '1');
963
+ argParser.addOption('per-page', help: 'Items per page.', defaultsTo: '30');
964
+ }
965
+
966
+ @override
967
+ String get name => 'list';
968
+
969
+ @override
970
+ String get description => 'List records in a collection.';
971
+
972
+ @override
973
+ Future<int> run() => runGuarded(() async {
974
+ banner('pocketbase · records · list');
975
+ final r = argResults!;
976
+ final data = await _pbStep(
977
+ 'Listing ${r['collection']}',
978
+ (c) => c.listRecords<dynamic>(
979
+ r['collection'] as String,
980
+ (j) => j,
981
+ filter: r['filter'] as String?,
982
+ sort: r['sort'] as String?,
983
+ page: int.tryParse(r['page'] as String? ?? '1') ?? 1,
984
+ perPage: int.tryParse(r['per-page'] as String? ?? '30') ?? 30,
985
+ ),
986
+ r,
987
+ );
988
+ if (data.items.isEmpty) {
989
+ warn('No records found.');
990
+ return;
991
+ }
992
+ final rows = data.items.map((r) {
993
+ final m = r as Map<String, dynamic>;
994
+ return [cyan(m['id']?.toString() ?? '?'), m.values.first?.toString() ?? ''];
995
+ }).toList();
996
+ table('${data.totalItems} records', ['ID', 'FIRST FIELD'], rows);
997
+ });
998
+ }
999
+
1000
+ class PocketBaseRecordsGetCommand extends FlutterPatcherCommand {
1001
+ PocketBaseRecordsGetCommand() {
1002
+ _addBackendOptions(argParser);
1003
+ argParser.addOption('collection', help: 'Collection name.', mandatory: true);
1004
+ argParser.addOption('id', help: 'Record ID.', mandatory: true);
1005
+ }
1006
+
1007
+ @override
1008
+ String get name => 'get';
1009
+
1010
+ @override
1011
+ String get description => 'Get a single record.';
1012
+
1013
+ @override
1014
+ Future<int> run() => runGuarded(() async {
1015
+ banner('pocketbase · records · get');
1016
+ final r = argResults!;
1017
+ final record = await _pbStep(
1018
+ 'Getting ${r['collection']}/${r['id']}',
1019
+ (c) => c.getRecord<dynamic>(r['collection'] as String, r['id'] as String, (j) => j),
1020
+ r,
1021
+ );
1022
+ if (record == null) {
1023
+ warn('Record not found.');
1024
+ return;
1025
+ }
1026
+ stdout.writeln(const JsonEncoder.withIndent(' ').convert(record));
1027
+ });
1028
+ }
1029
+
1030
+ class PocketBaseRecordsCreateCommand extends FlutterPatcherCommand {
1031
+ PocketBaseRecordsCreateCommand() {
1032
+ _addBackendOptions(argParser);
1033
+ argParser.addOption('collection', help: 'Collection name.', mandatory: true);
1034
+ argParser.addOption('body', help: 'JSON body.', mandatory: true);
1035
+ }
1036
+
1037
+ @override
1038
+ String get name => 'create';
1039
+
1040
+ @override
1041
+ String get description => 'Create a record.';
1042
+
1043
+ @override
1044
+ Future<int> run() => runGuarded(() async {
1045
+ banner('pocketbase · records · create');
1046
+ final r = argResults!;
1047
+ final body = jsonDecode(r['body'] as String) as Map<String, dynamic>;
1048
+ final record = await _pbStep(
1049
+ 'Creating record in ${r['collection']}',
1050
+ (c) => c.createRecord<dynamic>(r['collection'] as String, body, (j) => j),
1051
+ r,
1052
+ );
1053
+ box('record', [kv('id', cyan((record as Map)['id']?.toString() ?? '?'))]);
1054
+ });
1055
+ }
1056
+
1057
+ class PocketBaseRecordsUpdateCommand extends FlutterPatcherCommand {
1058
+ PocketBaseRecordsUpdateCommand() {
1059
+ _addBackendOptions(argParser);
1060
+ argParser.addOption('collection', help: 'Collection name.', mandatory: true);
1061
+ argParser.addOption('id', help: 'Record ID.', mandatory: true);
1062
+ argParser.addOption('body', help: 'JSON body.', mandatory: true);
1063
+ }
1064
+
1065
+ @override
1066
+ String get name => 'update';
1067
+
1068
+ @override
1069
+ String get description => 'Update a record.';
1070
+
1071
+ @override
1072
+ Future<int> run() => runGuarded(() async {
1073
+ banner('pocketbase · records · update');
1074
+ final r = argResults!;
1075
+ final body = jsonDecode(r['body'] as String) as Map<String, dynamic>;
1076
+ await _pbStep(
1077
+ 'Updating ${r['collection']}/${r['id']}',
1078
+ (c) => c.updateRecord<dynamic>(r['collection'] as String, r['id'] as String, body, (j) => j),
1079
+ r,
1080
+ );
1081
+ step(green('Updated ${r['collection']}/${r['id']}'));
1082
+ });
1083
+ }
1084
+
1085
+ class PocketBaseRecordsDeleteCommand extends FlutterPatcherCommand {
1086
+ PocketBaseRecordsDeleteCommand() {
1087
+ _addBackendOptions(argParser);
1088
+ argParser.addOption('collection', help: 'Collection name.', mandatory: true);
1089
+ argParser.addOption('id', help: 'Record ID.', mandatory: true);
1090
+ }
1091
+
1092
+ @override
1093
+ String get name => 'delete';
1094
+
1095
+ @override
1096
+ String get description => 'Delete a record.';
1097
+
1098
+ @override
1099
+ Future<int> run() => runGuarded(() async {
1100
+ banner('pocketbase · records · delete');
1101
+ final r = argResults!;
1102
+ await _pbStep(
1103
+ 'Deleting ${r['collection']}/${r['id']}',
1104
+ (c) => c.deleteRecord(r['collection'] as String, r['id'] as String),
1105
+ r,
1106
+ );
1107
+ step(green('Deleted ${r['collection']}/${r['id']}'));
1108
+ });
1109
+ }
1110
+
1111
+ class PocketBaseRecordsBatchCommand extends FlutterPatcherCommand {
1112
+ PocketBaseRecordsBatchCommand() {
1113
+ _addBackendOptions(argParser);
1114
+ argParser.addOption('requests', help: 'JSON array of request objects.', mandatory: true);
1115
+ }
1116
+
1117
+ @override
1118
+ String get name => 'batch';
1119
+
1120
+ @override
1121
+ String get description => 'Execute multiple record operations in a single transaction.';
1122
+
1123
+ @override
1124
+ Future<int> run() => runGuarded(() async {
1125
+ banner('pocketbase · records · batch');
1126
+ final requests = jsonDecode(argResults!['requests'] as String) as List<dynamic>;
1127
+ final body = requests.cast<Map<String, dynamic>>();
1128
+ final results = await _pbStep(
1129
+ 'Executing ${body.length} batch requests',
1130
+ (c) => c.batch(body),
1131
+ argResults!,
1132
+ );
1133
+ for (final (i, r) in results.indexed) {
1134
+ final status = r['status'] as int? ?? 0;
1135
+ final ok = status >= 200 && status < 300;
1136
+ step('${ok ? green('ok') : red('fail')} [${i + 1}] HTTP $status');
1137
+ }
1138
+ });
1139
+ }
1140
+ // ---------------------------------------------------------------------------
1141
+
1142
+ class PocketBaseCollectionsCommand extends FlutterPatcherCommand {
1143
+ PocketBaseCollectionsCommand() {
1144
+ addSubcommand(PocketBaseCollectionsListCommand());
1145
+ addSubcommand(PocketBaseCollectionsViewCommand());
1146
+ addSubcommand(PocketBaseCollectionsCreateCommand());
1147
+ addSubcommand(PocketBaseCollectionsUpdateCommand());
1148
+ addSubcommand(PocketBaseCollectionsDeleteCommand());
1149
+ addSubcommand(PocketBaseCollectionsTruncateCommand());
1150
+ addSubcommand(PocketBaseCollectionsImportCommand());
1151
+ }
1152
+
1153
+ @override
1154
+ String get name => 'collections';
1155
+
1156
+ @override
1157
+ String get description => 'Manage PocketBase collections.';
1158
+ }
1159
+
1160
+ class PocketBaseCollectionsListCommand extends FlutterPatcherCommand {
1161
+ PocketBaseCollectionsListCommand() {
1162
+ _addBackendOptions(argParser);
1163
+ }
1164
+
1165
+ @override
1166
+ String get name => 'list';
1167
+
1168
+ @override
1169
+ String get description => 'List all collections.';
1170
+
1171
+ @override
1172
+ Future<int> run() => runGuarded(() async {
1173
+ banner('pocketbase · collections · list');
1174
+ final cols = await _pbStep('Listing collections', (c) => c.listCollections(), argResults!);
1175
+ if (cols.isEmpty) {
1176
+ warn('No collections found.');
1177
+ return;
1178
+ }
1179
+ final rows = cols.map((c) => [
1180
+ cyan(c['name']?.toString() ?? '?'),
1181
+ c['type']?.toString() ?? '?',
1182
+ '${(c['fields'] as List?)?.length ?? 0} fields',
1183
+ ]).toList();
1184
+ table('${cols.length} collections', ['NAME', 'TYPE', 'FIELDS'], rows);
1185
+ });
1186
+ }
1187
+
1188
+ class PocketBaseCollectionsViewCommand extends FlutterPatcherCommand {
1189
+ PocketBaseCollectionsViewCommand() {
1190
+ _addBackendOptions(argParser);
1191
+ argParser.addOption('name', help: 'Collection name or ID.', mandatory: true);
1192
+ }
1193
+
1194
+ @override
1195
+ String get name => 'view';
1196
+
1197
+ @override
1198
+ String get description => 'View a collection schema.';
1199
+
1200
+ @override
1201
+ Future<int> run() => runGuarded(() async {
1202
+ banner('pocketbase · collections · view');
1203
+ final name = argResults!['name'] as String;
1204
+ final col = await _pbStep('Viewing $name', (c) => c.getCollection(name), argResults!);
1205
+ stdout.writeln(const JsonEncoder.withIndent(' ').convert(col));
1206
+ });
1207
+ }
1208
+
1209
+ class PocketBaseCollectionsCreateCommand extends FlutterPatcherCommand {
1210
+ PocketBaseCollectionsCreateCommand() {
1211
+ _addBackendOptions(argParser);
1212
+ argParser.addOption('body', help: 'JSON collection definition.', mandatory: true);
1213
+ }
1214
+
1215
+ @override
1216
+ String get name => 'create';
1217
+
1218
+ @override
1219
+ String get description => 'Create a new collection.';
1220
+
1221
+ @override
1222
+ Future<int> run() => runGuarded(() async {
1223
+ banner('pocketbase · collections · create');
1224
+ final body = jsonDecode(argResults!['body'] as String) as Map<String, dynamic>;
1225
+ await _pbStep('Creating ${body['name'] ?? 'collection'}',
1226
+ (c) => c.createCollection(body), argResults!);
1227
+ step(green('Created ${body['name'] ?? 'collection'}'));
1228
+ });
1229
+ }
1230
+
1231
+ class PocketBaseCollectionsUpdateCommand extends FlutterPatcherCommand {
1232
+ PocketBaseCollectionsUpdateCommand() {
1233
+ _addBackendOptions(argParser);
1234
+ argParser.addOption('name', help: 'Collection name or ID.', mandatory: true);
1235
+ argParser.addOption('body', help: 'JSON fields to update.', mandatory: true);
1236
+ }
1237
+
1238
+ @override
1239
+ String get name => 'update';
1240
+
1241
+ @override
1242
+ String get description => 'Update a collection.';
1243
+
1244
+ @override
1245
+ Future<int> run() => runGuarded(() async {
1246
+ banner('pocketbase · collections · update');
1247
+ final r = argResults!;
1248
+ final name = r['name'] as String;
1249
+ final body = jsonDecode(r['body'] as String) as Map<String, dynamic>;
1250
+ await _pbStep('Updating $name', (c) => c.updateCollection(name, body), r);
1251
+ step(green('Updated $name'));
1252
+ });
1253
+ }
1254
+
1255
+ class PocketBaseCollectionsDeleteCommand extends FlutterPatcherCommand {
1256
+ PocketBaseCollectionsDeleteCommand() {
1257
+ _addBackendOptions(argParser);
1258
+ argParser.addOption('name', help: 'Collection name or ID.', mandatory: true);
1259
+ }
1260
+
1261
+ @override
1262
+ String get name => 'delete';
1263
+
1264
+ @override
1265
+ String get description => 'Delete a collection.';
1266
+
1267
+ @override
1268
+ Future<int> run() => runGuarded(() async {
1269
+ banner('pocketbase · collections · delete');
1270
+ final name = argResults!['name'] as String;
1271
+ await _pbStep('Deleting $name', (c) => c.deleteCollection(name), argResults!);
1272
+ step(green('Deleted collection $name'));
1273
+ });
1274
+ }
1275
+
1276
+ class PocketBaseCollectionsTruncateCommand extends FlutterPatcherCommand {
1277
+ PocketBaseCollectionsTruncateCommand() {
1278
+ _addBackendOptions(argParser);
1279
+ argParser.addOption('name', help: 'Collection name or ID.', mandatory: true);
1280
+ }
1281
+
1282
+ @override
1283
+ String get name => 'truncate';
1284
+
1285
+ @override
1286
+ String get description => 'Delete all records in a collection.';
1287
+
1288
+ @override
1289
+ Future<int> run() => runGuarded(() async {
1290
+ banner('pocketbase · collections · truncate');
1291
+ final name = argResults!['name'] as String;
1292
+ await _pbStep('Truncating $name', (c) => c.truncateCollection(name), argResults!);
1293
+ step(green('Truncated $name'));
1294
+ });
1295
+ }
1296
+
1297
+ class PocketBaseCollectionsImportCommand extends FlutterPatcherCommand {
1298
+ PocketBaseCollectionsImportCommand() {
1299
+ _addBackendOptions(argParser);
1300
+ argParser.addOption('file', help: 'JSON file with collections config.', mandatory: true);
1301
+ argParser.addFlag('delete-missing', help: 'Delete collections not in file.', defaultsTo: false);
1302
+ }
1303
+
1304
+ @override
1305
+ String get name => 'import';
1306
+
1307
+ @override
1308
+ String get description => 'Import collections from a JSON file.';
1309
+
1310
+ @override
1311
+ Future<int> run() => runGuarded(() async {
1312
+ banner('pocketbase · collections · import');
1313
+ final r = argResults!;
1314
+ final fp = r['file'] as String;
1315
+ final f = File(fp);
1316
+ if (!await f.exists()) throw PackException('File not found: $fp', 64);
1317
+ final json = jsonDecode(await f.readAsString()) as List<dynamic>;
1318
+ final cols = json.cast<Map<String, dynamic>>();
1319
+ await _pbStep(
1320
+ 'Importing ${cols.length} collections',
1321
+ (c) => c.importCollections(cols, deleteMissing: r['delete-missing'] as bool),
1322
+ r,
1323
+ );
1324
+ step(green('Imported ${cols.length} collections'));
1325
+ });
1326
+ }
1327
+
1328
+ // ---------------------------------------------------------------------------
1329
+ // settings
1330
+ // ---------------------------------------------------------------------------
1331
+
1332
+ class PocketBaseSettingsCommand extends FlutterPatcherCommand {
1333
+ PocketBaseSettingsCommand() {
1334
+ addSubcommand(PocketBaseSettingsListCommand());
1335
+ addSubcommand(PocketBaseSettingsUpdateCommand());
1336
+ addSubcommand(PocketBaseSettingsTestS3Command());
1337
+ addSubcommand(PocketBaseSettingsTestEmailCommand());
1338
+ }
1339
+
1340
+ @override
1341
+ String get name => 'settings';
1342
+
1343
+ @override
1344
+ String get description => 'Manage PocketBase application settings.';
1345
+ }
1346
+
1347
+ class PocketBaseSettingsListCommand extends FlutterPatcherCommand {
1348
+ PocketBaseSettingsListCommand() {
1349
+ _addBackendOptions(argParser);
1350
+ }
1351
+
1352
+ @override
1353
+ String get name => 'list';
1354
+
1355
+ @override
1356
+ String get description => 'List all application settings.';
1357
+
1358
+ @override
1359
+ Future<int> run() => runGuarded(() async {
1360
+ banner('pocketbase · settings · list');
1361
+ final settings = await _pbStep('Fetching settings', (c) => c.listSettings(), argResults!);
1362
+ stdout.writeln(const JsonEncoder.withIndent(' ').convert(settings));
1363
+ });
1364
+ }
1365
+
1366
+ class PocketBaseSettingsUpdateCommand extends FlutterPatcherCommand {
1367
+ PocketBaseSettingsUpdateCommand() {
1368
+ _addBackendOptions(argParser);
1369
+ argParser.addOption('body', help: 'JSON object with settings to update.', mandatory: true);
1370
+ }
1371
+
1372
+ @override
1373
+ String get name => 'update';
1374
+
1375
+ @override
1376
+ String get description => 'Update application settings.';
1377
+
1378
+ @override
1379
+ Future<int> run() => runGuarded(() async {
1380
+ banner('pocketbase · settings · update');
1381
+ final body = jsonDecode(argResults!['body'] as String) as Map<String, dynamic>;
1382
+ await _pbStep('Updating settings', (c) => c.updateSettings(body), argResults!);
1383
+ step(green('Settings updated'));
1384
+ });
1385
+ }
1386
+
1387
+ class PocketBaseSettingsTestS3Command extends FlutterPatcherCommand {
1388
+ PocketBaseSettingsTestS3Command() {
1389
+ _addBackendOptions(argParser);
1390
+ argParser.addOption('filesystem', help: 'Storage filesystem to test.', defaultsTo: 'storage');
1391
+ }
1392
+
1393
+ @override
1394
+ String get name => 'test-s3';
1395
+
1396
+ @override
1397
+ String get description => 'Test S3 storage connection.';
1398
+
1399
+ @override
1400
+ Future<int> run() => runGuarded(() async {
1401
+ banner('pocketbase · settings · test-s3');
1402
+ final fs = argResults!['filesystem'] as String;
1403
+ await _pbStep('Testing S3 ($fs)', (c) => c.testS3(fs), argResults!);
1404
+ step(green('S3 connection OK'));
1405
+ });
1406
+ }
1407
+
1408
+ class PocketBaseSettingsTestEmailCommand extends FlutterPatcherCommand {
1409
+ PocketBaseSettingsTestEmailCommand() {
1410
+ _addBackendOptions(argParser);
1411
+ argParser.addOption('email', help: 'Recipient email.', mandatory: true);
1412
+ argParser.addOption('template', help: 'Email template.', mandatory: true);
1413
+ argParser.addOption('collection', help: 'Auth collection name or ID.');
1414
+ }
1415
+
1416
+ @override
1417
+ String get name => 'test-email';
1418
+
1419
+ @override
1420
+ String get description => 'Send a test email.';
1421
+
1422
+ @override
1423
+ Future<int> run() => runGuarded(() async {
1424
+ banner('pocketbase · settings · test-email');
1425
+ final r = argResults!;
1426
+ await _pbStep(
1427
+ 'Sending test email to ${r['email']}',
1428
+ (c) => c.testEmail(
1429
+ email: r['email'] as String,
1430
+ template: r['template'] as String,
1431
+ collection: r['collection'] as String?,
1432
+ ),
1433
+ r,
1434
+ );
1435
+ step(green('Test email sent'));
1436
+ });
1437
+ }
1438
+
1439
+ // ---------------------------------------------------------------------------
1440
+ // sql
1441
+ // ---------------------------------------------------------------------------
1442
+
1443
+ class PocketBaseSqlCommand extends FlutterPatcherCommand {
1444
+ PocketBaseSqlCommand() {
1445
+ _addBackendOptions(argParser);
1446
+ argParser.addOption('query', help: 'SQL query to execute.', mandatory: true);
1447
+ }
1448
+
1449
+ @override
1450
+ String get name => 'sql';
1451
+
1452
+ @override
1453
+ String get description => 'Execute a raw SQL query.';
1454
+
1455
+ @override
1456
+ Future<int> run() => runGuarded(() async {
1457
+ banner('pocketbase · sql');
1458
+ final query = argResults!['query'] as String;
1459
+ final result = await _pbStep('Executing query', (c) => c.runSql(query), argResults!);
1460
+ final columns = (result['columns'] as List? ?? []).cast<Map<String, dynamic>>();
1461
+ final rows = (result['rows'] as List? ?? []).cast<List<dynamic>>();
1462
+ if (rows.isNotEmpty) {
1463
+ final colNames = columns.map((c) => '${c['name']}').toList();
1464
+ final tableRows = rows.map((r) => r.map((v) => '$v').toList()).toList();
1465
+ table('${rows.length} rows', colNames, tableRows);
1466
+ } else {
1467
+ step('Query executed. ${result['affectedRows'] ?? 0} rows affected.');
1468
+ }
1469
+ });
1470
+ }
1471
+
1472
+ // ---------------------------------------------------------------------------
1473
+ // crons
1474
+ // ---------------------------------------------------------------------------
1475
+
1476
+ class PocketBaseCronsCommand extends FlutterPatcherCommand {
1477
+ PocketBaseCronsCommand() {
1478
+ addSubcommand(PocketBaseCronsListCommand());
1479
+ addSubcommand(PocketBaseCronsRunCommand());
1480
+ }
1481
+
1482
+ @override
1483
+ String get name => 'crons';
1484
+
1485
+ @override
1486
+ String get description => 'Manage PocketBase cron jobs.';
1487
+ }
1488
+
1489
+ class PocketBaseCronsListCommand extends FlutterPatcherCommand {
1490
+ PocketBaseCronsListCommand() {
1491
+ _addBackendOptions(argParser);
1492
+ }
1493
+
1494
+ @override
1495
+ String get name => 'list';
1496
+
1497
+ @override
1498
+ String get description => 'List all registered cron jobs.';
1499
+
1500
+ @override
1501
+ Future<int> run() => runGuarded(() async {
1502
+ banner('pocketbase · crons · list');
1503
+ final crons = await _pbStep('Fetching cron jobs', (c) => c.listCrons(), argResults!);
1504
+ if (crons.isEmpty) {
1505
+ warn('No cron jobs registered.');
1506
+ return;
1507
+ }
1508
+ final rows = crons.map((c) => [
1509
+ cyan(c['id']?.toString() ?? '?'),
1510
+ c['expression']?.toString() ?? '?',
1511
+ ]).toList();
1512
+ table('${crons.length} cron jobs', ['ID', 'EXPRESSION'], rows);
1513
+ });
1514
+ }
1515
+
1516
+ class PocketBaseCronsRunCommand extends FlutterPatcherCommand {
1517
+ PocketBaseCronsRunCommand() {
1518
+ _addBackendOptions(argParser);
1519
+ argParser.addOption('job-id', help: 'Cron job ID to run.', mandatory: true);
1520
+ }
1521
+
1522
+ @override
1523
+ String get name => 'run';
1524
+
1525
+ @override
1526
+ String get description => 'Trigger a cron job by ID.';
1527
+
1528
+ @override
1529
+ Future<int> run() => runGuarded(() async {
1530
+ banner('pocketbase · crons · run');
1531
+ final jobId = argResults!['job-id'] as String;
1532
+ await _pbStep('Running $jobId', (c) => c.runCron(jobId), argResults!);
1533
+ step(green('Cron $jobId triggered'));
1534
+ });
1535
+ }
1536
+
1537
+ // ---------------------------------------------------------------------------
1538
+ // query
1539
+ // ---------------------------------------------------------------------------
1540
+
1541
+ class PocketBaseQueryCommand extends FlutterPatcherCommand {
1542
+ PocketBaseQueryCommand() {
1543
+ _addBackendOptions(argParser);
1544
+ argParser.addOption('method', help: 'HTTP method.', defaultsTo: 'GET');
1545
+ argParser.addOption('path', help: 'API path.', mandatory: true);
1546
+ argParser.addOption('body', help: 'JSON body for POST/PATCH.');
1547
+ }
1548
+
1549
+ @override
1550
+ String get name => 'query';
1551
+
1552
+ @override
1553
+ String get description => 'Execute an arbitrary API request.';
1554
+
1555
+ @override
1556
+ Future<int> run() => runGuarded(() async {
1557
+ banner('pocketbase · query');
1558
+ final r = argResults!;
1559
+ final method = r['method'] as String;
1560
+ final path = r['path'] as String;
1561
+ final bodyStr = r['body'] as String?;
1562
+ Map<String, dynamic>? body;
1563
+ if (bodyStr != null && bodyStr.isNotEmpty) {
1564
+ body = jsonDecode(bodyStr) as Map<String, dynamic>;
1565
+ }
1566
+ final client = PocketBaseClient(_resolveBackend(r).$1);
1567
+ await client.authenticate(_resolveBackend(r).$2, _resolveBackend(r).$3);
1568
+ final res = await client.rawRequest(method, path, body: body);
1569
+ client.close();
1570
+ stdout.writeln(const JsonEncoder.withIndent(' ').convert(jsonDecode(res.body)));
1571
+ });
1572
+ }
1573
+
1574
+ // ---------------------------------------------------------------------------
1575
+ // config
1576
+ // ---------------------------------------------------------------------------
1577
+
1578
+ class PocketBaseConfigCommand extends FlutterPatcherCommand {
1579
+ PocketBaseConfigCommand() {
1580
+ _addBackendOptions(argParser);
1581
+ }
1582
+
1583
+ @override
1584
+ String get name => 'config';
1585
+
1586
+ @override
1587
+ String get description => 'Show current PocketBase configuration.';
1588
+
1589
+ @override
1590
+ Future<int> run() => runGuarded(() async {
1591
+ banner('pocketbase · config');
1592
+ final (url, admin, _) = _resolveBackend(argResults!);
1593
+ final paths = PocketBaseInstallPaths.resolve();
1594
+ final exists = await paths.binaryPath.exists();
1595
+ box('config', [
1596
+ kv('url', cyan(url)),
1597
+ kv('admin', admin),
1598
+ kv('version', paths.version),
1599
+ kv('binary', exists ? green(paths.binaryPath.path) : red('not installed')),
1600
+ ]);
233
1601
  });
234
1602
  }