@_nazmiforreal/flutter-ota 0.1.19 → 0.1.21

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