@_nazmiforreal/flutter-ota 0.1.20 → 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.
- package/dart-src/packages/cli-tools/lib/flutter_ota_kit_cli.dart +2 -1
- package/dart-src/packages/cli-tools/lib/src/cli_base.dart +3 -2
- package/dart-src/packages/cli-tools/lib/src/commands/bundle.dart +22 -10
- package/dart-src/packages/cli-tools/lib/src/commands/channel.dart +16 -5
- package/dart-src/packages/cli-tools/lib/src/commands/config_command.dart +3 -0
- package/dart-src/packages/cli-tools/lib/src/commands/console.dart +7 -8
- package/dart-src/packages/cli-tools/lib/src/commands/deploy.dart +12 -3
- package/dart-src/packages/cli-tools/lib/src/commands/doctor.dart +129 -6
- package/dart-src/packages/cli-tools/lib/src/commands/fingerprint.dart +2 -1
- package/dart-src/packages/cli-tools/lib/src/commands/init.dart +23 -14
- package/dart-src/packages/cli-tools/lib/src/commands/keys.dart +4 -0
- package/dart-src/packages/cli-tools/lib/src/commands/migrate.dart +266 -26
- package/dart-src/packages/cli-tools/lib/src/commands/pocketbase.dart +96 -58
- package/dart-src/packages/cli-tools/lib/src/commands/rollback.dart +4 -1
- package/dart-src/packages/cli-tools/lib/src/commands/storage.dart +10 -9
- package/dart-src/packages/cli-tools/lib/src/config.dart +14 -19
- package/dart-src/packages/cli-tools/lib/src/operations.dart +1 -1
- package/dart-src/packages/cli-tools/lib/src/pocketbase/process_manager.dart +10 -2
- package/dart-src/packages/cli-tools/lib/src/pocketbase/schema_installer.dart +0 -1
- package/dart-src/packages/cli-tools/lib/src/ui/ui.dart +2 -2
- package/dart-src/packages/core/lib/flutter_ota_kit_core.dart +2 -0
- package/dart-src/packages/core/lib/src/app_update_info.dart +71 -1
- package/dart-src/packages/core/lib/src/bundle.dart +62 -17
- package/dart-src/packages/core/lib/src/bundle_artifacts.dart +7 -3
- package/dart-src/packages/core/lib/src/bundle_patch_artifact.dart +16 -0
- package/dart-src/packages/core/lib/src/changed_asset.dart +33 -3
- package/dart-src/packages/core/lib/src/get_bundles_args.dart +24 -2
- package/dart-src/packages/core/lib/src/platform.dart +8 -0
- package/dart-src/packages/core/lib/src/semver.dart +0 -3
- package/dart-src/packages/core/lib/src/status.dart +20 -1
- package/dart-src/packages/core/lib/src/strategy.dart +10 -0
- package/dart-src/packages/core/lib/src/update_bundle_params.dart +15 -0
- package/dart-src/packages/core/lib/src/uuid.dart +11 -0
- package/dart-src/plugins/aws/lib/src/aws_cloudfront_client.dart +2 -2
- package/dart-src/plugins/plugin-core/lib/src/asset_storage_layout.dart +3 -0
- package/dart-src/plugins/plugin-core/lib/src/parse_storage_uri.dart +17 -0
- package/dart-src/plugins/plugin-core/lib/src/types.dart +94 -3
- package/dart-src/plugins/pocketbase/lib/src/pocketbase_client.dart +3 -0
- package/package.json +1 -1
- package/bin/flutter-ota-linux-x64 +0 -0
- package/bin/flutter-ota.js +0 -36
- package/dart-src/packages/cli-tools/.dart_tool/package_config.json +0 -460
- package/dart-src/packages/cli-tools/.dart_tool/package_graph.json +0 -707
- package/dart-src/packages/cli-tools/pubspec.lock +0 -590
|
@@ -10,8 +10,11 @@ import '../ui/ui.dart';
|
|
|
10
10
|
|
|
11
11
|
/// `flutter-ota migrate` — run backend SQL migrations.
|
|
12
12
|
class MigrateCommand extends FlutterPatcherCommand {
|
|
13
|
-
MigrateCommand() {
|
|
14
|
-
|
|
13
|
+
MigrateCommand({this.config, this.backendOverride}) {
|
|
14
|
+
final detected = config?.provider ?? loadConfig()?.provider;
|
|
15
|
+
argParser.addOption('backend', abbr: 'b', help: detected != null
|
|
16
|
+
? 'Backend provider [detected: $detected].'
|
|
17
|
+
: 'Backend provider.');
|
|
15
18
|
argParser.addOption(
|
|
16
19
|
'database-url',
|
|
17
20
|
help: 'Postgres connection string (or DATABASE_URL env).',
|
|
@@ -26,6 +29,34 @@ class MigrateCommand extends FlutterPatcherCommand {
|
|
|
26
29
|
'migrations-dir',
|
|
27
30
|
help: 'Directory of *.sql migration files (ordered by name).',
|
|
28
31
|
);
|
|
32
|
+
argParser.addOption(
|
|
33
|
+
'account-id',
|
|
34
|
+
help: 'Cloudflare account ID (or CLOUDFLARE_ACCOUNT_ID env).',
|
|
35
|
+
);
|
|
36
|
+
argParser.addOption(
|
|
37
|
+
'api-token',
|
|
38
|
+
help: 'Cloudflare API token (or CLOUDFLARE_API_TOKEN env).',
|
|
39
|
+
);
|
|
40
|
+
argParser.addOption(
|
|
41
|
+
'd1-database-id',
|
|
42
|
+
help: 'Cloudflare D1 database ID (or CLOUDFLARE_D1_DATABASE_ID env).',
|
|
43
|
+
);
|
|
44
|
+
argParser.addOption(
|
|
45
|
+
'r2-bucket',
|
|
46
|
+
help: 'Cloudflare R2 bucket name (or R2_BUCKET env, default: bundles).',
|
|
47
|
+
);
|
|
48
|
+
argParser.addOption(
|
|
49
|
+
'pocketbase-url',
|
|
50
|
+
help: 'PocketBase URL (or POCKETBASE_URL env).',
|
|
51
|
+
);
|
|
52
|
+
argParser.addOption(
|
|
53
|
+
'pocketbase-admin-email',
|
|
54
|
+
help: 'PocketBase admin email (or POCKETBASE_ADMIN_EMAIL env).',
|
|
55
|
+
);
|
|
56
|
+
argParser.addOption(
|
|
57
|
+
'pocketbase-admin-password',
|
|
58
|
+
help: 'PocketBase admin password (or POCKETBASE_ADMIN_PASSWORD env).',
|
|
59
|
+
);
|
|
29
60
|
argParser.addFlag(
|
|
30
61
|
'dry-run',
|
|
31
62
|
abbr: 'd',
|
|
@@ -33,12 +64,15 @@ class MigrateCommand extends FlutterPatcherCommand {
|
|
|
33
64
|
);
|
|
34
65
|
}
|
|
35
66
|
|
|
67
|
+
final FlutterPatcherConfig? config;
|
|
68
|
+
final Backend? backendOverride;
|
|
69
|
+
|
|
36
70
|
@override
|
|
37
71
|
String get name => 'migrate';
|
|
38
72
|
|
|
39
73
|
@override
|
|
40
74
|
String get description =>
|
|
41
|
-
'Run SQL migrations against the backend database (Supabase Postgres).';
|
|
75
|
+
'Run SQL migrations against the backend database (Supabase, Postgres, Cloudflare, AWS, PocketBase).';
|
|
42
76
|
|
|
43
77
|
String defaultMigrationsDir(String provider) {
|
|
44
78
|
final sub = switch (provider) {
|
|
@@ -79,19 +113,51 @@ class MigrateCommand extends FlutterPatcherCommand {
|
|
|
79
113
|
|
|
80
114
|
@override
|
|
81
115
|
Future<int> run() => runGuarded(() async {
|
|
82
|
-
final
|
|
116
|
+
final cfg = effectiveConfig(config ?? loadConfig(), argResults!);
|
|
117
|
+
final provider = argResults!['backend'] as String? ?? cfg?.provider ?? 'supabase';
|
|
83
118
|
|
|
84
119
|
if (provider == 'aws') {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
120
|
+
banner('migrate · aws');
|
|
121
|
+
step('AWS stores bundle metadata as JSON in S3 — no SQL migrations needed.');
|
|
122
|
+
step('The S3 bucket and object prefix are created automatically on first deploy.');
|
|
123
|
+
step('No action required. Run `flutter-ota deploy` to get started.');
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (provider == 'pocketbase') {
|
|
128
|
+
final pbUrl =
|
|
129
|
+
argResults!['pocketbase-url'] as String? ??
|
|
130
|
+
Platform.environment['POCKETBASE_URL'] ??
|
|
131
|
+
cfg?.pocketbase.url;
|
|
132
|
+
final pbAdmin =
|
|
133
|
+
argResults!['pocketbase-admin-email'] as String? ??
|
|
134
|
+
Platform.environment['POCKETBASE_ADMIN_EMAIL'] ??
|
|
135
|
+
cfg?.pocketbase.adminEmail;
|
|
136
|
+
final pbPass =
|
|
137
|
+
argResults!['pocketbase-admin-password'] as String? ??
|
|
138
|
+
Platform.environment['POCKETBASE_ADMIN_PASSWORD'] ??
|
|
139
|
+
cfg?.pocketbase.adminPassword;
|
|
140
|
+
if (pbUrl == null || pbUrl.isEmpty) {
|
|
141
|
+
throw StateError(
|
|
142
|
+
'PocketBase requires a URL. Set POCKETBASE_URL env var, '
|
|
143
|
+
'or `flutter-ota init pocketbase`.',
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
banner('migrate · pocketbase');
|
|
147
|
+
final installer = PocketBaseSchemaInstaller(
|
|
148
|
+
url: pbUrl,
|
|
149
|
+
adminEmail: pbAdmin ?? '',
|
|
150
|
+
adminPassword: pbPass ?? '',
|
|
151
|
+
);
|
|
152
|
+
final res = await installer.install();
|
|
153
|
+
final steps = Steps('migrate');
|
|
154
|
+
if (res.created.isNotEmpty) {
|
|
155
|
+
steps.success('Created: ${res.created.join(', ')}');
|
|
156
|
+
}
|
|
157
|
+
if (res.skipped.isNotEmpty) {
|
|
158
|
+
steps.success('Already present: ${res.skipped.join(', ')}');
|
|
159
|
+
}
|
|
160
|
+
steps.summary();
|
|
95
161
|
return;
|
|
96
162
|
}
|
|
97
163
|
|
|
@@ -110,20 +176,49 @@ class MigrateCommand extends FlutterPatcherCommand {
|
|
|
110
176
|
}
|
|
111
177
|
|
|
112
178
|
if (provider == 'cloudflare') {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
179
|
+
final cfCfg = cfg ??
|
|
180
|
+
FlutterPatcherConfig(
|
|
181
|
+
provider: 'cloudflare',
|
|
182
|
+
supabase: const SupabaseConfigJson(),
|
|
183
|
+
cloudflare: const CloudflareConfigJson(),
|
|
184
|
+
);
|
|
185
|
+
final accountId =
|
|
186
|
+
argResults!['account-id'] as String? ??
|
|
187
|
+
Platform.environment['CLOUDFLARE_ACCOUNT_ID'] ??
|
|
188
|
+
cfCfg.cloudflare.accountId;
|
|
189
|
+
final apiToken =
|
|
190
|
+
argResults!['api-token'] as String? ??
|
|
191
|
+
Platform.environment['CLOUDFLARE_API_TOKEN'] ??
|
|
192
|
+
cfCfg.cloudflare.apiToken;
|
|
193
|
+
final dbId =
|
|
194
|
+
argResults!['d1-database-id'] as String? ??
|
|
195
|
+
Platform.environment['CLOUDFLARE_D1_DATABASE_ID'] ??
|
|
196
|
+
cfCfg.cloudflare.d1DatabaseId;
|
|
197
|
+
final r2Bucket =
|
|
198
|
+
argResults!['r2-bucket'] as String? ??
|
|
199
|
+
Platform.environment['R2_BUCKET'] ??
|
|
200
|
+
cfCfg.cloudflare.r2Bucket ??
|
|
201
|
+
'bundles';
|
|
202
|
+
|
|
203
|
+
if (accountId == null || apiToken == null) {
|
|
204
|
+
throw StateError(
|
|
205
|
+
'Cloudflare requires accountId and apiToken. Set them via '
|
|
206
|
+
'--account-id/--api-token, CLOUDFLARE_* env vars, or '
|
|
207
|
+
'`flutter-ota init cloudflare`.',
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
await _runCloudflareMigration(
|
|
211
|
+
accountId: accountId,
|
|
212
|
+
apiToken: apiToken,
|
|
213
|
+
databaseId: dbId,
|
|
214
|
+
r2Bucket: r2Bucket,
|
|
215
|
+
files: files,
|
|
216
|
+
);
|
|
121
217
|
return;
|
|
122
218
|
}
|
|
123
219
|
|
|
124
220
|
if (provider == 'supabase') {
|
|
125
|
-
final cfg
|
|
126
|
-
loadConfig() ??
|
|
221
|
+
final sbCfg = cfg ??
|
|
127
222
|
FlutterPatcherConfig(
|
|
128
223
|
provider: 'supabase',
|
|
129
224
|
supabase: const SupabaseConfigJson(),
|
|
@@ -131,11 +226,11 @@ class MigrateCommand extends FlutterPatcherCommand {
|
|
|
131
226
|
final mgmtKey =
|
|
132
227
|
argResults!['management-key'] as String? ??
|
|
133
228
|
Platform.environment['SUPABASE_MANAGEMENT_KEY'] ??
|
|
134
|
-
|
|
229
|
+
sbCfg.supabase.managementKey;
|
|
135
230
|
final pgUrl =
|
|
136
231
|
argResults!['database-url'] as String? ??
|
|
137
232
|
Platform.environment['DATABASE_URL'] ??
|
|
138
|
-
|
|
233
|
+
sbCfg.supabase.databaseUrl;
|
|
139
234
|
if (mgmtKey != null && mgmtKey.isNotEmpty) {
|
|
140
235
|
await _runViaManagementApi(mgmtKey, files);
|
|
141
236
|
return;
|
|
@@ -306,4 +401,149 @@ class MigrateCommand extends FlutterPatcherCommand {
|
|
|
306
401
|
}
|
|
307
402
|
steps.summary();
|
|
308
403
|
}
|
|
404
|
+
|
|
405
|
+
/// Auto-provision Cloudflare backend: create D1 database, run SQL, create R2.
|
|
406
|
+
///
|
|
407
|
+
/// Called from `init` to match Supabase's fully-automated flow.
|
|
408
|
+
static Future<void> runCloudflareMigration({
|
|
409
|
+
required String accountId,
|
|
410
|
+
required String apiToken,
|
|
411
|
+
String? databaseId,
|
|
412
|
+
String? r2Bucket,
|
|
413
|
+
}) async {
|
|
414
|
+
final sub = 'cloudflare';
|
|
415
|
+
final dir = p.join(
|
|
416
|
+
p.dirname(Platform.script.path),
|
|
417
|
+
'..',
|
|
418
|
+
'migrations',
|
|
419
|
+
sub,
|
|
420
|
+
);
|
|
421
|
+
final migrationsDir = Directory(dir);
|
|
422
|
+
if (!migrationsDir.existsSync()) {
|
|
423
|
+
throw StateError('Migrations directory not found: $dir');
|
|
424
|
+
}
|
|
425
|
+
final files =
|
|
426
|
+
migrationsDir
|
|
427
|
+
.listSync()
|
|
428
|
+
.whereType<File>()
|
|
429
|
+
.where((f) => f.path.endsWith('.sql'))
|
|
430
|
+
.toList()
|
|
431
|
+
..sort((a, b) => a.path.compareTo(b.path));
|
|
432
|
+
if (files.isEmpty) {
|
|
433
|
+
throw StateError('No *.sql files in $dir');
|
|
434
|
+
}
|
|
435
|
+
await _runCloudflareMigration(
|
|
436
|
+
accountId: accountId,
|
|
437
|
+
apiToken: apiToken,
|
|
438
|
+
databaseId: databaseId,
|
|
439
|
+
r2Bucket: r2Bucket ?? 'bundles',
|
|
440
|
+
files: files,
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
static Future<void> _runCloudflareMigration({
|
|
445
|
+
required String accountId,
|
|
446
|
+
required String apiToken,
|
|
447
|
+
required String? databaseId,
|
|
448
|
+
required String r2Bucket,
|
|
449
|
+
required List<File> files,
|
|
450
|
+
}) async {
|
|
451
|
+
final headers = {
|
|
452
|
+
'Authorization': 'Bearer $apiToken',
|
|
453
|
+
'Content-Type': 'application/json',
|
|
454
|
+
};
|
|
455
|
+
final baseUrl = 'https://api.cloudflare.com/client/v4';
|
|
456
|
+
|
|
457
|
+
banner('migrate · cloudflare');
|
|
458
|
+
final steps = Steps('migrate');
|
|
459
|
+
|
|
460
|
+
// --- D1 database ---
|
|
461
|
+
var dbId = databaseId;
|
|
462
|
+
if (dbId == null || dbId.isEmpty) {
|
|
463
|
+
// Create a new D1 database
|
|
464
|
+
final sw = Stopwatch()..start();
|
|
465
|
+
final res = await http.post(
|
|
466
|
+
Uri.parse('$baseUrl/accounts/$accountId/d1/database'),
|
|
467
|
+
headers: headers,
|
|
468
|
+
body: jsonEncode({'name': 'flutter-ota-kit'}),
|
|
469
|
+
);
|
|
470
|
+
sw.stop();
|
|
471
|
+
final body = jsonDecode(res.body) as Map<String, dynamic>;
|
|
472
|
+
if (res.statusCode >= 400 || body['success'] != true) {
|
|
473
|
+
final errors = body['errors'] as List? ?? [];
|
|
474
|
+
final msg = errors.isNotEmpty ? errors.first['message'] : res.body;
|
|
475
|
+
steps.fail('Could not create D1 database: $msg');
|
|
476
|
+
steps.summary();
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
dbId = body['result']?['uuid'] as String?;
|
|
480
|
+
if (dbId == null || dbId.isEmpty) {
|
|
481
|
+
steps.fail('D1 database created but no UUID returned');
|
|
482
|
+
steps.summary();
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
final ms = sw.elapsedMilliseconds;
|
|
486
|
+
final time =
|
|
487
|
+
ms >= 1000 ? '${(ms / 1000).toStringAsFixed(1)}s' : '${ms}ms';
|
|
488
|
+
steps.success('Created D1 database ($dbId) in $time');
|
|
489
|
+
} else {
|
|
490
|
+
steps.success('Using existing D1 database ($dbId)');
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// --- Run SQL migrations against D1 ---
|
|
494
|
+
for (final file in files) {
|
|
495
|
+
final sw = Stopwatch()..start();
|
|
496
|
+
final sql = file.readAsStringSync();
|
|
497
|
+
final res = await http.post(
|
|
498
|
+
Uri.parse('$baseUrl/accounts/$accountId/d1/database/$dbId/query'),
|
|
499
|
+
headers: headers,
|
|
500
|
+
body: jsonEncode({'sql': sql}),
|
|
501
|
+
);
|
|
502
|
+
sw.stop();
|
|
503
|
+
final body = jsonDecode(res.body) as Map<String, dynamic>;
|
|
504
|
+
if (res.statusCode >= 400 || body['success'] != true) {
|
|
505
|
+
final errors = body['errors'] as List? ?? [];
|
|
506
|
+
final msg = errors.isNotEmpty ? errors.first['message'] : res.body;
|
|
507
|
+
steps.fail('${p.basename(file.path)} ($res.statusCode): $msg');
|
|
508
|
+
} else {
|
|
509
|
+
final ms = sw.elapsedMilliseconds;
|
|
510
|
+
final time =
|
|
511
|
+
ms >= 1000 ? '${(ms / 1000).toStringAsFixed(1)}s' : '${ms}ms';
|
|
512
|
+
steps.success('Applied ${p.basename(file.path)} in $time');
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// --- R2 bucket ---
|
|
517
|
+
final sw = Stopwatch()..start();
|
|
518
|
+
final r2Res = await http.put(
|
|
519
|
+
Uri.parse('$baseUrl/accounts/$accountId/s3/buckets/$r2Bucket'),
|
|
520
|
+
headers: headers,
|
|
521
|
+
);
|
|
522
|
+
sw.stop();
|
|
523
|
+
final r2Body = jsonDecode(r2Res.body) as Map<String, dynamic>;
|
|
524
|
+
if (r2Res.statusCode >= 400 || r2Body['success'] != true) {
|
|
525
|
+
final errors = r2Body['errors'] as List? ?? [];
|
|
526
|
+
final msg = errors.isNotEmpty ? errors.first['message'] : r2Res.body;
|
|
527
|
+
// Bucket already exists is not an error
|
|
528
|
+
if (!msg.toLowerCase().contains('already exists') &&
|
|
529
|
+
r2Res.statusCode != 409) {
|
|
530
|
+
steps.fail('Could not create R2 bucket "$r2Bucket": $msg');
|
|
531
|
+
} else {
|
|
532
|
+
final ms = sw.elapsedMilliseconds;
|
|
533
|
+
final time =
|
|
534
|
+
ms >= 1000 ? '${(ms / 1000).toStringAsFixed(1)}s' : '${ms}ms';
|
|
535
|
+
steps.success('Ensured R2 bucket "$r2Bucket" in $time');
|
|
536
|
+
}
|
|
537
|
+
} else {
|
|
538
|
+
final ms = sw.elapsedMilliseconds;
|
|
539
|
+
final time =
|
|
540
|
+
ms >= 1000 ? '${(ms / 1000).toStringAsFixed(1)}s' : '${ms}ms';
|
|
541
|
+
steps.success('Created R2 bucket "$r2Bucket" in $time');
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
steps.summary();
|
|
545
|
+
if (steps.hasErrors) {
|
|
546
|
+
throw StateError('One or more migrations failed.');
|
|
547
|
+
}
|
|
548
|
+
}
|
|
309
549
|
}
|
|
@@ -46,7 +46,7 @@ class PocketBaseCommand extends FlutterPatcherCommand {
|
|
|
46
46
|
String get description =>
|
|
47
47
|
'Manage a local PocketBase instance (install / serve / stop / '
|
|
48
48
|
'status / backup / export / import / admin / migrate / health / '
|
|
49
|
-
'logs / records / collections / settings / sql / crons).';
|
|
49
|
+
'logs / records / collections / settings / sql / crons / query / config).';
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
class PocketBaseInstallCommand extends FlutterPatcherCommand {
|
|
@@ -225,18 +225,36 @@ class PocketBaseServeCommand extends FlutterPatcherCommand {
|
|
|
225
225
|
}
|
|
226
226
|
|
|
227
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
|
+
|
|
228
233
|
@override
|
|
229
234
|
String get name => 'stop';
|
|
230
235
|
|
|
231
236
|
@override
|
|
232
|
-
String get description =>
|
|
233
|
-
'Hint to stop a running PocketBase (Ctrl+C in serve).';
|
|
237
|
+
String get description => 'Stop a running PocketBase instance.';
|
|
234
238
|
|
|
235
239
|
@override
|
|
236
240
|
Future<int> run() => runGuarded(() async {
|
|
237
241
|
banner('pocketbase · stop');
|
|
238
|
-
|
|
239
|
-
|
|
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.');
|
|
240
258
|
});
|
|
241
259
|
}
|
|
242
260
|
|
|
@@ -300,6 +318,11 @@ void _addBackendOptions(ArgParser parser) {
|
|
|
300
318
|
parser.addOption('data-dir', help: 'PB data directory.');
|
|
301
319
|
}
|
|
302
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
|
+
|
|
303
326
|
(String url, String adminEmail, String adminPassword) _resolveBackend(ArgResults r) {
|
|
304
327
|
final url = r['url'] as String? ?? _detectRunningUrl(r);
|
|
305
328
|
if (url == null || url.isEmpty) {
|
|
@@ -500,16 +523,20 @@ class PocketBaseBackupDownloadCommand extends FlutterPatcherCommand {
|
|
|
500
523
|
banner('pocketbase · backup · download');
|
|
501
524
|
final key = argResults!['name'] as String;
|
|
502
525
|
final output = argResults!['output'] as String;
|
|
503
|
-
final
|
|
504
|
-
final (url, _, _) = _resolveBackend(argResults!);
|
|
526
|
+
final (url, admin, pass) = _resolveBackend(argResults!);
|
|
505
527
|
final dlClient = PocketBaseClient(url);
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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
|
+
}
|
|
513
540
|
});
|
|
514
541
|
}
|
|
515
542
|
|
|
@@ -566,39 +593,43 @@ class PocketBaseExportCommand extends FlutterPatcherCommand {
|
|
|
566
593
|
final collection = r['collection'] as String?;
|
|
567
594
|
final output = r['output'] as String;
|
|
568
595
|
|
|
569
|
-
final
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
collections
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
final
|
|
579
|
-
|
|
580
|
-
|
|
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']);
|
|
581
614
|
}
|
|
582
615
|
}
|
|
583
|
-
if (collections.isEmpty) {
|
|
584
|
-
collections.addAll(['bundles', 'channels', 'audit_log', 'bundles_patches']);
|
|
585
|
-
}
|
|
586
|
-
}
|
|
587
616
|
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
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
|
+
}
|
|
599
629
|
}
|
|
630
|
+
} finally {
|
|
631
|
+
client.close();
|
|
600
632
|
}
|
|
601
|
-
client.close();
|
|
602
633
|
});
|
|
603
634
|
}
|
|
604
635
|
|
|
@@ -677,7 +708,7 @@ class PocketBaseAdminCreateCommand extends FlutterPatcherCommand {
|
|
|
677
708
|
(c) => c.createAdmin(email: email, password: password, passwordConfirm: password),
|
|
678
709
|
r,
|
|
679
710
|
);
|
|
680
|
-
|
|
711
|
+
stdout.writeln(' ${dim('→')} admin ${cyan(result['id'] ?? '?')} $email');
|
|
681
712
|
});
|
|
682
713
|
}
|
|
683
714
|
|
|
@@ -808,11 +839,10 @@ class PocketBaseHealthCommand extends FlutterPatcherCommand {
|
|
|
808
839
|
final code = health['code'] as int? ?? 0;
|
|
809
840
|
final message = health['message'] as String? ?? '';
|
|
810
841
|
final data = health['data'] as Map<String, dynamic>? ?? {};
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
]);
|
|
842
|
+
final status = code == 200 ? green('healthy') : red('unhealthy');
|
|
843
|
+
step('status $status');
|
|
844
|
+
step('message $message');
|
|
845
|
+
step('canBackup ${data['canBackup'] ?? false}');
|
|
816
846
|
});
|
|
817
847
|
}
|
|
818
848
|
|
|
@@ -875,7 +905,7 @@ class PocketBaseLogsListCommand extends FlutterPatcherCommand {
|
|
|
875
905
|
final msg = (log['message'] as String? ?? '').length > 60
|
|
876
906
|
? '${(log['message'] as String).substring(0, 57)}...'
|
|
877
907
|
: log['message'] as String? ?? '';
|
|
878
|
-
return [lvl, log['created']?.toString()
|
|
908
|
+
return [lvl, _safeSubstring(log['created']?.toString(), 19), msg];
|
|
879
909
|
}).toList();
|
|
880
910
|
table('$total logs', ['LEVEL', 'TIME', 'MESSAGE'], rows);
|
|
881
911
|
});
|
|
@@ -906,7 +936,7 @@ class PocketBaseLogsStatsCommand extends FlutterPatcherCommand {
|
|
|
906
936
|
return;
|
|
907
937
|
}
|
|
908
938
|
final rows = stats.map((s) => [
|
|
909
|
-
s['date']?.toString()
|
|
939
|
+
_safeSubstring(s['date']?.toString(), 16),
|
|
910
940
|
'${s['total'] ?? 0}',
|
|
911
941
|
]).toList();
|
|
912
942
|
table('${stats.length} entries', ['TIME', 'COUNT'], rows);
|
|
@@ -991,9 +1021,13 @@ class PocketBaseRecordsListCommand extends FlutterPatcherCommand {
|
|
|
991
1021
|
}
|
|
992
1022
|
final rows = data.items.map((r) {
|
|
993
1023
|
final m = r as Map<String, dynamic>;
|
|
994
|
-
|
|
1024
|
+
final label = m['name']?.toString() ??
|
|
1025
|
+
m['updated']?.toString() ??
|
|
1026
|
+
m['created']?.toString() ??
|
|
1027
|
+
'';
|
|
1028
|
+
return [cyan(m['id']?.toString() ?? '?'), label];
|
|
995
1029
|
}).toList();
|
|
996
|
-
table('${data.totalItems} records', ['ID', '
|
|
1030
|
+
table('${data.totalItems} records', ['ID', 'LABEL'], rows);
|
|
997
1031
|
});
|
|
998
1032
|
}
|
|
999
1033
|
|
|
@@ -1050,7 +1084,7 @@ class PocketBaseRecordsCreateCommand extends FlutterPatcherCommand {
|
|
|
1050
1084
|
(c) => c.createRecord<dynamic>(r['collection'] as String, body, (j) => j),
|
|
1051
1085
|
r,
|
|
1052
1086
|
);
|
|
1053
|
-
|
|
1087
|
+
stdout.writeln(' ${dim('→')} record ${cyan((record as Map)['id']?.toString() ?? '?')}');
|
|
1054
1088
|
});
|
|
1055
1089
|
}
|
|
1056
1090
|
|
|
@@ -1563,11 +1597,15 @@ class PocketBaseQueryCommand extends FlutterPatcherCommand {
|
|
|
1563
1597
|
if (bodyStr != null && bodyStr.isNotEmpty) {
|
|
1564
1598
|
body = jsonDecode(bodyStr) as Map<String, dynamic>;
|
|
1565
1599
|
}
|
|
1566
|
-
final
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
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
|
+
}
|
|
1571
1609
|
});
|
|
1572
1610
|
}
|
|
1573
1611
|
|
|
@@ -6,7 +6,10 @@ import '../ui/ui.dart';
|
|
|
6
6
|
/// `flutter-ota rollback` — roll a channel back to a previous bundle.
|
|
7
7
|
class RollbackCommand extends FlutterPatcherCommand {
|
|
8
8
|
RollbackCommand({this.config, this.backendOverride}) {
|
|
9
|
-
|
|
9
|
+
final detected = config?.provider ?? loadConfig()?.provider;
|
|
10
|
+
argParser.addOption('backend', abbr: 'b', help: detected != null
|
|
11
|
+
? 'Backend provider [detected: $detected].'
|
|
12
|
+
: 'Backend provider.');
|
|
10
13
|
argParser.addOption('channel', abbr: 'c', help: 'Channel to roll back.');
|
|
11
14
|
argParser.addOption(
|
|
12
15
|
'bundle-id',
|
|
@@ -22,12 +22,15 @@ class StorageCommand extends FlutterPatcherCommand {
|
|
|
22
22
|
|
|
23
23
|
@override
|
|
24
24
|
String get description =>
|
|
25
|
-
'Inspect and manage bundle storage objects (
|
|
25
|
+
'Inspect and manage bundle storage objects (list / delete).';
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
class StorageListCommand extends FlutterPatcherCommand {
|
|
29
29
|
StorageListCommand({this.config, this.backendOverride}) {
|
|
30
|
-
|
|
30
|
+
final detected = config?.provider ?? loadConfig()?.provider;
|
|
31
|
+
argParser.addOption('backend', abbr: 'b', help: detected != null
|
|
32
|
+
? 'Backend provider [detected: $detected].'
|
|
33
|
+
: 'Backend provider.');
|
|
31
34
|
argParser.addOption('prefix', help: 'Key prefix filter (e.g. bundles).');
|
|
32
35
|
}
|
|
33
36
|
|
|
@@ -57,12 +60,7 @@ class StorageListCommand extends FlutterPatcherCommand {
|
|
|
57
60
|
}
|
|
58
61
|
final rows = <List<String>>[];
|
|
59
62
|
for (final o in objects) {
|
|
60
|
-
|
|
61
|
-
? '${(o.size / (1024 * 1024)).toStringAsFixed(2)} MB'
|
|
62
|
-
: o.size >= 1024
|
|
63
|
-
? '${(o.size / 1024).toStringAsFixed(1)} KB'
|
|
64
|
-
: '${o.size} B';
|
|
65
|
-
rows.add([cyan(o.key), size]);
|
|
63
|
+
rows.add([cyan(o.key), humanSize(o.size)]);
|
|
66
64
|
}
|
|
67
65
|
table('${objects.length} objects', ['KEY', 'SIZE'], rows);
|
|
68
66
|
steps.summary();
|
|
@@ -71,7 +69,10 @@ class StorageListCommand extends FlutterPatcherCommand {
|
|
|
71
69
|
|
|
72
70
|
class StorageDeleteCommand extends FlutterPatcherCommand {
|
|
73
71
|
StorageDeleteCommand({this.config, this.backendOverride}) {
|
|
74
|
-
|
|
72
|
+
final detected = config?.provider ?? loadConfig()?.provider;
|
|
73
|
+
argParser.addOption('backend', abbr: 'b', help: detected != null
|
|
74
|
+
? 'Backend provider [detected: $detected].'
|
|
75
|
+
: 'Backend provider.');
|
|
75
76
|
argParser.addMultiOption('key', help: 'Storage key to delete (repeatable).');
|
|
76
77
|
argParser.addOption('uri', help: 'Full storage URI to delete.');
|
|
77
78
|
}
|