@_nazmiforreal/flutter-ota 0.1.14 → 0.1.16

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 (39) hide show
  1. package/bin/flutter-ota-linux-x64 +0 -0
  2. package/bin/flutter-ota.js +23 -72
  3. package/dart-src/packages/cli-tools/.dart_tool/package_config.json +0 -2
  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/build.dart +1 -1
  6. package/dart-src/packages/cli-tools/lib/src/commands/bundle.dart +112 -124
  7. package/dart-src/packages/cli-tools/lib/src/commands/channel.dart +20 -28
  8. package/dart-src/packages/cli-tools/lib/src/commands/config_command.dart +40 -18
  9. package/dart-src/packages/cli-tools/lib/src/commands/console.dart +10 -11
  10. package/dart-src/packages/cli-tools/lib/src/commands/deploy.dart +30 -33
  11. package/dart-src/packages/cli-tools/lib/src/commands/doctor.dart +43 -41
  12. package/dart-src/packages/cli-tools/lib/src/commands/fingerprint.dart +9 -10
  13. package/dart-src/packages/cli-tools/lib/src/commands/init.dart +32 -35
  14. package/dart-src/packages/cli-tools/lib/src/commands/keys.dart +3 -6
  15. package/dart-src/packages/cli-tools/lib/src/commands/migrate.dart +59 -50
  16. package/dart-src/packages/cli-tools/lib/src/commands/pocketbase.dart +32 -37
  17. package/dart-src/packages/cli-tools/lib/src/commands/rollback.dart +13 -16
  18. package/dart-src/packages/cli-tools/lib/src/commands/storage.dart +12 -20
  19. package/dart-src/packages/cli-tools/lib/src/config.dart +10 -3
  20. package/dart-src/packages/cli-tools/lib/src/operations.dart +5 -0
  21. package/dart-src/packages/cli-tools/lib/src/ui/ui.dart +225 -31
  22. package/dart-src/packages/cli-tools/lib/src/util.dart +7 -3
  23. package/dart-src/packages/cli-tools/test/cli_test.dart +6 -0
  24. package/dart-src/plugins/supabase/supabase/migrations/20250103114225_init.sql +7 -3
  25. package/dart-src/plugins/supabase/supabase/migrations/20250314000000_hot-updater_0.13.0.sql +7 -4
  26. package/dart-src/plugins/supabase/supabase/migrations/20250516000000_hot-updater_0.18.0.sql +18 -6
  27. package/dart-src/plugins/supabase/supabase/migrations/20251014000000_hot-updater_0.21.0.sql +4 -2
  28. package/dart-src/plugins/supabase/supabase/migrations/20260401000000_hot-updater_0.29.0.sql +4 -2
  29. package/dart-src/plugins/supabase/supabase/migrations/20260520014100_hot-updater_rls.sql +22 -6
  30. package/migrations/supabase/20250103114225_init.sql +7 -3
  31. package/migrations/supabase/20250314000000_hot-updater_0.13.0.sql +7 -4
  32. package/migrations/supabase/20250516000000_hot-updater_0.18.0.sql +18 -6
  33. package/migrations/supabase/20251014000000_hot-updater_0.21.0.sql +4 -2
  34. package/migrations/supabase/20260401000000_hot-updater_0.29.0.sql +4 -2
  35. package/migrations/supabase/20260520014100_hot-updater_rls.sql +22 -6
  36. package/package.json +1 -1
  37. package/scripts/vendor.sh +32 -0
  38. package/migrations/aws/README.md +0 -7
  39. package/migrations/cloudflare/bundles.sql +0 -42
@@ -1,6 +1,5 @@
1
1
  import 'dart:convert';
2
2
 
3
- import 'package:args/args.dart';
4
3
 
5
4
  import '../backend.dart';
6
5
  import '../cli_base.dart';
@@ -10,7 +9,9 @@ import '../ui/ui.dart';
10
9
 
11
10
  /// `flutter_ota_kit keys` — generate an Ed25519 keypair for bundle signing.
12
11
  class KeysCommand extends FlutterPatcherCommand {
13
- KeysCommand({this.config, this.backendOverride});
12
+ KeysCommand({this.config, this.backendOverride}) {
13
+ argParser.addFlag('save', help: 'Persist the public key into the project config.');
14
+ }
14
15
 
15
16
  final FlutterPatcherConfig? config;
16
17
  final Backend? backendOverride;
@@ -22,10 +23,6 @@ class KeysCommand extends FlutterPatcherCommand {
22
23
  String get description =>
23
24
  'Generate an Ed25519 keypair used to sign deployed bundles.';
24
25
 
25
- @override
26
- ArgParser get argParser => ArgParser()
27
- ..addFlag('save', help: 'Persist the public key into the project config.');
28
-
29
26
  @override
30
27
  Future<int> run() => runGuarded(() async {
31
28
  banner('keys');
@@ -1,7 +1,6 @@
1
1
  import 'dart:convert';
2
2
  import 'dart:io';
3
3
 
4
- import 'package:args/args.dart';
5
4
  import 'package:flutter_ota_kit_cli/flutter_ota_kit_cli.dart';
6
5
  import 'package:http/http.dart' as http;
7
6
  import 'package:path/path.dart' as p;
@@ -9,41 +8,38 @@ import 'package:postgres/postgres.dart';
9
8
 
10
9
  import '../ui/ui.dart';
11
10
 
12
- /// `flutter_ota_kit migrate` — run backend SQL migrations.
11
+ /// `flutter-ota migrate` — run backend SQL migrations.
13
12
  class MigrateCommand extends FlutterPatcherCommand {
14
- @override
15
- String get name => 'migrate';
16
-
17
- @override
18
- String get description =>
19
- 'Run SQL migrations against the backend database (Supabase Postgres).';
20
-
21
- @override
22
- ArgParser get argParser => ArgParser()
23
- ..addOption('backend', abbr: 'b', help: 'Backend provider.')
24
- ..addOption(
13
+ MigrateCommand() {
14
+ argParser.addOption('backend', abbr: 'b', help: 'Backend provider.');
15
+ argParser.addOption(
25
16
  'database-url',
26
17
  help: 'Postgres connection string (or DATABASE_URL env).',
27
- )
28
- ..addOption(
18
+ );
19
+ argParser.addOption(
29
20
  'management-key',
30
21
  help:
31
22
  'Supabase Management API key (or SUPABASE_MANAGEMENT_KEY env) — '
32
23
  'runs migrations without a separate Postgres connection.',
33
- )
34
- ..addOption(
24
+ );
25
+ argParser.addOption(
35
26
  'migrations-dir',
36
27
  help: 'Directory of *.sql migration files (ordered by name).',
37
- )
38
- ..addFlag(
28
+ );
29
+ argParser.addFlag(
39
30
  'dry-run',
40
31
  abbr: 'd',
41
32
  help: 'Print migrations instead of applying them.',
42
33
  );
34
+ }
35
+
36
+ @override
37
+ String get name => 'migrate';
38
+
39
+ @override
40
+ String get description =>
41
+ 'Run SQL migrations against the backend database (Supabase Postgres).';
43
42
 
44
- /// Default migrations dir lives next to the CLI entrypoint; each backend has
45
- /// its own dialect's migrations (cloudflare = D1 SQLite, postgres = plain
46
- /// Postgres, supabase = PostgREST, aws = no relational SQL).
47
43
  String defaultMigrationsDir(String provider) {
48
44
  final sub = switch (provider) {
49
45
  'supabase' => 'supabase',
@@ -85,9 +81,6 @@ class MigrateCommand extends FlutterPatcherCommand {
85
81
  Future<int> run() => runGuarded(() async {
86
82
  final provider = argResults!['backend'] as String? ?? 'supabase';
87
83
 
88
- // Backends with no relational SQL handled out-of-band (S3 blob store).
89
- // Short-circuit before any SQL discovery so a missing or empty
90
- // (README-only) migrations dir does not abort.
91
84
  if (provider == 'aws') {
92
85
  final dir =
93
86
  argResults!['migrations-dir'] as String? ??
@@ -95,7 +88,7 @@ class MigrateCommand extends FlutterPatcherCommand {
95
88
  banner('migrate');
96
89
  box('migrate', [
97
90
  'Migrations for "$provider" do not run through this command:',
98
- ' • aws → S3 blob store, no relational SQL (see $dir/README.md)',
91
+ ' · aws → S3 blob store, no relational SQL (see $dir/README.md)',
99
92
  '',
100
93
  'See the README under $dir for the intended setup.',
101
94
  ]);
@@ -153,9 +146,9 @@ class MigrateCommand extends FlutterPatcherCommand {
153
146
  }
154
147
  throw StateError(
155
148
  'For the supabase backend, provide either:\n'
156
- ' • --management-key (or SUPABASE_MANAGEMENT_KEY, or set it via '
149
+ ' · --management-key (or SUPABASE_MANAGEMENT_KEY, or set it via '
157
150
  '`flutter-ota init`) — a Supabase Management API key, OR\n'
158
- ' • --database-url (or DATABASE_URL, or set it via `init`) — a '
151
+ ' · --database-url (or DATABASE_URL, or set it via `init`) — a '
159
152
  'Postgres connection string.',
160
153
  );
161
154
  }
@@ -176,8 +169,6 @@ class MigrateCommand extends FlutterPatcherCommand {
176
169
  throw StateError('Unknown backend provider: "$provider".');
177
170
  });
178
171
 
179
- /// Run migrations through the Supabase Management API (no Postgres creds
180
- /// needed). Derived from the Supabase project ref in the configured URL.
181
172
  Future<void> _runViaManagementApi(String mgmtKey, List<File> files) async {
182
173
  final cfg = resolveSupabaseConfig(
183
174
  loadConfig() ??
@@ -189,7 +180,9 @@ class MigrateCommand extends FlutterPatcherCommand {
189
180
  final ref = Uri.parse(cfg.supabaseUrl).host.split('.').first;
190
181
  final endpoint = 'https://api.supabase.com/v1/projects/$ref/database/query';
191
182
  banner('migrate · supabase (management api)');
183
+ final steps = Steps('migrate');
192
184
  for (final file in files) {
185
+ final sw = Stopwatch()..start();
193
186
  final sql = file.readAsStringSync();
194
187
  final res = await http.post(
195
188
  Uri.parse(endpoint),
@@ -199,20 +192,27 @@ class MigrateCommand extends FlutterPatcherCommand {
199
192
  },
200
193
  body: jsonEncode({'query': sql}),
201
194
  );
195
+ sw.stop();
202
196
  if (res.statusCode >= 400) {
203
- throw StateError(
204
- 'Migration ${p.basename(file.path)} failed '
205
- '(${res.statusCode}): ${res.body}',
197
+ steps.fail(
198
+ '${p.basename(file.path)} (${res.statusCode}): ${res.body}',
206
199
  );
200
+ } else {
201
+ final ms = sw.elapsedMilliseconds;
202
+ final time = ms >= 1000
203
+ ? '${(ms / 1000).toStringAsFixed(1)}s'
204
+ : '${ms}ms';
205
+ steps.success('Applied ${p.basename(file.path)} in $time');
207
206
  }
208
- step('applied ${p.basename(file.path)}');
209
207
  }
210
- await _ensureSupabaseBucket();
208
+ await _ensureSupabaseBucket(steps);
209
+ steps.summary();
210
+ if (steps.hasErrors) {
211
+ throw StateError('One or more migrations failed.');
212
+ }
211
213
  }
212
214
 
213
- /// Create the Supabase Storage bucket (public) if it doesn't exist, so
214
- /// `deploy` can upload artifacts without a manual setup step.
215
- Future<void> _ensureSupabaseBucket() async {
215
+ Future<void> _ensureSupabaseBucket(Steps steps) async {
216
216
  final storage = resolveSupabaseStorageConfig(
217
217
  loadConfig() ??
218
218
  FlutterPatcherConfig(
@@ -221,13 +221,12 @@ class MigrateCommand extends FlutterPatcherCommand {
221
221
  ),
222
222
  );
223
223
  if (storage.supabaseServiceRoleKey == null) {
224
- box('migrate', [
225
- 'Skipped storage bucket creation — set supabase.serviceRoleKey '
226
- '(or SUPABASE_SERVICE_ROLE_KEY) to auto-create the '
227
- '"${storage.bucketName}" bucket.',
228
- ]);
224
+ steps.skip(
225
+ 'Skipped storage bucket creation — set supabase.serviceRoleKey',
226
+ );
229
227
  return;
230
228
  }
229
+ final sw = Stopwatch()..start();
231
230
  final res = await http.post(
232
231
  Uri.parse('${storage.supabaseUrl}/storage/v1/bucket'),
233
232
  headers: {
@@ -237,20 +236,22 @@ class MigrateCommand extends FlutterPatcherCommand {
237
236
  },
238
237
  body: jsonEncode({'name': storage.bucketName, 'public': true}),
239
238
  );
239
+ sw.stop();
240
240
  if (res.statusCode >= 400) {
241
241
  final body = jsonDecode(res.body) as Map<String, dynamic>;
242
242
  final msg = (body['message'] ?? body['error'] ?? '').toString();
243
243
  if (!msg.contains('already exists') && res.statusCode != 409) {
244
- box('migrate', [
245
- 'Warning: could not create bucket "${storage.bucketName}": $msg',
246
- ]);
244
+ steps.fail('Could not create bucket "${storage.bucketName}": $msg');
247
245
  return;
248
246
  }
249
247
  }
250
- step('ensured storage bucket "${storage.bucketName}"');
248
+ final ms = sw.elapsedMilliseconds;
249
+ final time = ms >= 1000
250
+ ? '${(ms / 1000).toStringAsFixed(1)}s'
251
+ : '${ms}ms';
252
+ steps.success('Ensured storage bucket "${storage.bucketName}" in $time');
251
253
  }
252
254
 
253
- /// Original path: connect directly to Postgres and execute each statement.
254
255
  Future<void> _runViaPostgres(String url, List<File> files) async {
255
256
  final uri = Uri.parse(url);
256
257
  final endpoint = Endpoint(
@@ -263,6 +264,7 @@ class MigrateCommand extends FlutterPatcherCommand {
263
264
  : null,
264
265
  );
265
266
  banner('migrate · postgres');
267
+ final steps = Steps('migrate');
266
268
  final conn = await Connection.open(
267
269
  endpoint,
268
270
  settings: ConnectionSettings(sslMode: SslMode.disable),
@@ -281,9 +283,10 @@ class MigrateCommand extends FlutterPatcherCommand {
281
283
  queryMode: QueryMode.simple,
282
284
  );
283
285
  if (existing.isNotEmpty) {
284
- stdout.writeln(' ${dim('skip')} $name (already applied)');
286
+ steps.skip('Skipped $name (already applied)');
285
287
  continue;
286
288
  }
289
+ final sw = Stopwatch()..start();
287
290
  for (final stmt in _splitStatements(file.readAsStringSync())) {
288
291
  await conn.execute(stmt, queryMode: QueryMode.simple);
289
292
  }
@@ -291,10 +294,16 @@ class MigrateCommand extends FlutterPatcherCommand {
291
294
  "INSERT INTO _flutter_ota_kit_migrations(name) VALUES ('$escaped')",
292
295
  queryMode: QueryMode.simple,
293
296
  );
294
- step('applied $name');
297
+ sw.stop();
298
+ final ms = sw.elapsedMilliseconds;
299
+ final time = ms >= 1000
300
+ ? '${(ms / 1000).toStringAsFixed(1)}s'
301
+ : '${ms}ms';
302
+ steps.success('Applied $name in $time');
295
303
  }
296
304
  } finally {
297
305
  await conn.close();
298
306
  }
307
+ steps.summary();
299
308
  }
300
309
  }
@@ -4,7 +4,6 @@ library;
4
4
  import 'dart:async';
5
5
  import 'dart:io';
6
6
 
7
- import 'package:args/args.dart';
8
7
  import 'package:path/path.dart' as p;
9
8
 
10
9
  import '../cli_base.dart';
@@ -28,42 +27,34 @@ class PocketBaseCommand extends FlutterPatcherCommand {
28
27
  @override
29
28
  String get description =>
30
29
  'Manage a local PocketBase instance (install / serve / stop / status).';
31
-
32
- @override
33
- ArgParser get argParser => ArgParser();
34
-
35
- @override
36
- Future<int> run() => runGuarded(() async {
37
- err('Missing subcommand. Use: install | serve | stop | status');
38
- return;
39
- });
40
30
  }
41
31
 
42
32
  class PocketBaseInstallCommand extends FlutterPatcherCommand {
33
+ PocketBaseInstallCommand() {
34
+ argParser.addOption('version', help: 'PocketBase version to install.');
35
+ }
36
+
43
37
  @override
44
38
  String get name => 'install';
45
39
 
46
40
  @override
47
41
  String get description => 'Download + extract the PocketBase binary.';
48
42
 
49
- @override
50
- ArgParser get argParser =>
51
- ArgParser()..addOption('version', help: 'PocketBase version to install.');
52
-
53
43
  @override
54
44
  Future<int> run() => runGuarded(() async {
55
45
  final version =
56
46
  argResults!['version'] as String? ?? kDefaultPocketBaseVersion;
57
47
  final installer = PocketBaseInstaller(version: version);
58
48
  final paths = installer.paths();
59
- step('Installing PocketBase v$version to ${paths.installDir.path}...');
49
+ banner('pocketbase · install');
50
+ final bar = ProgressBar(1, 'Downloading PocketBase v$version');
60
51
  final result = await installer.ensureInstalled(
61
52
  paths: paths,
62
53
  onProgress: (p) {
63
- stdout.write('\r ${(p * 100).toStringAsFixed(0)}% ');
54
+ bar.update((p * 100).round());
64
55
  },
65
56
  );
66
- stdout.writeln();
57
+ bar.close();
67
58
  if (result.alreadyInstalled) {
68
59
  step('Already installed at ${paths.binaryPath.path}');
69
60
  } else {
@@ -77,26 +68,26 @@ class PocketBaseInstallCommand extends FlutterPatcherCommand {
77
68
  }
78
69
 
79
70
  class PocketBaseServeCommand extends FlutterPatcherCommand {
71
+ PocketBaseServeCommand() {
72
+ argParser.addOption('version', help: 'PocketBase version.');
73
+ argParser.addOption('port', help: 'PB HTTP port.', defaultsTo: '8090');
74
+ argParser.addOption('host', help: 'PB bind address.', defaultsTo: '127.0.0.1');
75
+ argParser.addOption('data-dir', help: 'PB data directory.');
76
+ argParser.addOption('admin-email', help: 'Bootstrap admin email.');
77
+ argParser.addOption('admin-password', help: 'Bootstrap admin password.');
78
+ argParser.addFlag(
79
+ 'install-hooks',
80
+ help: 'Copy bundled JS hooks into the data dir on start.',
81
+ defaultsTo: true,
82
+ );
83
+ }
84
+
80
85
  @override
81
86
  String get name => 'serve';
82
87
 
83
88
  @override
84
89
  String get description =>
85
- 'Start a local PocketBase + install the flutter_ota_kit schema.';
86
-
87
- @override
88
- ArgParser get argParser => ArgParser()
89
- ..addOption('version', help: 'PocketBase version.')
90
- ..addOption('port', help: 'PB HTTP port.', defaultsTo: '8090')
91
- ..addOption('host', help: 'PB bind address.', defaultsTo: '127.0.0.1')
92
- ..addOption('data-dir', help: 'PB data directory.')
93
- ..addOption('admin-email', help: 'Bootstrap admin email.')
94
- ..addOption('admin-password', help: 'Bootstrap admin password.')
95
- ..addFlag(
96
- 'install-hooks',
97
- help: 'Copy bundled JS hooks into the data dir on start.',
98
- defaultsTo: true,
99
- );
90
+ 'Start a local PocketBase + install the flutter-ota schema.';
100
91
 
101
92
  @override
102
93
  Future<int> run() => runGuarded(() async {
@@ -146,7 +137,7 @@ class PocketBaseServeCommand extends FlutterPatcherCommand {
146
137
  step('PocketBase is running (pid ${manager.process!.pid}).');
147
138
 
148
139
  if (adminEmail != null && adminPassword != null) {
149
- step('Installing flutter_ota_kit schema...');
140
+ step('Installing flutter-ota schema...');
150
141
  final schemaInstaller = PocketBaseSchemaInstaller(
151
142
  url: 'http://$host:$port',
152
143
  adminEmail: adminEmail,
@@ -168,7 +159,7 @@ class PocketBaseServeCommand extends FlutterPatcherCommand {
168
159
  }
169
160
 
170
161
  banner('pocketbase');
171
- box('flutter_ota_kit pocketbase serve', [
162
+ box('flutter-ota pocketbase serve', [
172
163
  'Local PocketBase is running:',
173
164
  '',
174
165
  kv('url', 'http://$host:$port'),
@@ -179,9 +170,13 @@ class PocketBaseServeCommand extends FlutterPatcherCommand {
179
170
 
180
171
  // Wait for SIGINT/SIGTERM.
181
172
  final done = Completer<void>();
182
- ProcessSignal.sigint.watch().listen((_) => done.complete());
173
+ ProcessSignal.sigint.watch().listen((_) {
174
+ if (!done.isCompleted) done.complete();
175
+ });
183
176
  if (!Platform.isWindows) {
184
- ProcessSignal.sigterm.watch().listen((_) => done.complete());
177
+ ProcessSignal.sigterm.watch().listen((_) {
178
+ if (!done.isCompleted) done.complete();
179
+ });
185
180
  }
186
181
  await done.future;
187
182
  step('Shutting down PocketBase...');
@@ -229,7 +224,7 @@ class PocketBaseStatusCommand extends FlutterPatcherCommand {
229
224
  );
230
225
  } else {
231
226
  step(
232
- 'PocketBase: not installed. Run `flutter_ota_kit pocketbase install`.',
227
+ 'PocketBase: not installed. Run `flutter-ota pocketbase install`.',
233
228
  );
234
229
  }
235
230
  return;
@@ -1,12 +1,20 @@
1
- import 'package:args/args.dart';
2
1
  import 'package:flutter_ota_kit_cli/flutter_ota_kit_cli.dart';
3
2
  import 'package:flutter_ota_kit_core/flutter_ota_kit_core.dart';
4
3
 
5
4
  import '../ui/ui.dart';
6
5
 
7
- /// `flutter_ota_kit rollback` — roll a channel back to a previous bundle.
6
+ /// `flutter-ota rollback` — roll a channel back to a previous bundle.
8
7
  class RollbackCommand extends FlutterPatcherCommand {
9
- RollbackCommand({this.config, this.backendOverride});
8
+ RollbackCommand({this.config, this.backendOverride}) {
9
+ argParser.addOption('backend', abbr: 'b', help: 'Backend provider.');
10
+ argParser.addOption('channel', abbr: 'c', help: 'Channel to roll back.');
11
+ argParser.addOption(
12
+ 'bundle-id',
13
+ abbr: 'i',
14
+ help: 'Roll back to this specific bundle id.',
15
+ );
16
+ argParser.addOption('platform', abbr: 'p', help: 'Platform filter (e.g. android).');
17
+ }
10
18
 
11
19
  final FlutterPatcherConfig? config;
12
20
  final Backend? backendOverride;
@@ -18,23 +26,12 @@ class RollbackCommand extends FlutterPatcherCommand {
18
26
  String get description =>
19
27
  'Roll a channel back: disable the latest enabled bundle (or a specific --bundle-id).';
20
28
 
21
- @override
22
- ArgParser get argParser => ArgParser()
23
- ..addOption('backend', abbr: 'b', help: 'Backend provider.')
24
- ..addOption('channel', abbr: 'c', help: 'Channel to roll back.')
25
- ..addOption(
26
- 'bundle-id',
27
- abbr: 'i',
28
- help: 'Roll back to this specific bundle id.',
29
- )
30
- ..addOption('platform', abbr: 'p', help: 'Platform filter (e.g. android).');
31
-
32
29
  @override
33
30
  Future<int> run() => runGuarded(() async {
34
31
  final channel = argResults!['channel'] as String?;
35
32
  if (channel == null || channel.isEmpty) {
36
33
  throw PackException(
37
- 'Usage: flutter_ota_kit rollback --channel <channel> [--bundle-id <id>]',
34
+ 'Usage: flutter-ota rollback --channel <channel> [--bundle-id <id>]',
38
35
  64,
39
36
  );
40
37
  }
@@ -67,7 +64,7 @@ class RollbackCommand extends FlutterPatcherCommand {
67
64
  );
68
65
  box('rollback', [
69
66
  kv('channel', channel),
70
- kv('now live', cyan(live)),
67
+ kv('now live', live.isEmpty ? dim('(none)') : cyan(live)),
71
68
  kv('disabled', disabled.isEmpty ? dim('(none)') : disabled.join(', ')),
72
69
  ]);
73
70
  });
@@ -1,4 +1,3 @@
1
- import 'package:args/args.dart';
2
1
  import 'package:flutter_ota_kit_cli/flutter_ota_kit_cli.dart';
3
2
 
4
3
  import '../ui/ui.dart';
@@ -27,7 +26,10 @@ class StorageCommand extends FlutterPatcherCommand {
27
26
  }
28
27
 
29
28
  class StorageListCommand extends FlutterPatcherCommand {
30
- StorageListCommand({this.config, this.backendOverride});
29
+ StorageListCommand({this.config, this.backendOverride}) {
30
+ argParser.addOption('backend', abbr: 'b', help: 'Backend provider.');
31
+ argParser.addOption('prefix', help: 'Key prefix filter (e.g. bundles).');
32
+ }
31
33
 
32
34
  final FlutterPatcherConfig? config;
33
35
  final Backend? backendOverride;
@@ -38,11 +40,6 @@ class StorageListCommand extends FlutterPatcherCommand {
38
40
  @override
39
41
  String get description => 'List storage objects.';
40
42
 
41
- @override
42
- ArgParser get argParser => ArgParser()
43
- ..addOption('backend', abbr: 'b', help: 'Backend provider.')
44
- ..addOption('prefix', help: 'Key prefix filter (e.g. bundles).');
45
-
46
43
  @override
47
44
  Future<int> run() => runGuarded(() async {
48
45
  final cfg = effectiveConfig(config ?? loadConfig(), argResults!);
@@ -56,24 +53,25 @@ class StorageListCommand extends FlutterPatcherCommand {
56
53
  step('(no objects)');
57
54
  return;
58
55
  }
59
- final lines = <String>[];
56
+ final rows = <List<String>>[];
60
57
  for (final o in objects) {
61
58
  final size = o.size >= 1024 * 1024
62
59
  ? '${(o.size / (1024 * 1024)).toStringAsFixed(2)} MB'
63
60
  : o.size >= 1024
64
61
  ? '${(o.size / 1024).toStringAsFixed(1)} KB'
65
62
  : '${o.size} B';
66
- lines
67
- ..add(kv('key', cyan(o.key)))
68
- ..add(kv('size', size))
69
- ..add('');
63
+ rows.add([cyan(o.key), size]);
70
64
  }
71
- box('${objects.length} objects', lines);
65
+ table('${objects.length} objects', ['KEY', 'SIZE'], rows);
72
66
  });
73
67
  }
74
68
 
75
69
  class StorageDeleteCommand extends FlutterPatcherCommand {
76
- StorageDeleteCommand({this.config, this.backendOverride});
70
+ StorageDeleteCommand({this.config, this.backendOverride}) {
71
+ argParser.addOption('backend', abbr: 'b', help: 'Backend provider.');
72
+ argParser.addMultiOption('key', help: 'Storage key to delete (repeatable).');
73
+ argParser.addOption('uri', help: 'Full storage URI to delete.');
74
+ }
77
75
 
78
76
  final FlutterPatcherConfig? config;
79
77
  final Backend? backendOverride;
@@ -84,12 +82,6 @@ class StorageDeleteCommand extends FlutterPatcherCommand {
84
82
  @override
85
83
  String get description => 'Delete storage object(s) by key or full URI.';
86
84
 
87
- @override
88
- ArgParser get argParser => ArgParser()
89
- ..addOption('backend', abbr: 'b', help: 'Backend provider.')
90
- ..addMultiOption('key', help: 'Storage key to delete (repeatable).')
91
- ..addOption('uri', help: 'Full storage URI to delete.');
92
-
93
85
  @override
94
86
  Future<int> run() => runGuarded(() async {
95
87
  final keys = (argResults!['key'] as List<String>?) ?? <String>[];
@@ -165,9 +165,16 @@ FlutterPatcherConfig? loadConfig() {
165
165
  if (file.existsSync()) {
166
166
  final raw = file.readAsStringSync();
167
167
  if (raw.trim().isEmpty) continue;
168
- return FlutterPatcherConfig.fromJson(
169
- jsonDecode(raw) as Map<String, dynamic>,
170
- );
168
+ try {
169
+ return FlutterPatcherConfig.fromJson(
170
+ jsonDecode(raw) as Map<String, dynamic>,
171
+ );
172
+ } on FormatException {
173
+ stderr.writeln(
174
+ 'Warning: invalid config at ${file.path} — ignoring.',
175
+ );
176
+ return null;
177
+ }
171
178
  }
172
179
  }
173
180
  return null;
@@ -60,6 +60,11 @@ Future<Bundle> deployBundle(Backend backend, DeployOptions opts) async {
60
60
  'Use only one of target-app-version / fingerprint-hash (DB CHECK).',
61
61
  );
62
62
  }
63
+ if (opts.targetAppVersion == null && opts.fingerprintHash == null) {
64
+ throw StateError(
65
+ 'Provide either --target-app-version or --fingerprint-hash.',
66
+ );
67
+ }
63
68
  // Resolve the artifact to upload. If the source is already a zip (e.g. the
64
69
  // `patch.zip` produced by `flutter_ota_kit build`/`pack`), upload it directly.
65
70
  // Otherwise, if the source directory contains a `patch.zip`, use that. As a