@_nazmiforreal/flutter-ota 0.1.5 → 0.1.7

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 (147) hide show
  1. package/README.md +13 -13
  2. package/bin/{flutter-patcher-linux-x64 → flutter-ota-linux-x64} +0 -0
  3. package/bin/{flutter-patcher.js → flutter-ota.js} +4 -4
  4. package/dart-src/packages/cli-tools/bin/{flutter_patcher.dart → flutter_ota_kit.dart} +2 -2
  5. package/dart-src/packages/cli-tools/lib/{flutter_patcher_cli.dart → flutter_ota_kit_cli.dart} +5 -3
  6. package/dart-src/packages/cli-tools/lib/src/backend.dart +6 -25
  7. package/dart-src/packages/cli-tools/lib/src/cli_base.dart +10 -4
  8. package/dart-src/packages/cli-tools/lib/src/commands/build.dart +5 -3
  9. package/dart-src/packages/cli-tools/lib/src/commands/bundle.dart +279 -10
  10. package/dart-src/packages/cli-tools/lib/src/commands/channel.dart +7 -7
  11. package/dart-src/packages/cli-tools/lib/src/commands/config_command.dart +5 -5
  12. package/dart-src/packages/cli-tools/lib/src/commands/console.dart +4 -4
  13. package/dart-src/packages/cli-tools/lib/src/commands/deploy.dart +4 -4
  14. package/dart-src/packages/cli-tools/lib/src/commands/doctor.dart +3 -3
  15. package/dart-src/packages/cli-tools/lib/src/commands/fingerprint.dart +1 -1
  16. package/dart-src/packages/cli-tools/lib/src/commands/init.dart +294 -45
  17. package/dart-src/packages/cli-tools/lib/src/commands/keys.dart +1 -1
  18. package/dart-src/packages/cli-tools/lib/src/commands/migrate.dart +218 -69
  19. package/dart-src/packages/cli-tools/lib/src/commands/rollback.dart +5 -5
  20. package/dart-src/packages/cli-tools/lib/src/commands/storage.dart +117 -0
  21. package/dart-src/packages/cli-tools/lib/src/config.dart +29 -78
  22. package/dart-src/packages/cli-tools/lib/src/operations.dart +4 -4
  23. package/dart-src/packages/cli-tools/lib/src/pack.dart +12 -6
  24. package/dart-src/packages/cli-tools/lib/src/runner.dart +25 -6
  25. package/dart-src/packages/cli-tools/lib/src/ui/ui.dart +5 -3
  26. package/dart-src/packages/cli-tools/lib/src/verbose.dart +3 -0
  27. package/dart-src/packages/cli-tools/migrations/aws/README.md +7 -0
  28. package/dart-src/packages/cli-tools/migrations/postgres/bundles.sql +45 -0
  29. package/dart-src/packages/cli-tools/migrations/postgres/get_target_app_version_list.sql +21 -0
  30. package/dart-src/packages/cli-tools/migrations/postgres/get_update_info_by_app_version.sql +126 -0
  31. package/dart-src/packages/cli-tools/migrations/postgres/get_update_info_by_fingerprint_hash.sql +125 -0
  32. package/dart-src/packages/cli-tools/migrations/postgres/hash_user_id.sql +30 -0
  33. package/dart-src/packages/cli-tools/migrations/postgres/is_device_eligible.sql +237 -0
  34. package/dart-src/packages/cli-tools/migrations/supabase/20260520014100_hot-updater_rls.sql +11 -0
  35. package/dart-src/packages/cli-tools/pubspec.yaml +9 -11
  36. package/dart-src/packages/cli-tools/test/backends_test.dart +4 -4
  37. package/dart-src/packages/cli-tools/test/build_pack_test.dart +1 -1
  38. package/dart-src/packages/cli-tools/test/cli_test.dart +3 -3
  39. package/dart-src/packages/cli-tools/test/mocks/mock_aws_s3_client.dart +2 -2
  40. package/dart-src/packages/cli-tools/test/mocks/mock_d1_client.dart +2 -2
  41. package/dart-src/packages/cli-tools/test/mocks/mock_postgres_client.dart +7 -7
  42. package/dart-src/packages/core/lib/{flutter_patcher_core.dart → flutter_ota_kit_core.dart} +1 -1
  43. package/dart-src/packages/core/pubspec.yaml +2 -2
  44. package/dart-src/packages/core/test/rollout_test.dart +1 -1
  45. package/dart-src/packages/core/test/semver_test.dart +1 -1
  46. package/dart-src/packages/core/test/types_test.dart +2 -2
  47. package/dart-src/packages/core/test/uuid_test.dart +1 -1
  48. package/dart-src/packages/core/tool/dbg.dart +1 -1
  49. package/dart-src/plugins/aws/lib/{flutter_patcher_aws.dart → flutter_ota_kit_aws.dart} +0 -2
  50. package/dart-src/plugins/aws/lib/src/aws_cloudfront_client.dart +1 -1
  51. package/dart-src/plugins/aws/lib/src/aws_database.dart +1 -1
  52. package/dart-src/plugins/aws/lib/src/aws_s3_client.dart +1 -1
  53. package/dart-src/plugins/aws/lib/src/aws_storage.dart +1 -1
  54. package/dart-src/plugins/aws/lib/src/aws_storage_profile.dart +25 -3
  55. package/dart-src/plugins/aws/lib/src/with_cloudfront_signed_url.dart +1 -1
  56. package/dart-src/plugins/aws/pubspec.yaml +4 -4
  57. package/dart-src/plugins/aws/test/aws_database_test.dart +3 -3
  58. package/dart-src/plugins/aws/test/aws_storage_test.dart +2 -2
  59. package/dart-src/plugins/aws/test/mock_aws_s3_client.dart +2 -2
  60. package/dart-src/plugins/cloudflare/lib/src/cloudflare_worker_database.dart +1 -1
  61. package/dart-src/plugins/cloudflare/lib/src/d1_build_where.dart +1 -1
  62. package/dart-src/plugins/cloudflare/lib/src/d1_bundle_mapper.dart +1 -1
  63. package/dart-src/plugins/cloudflare/lib/src/d1_database.dart +1 -1
  64. package/dart-src/plugins/cloudflare/lib/src/d1_database_plugin.dart +2 -2
  65. package/dart-src/plugins/cloudflare/lib/src/r2_s3_client.dart +1 -1
  66. package/dart-src/plugins/cloudflare/lib/src/r2_storage.dart +1 -1
  67. package/dart-src/plugins/cloudflare/lib/src/r2_storage_profile.dart +1 -1
  68. package/dart-src/plugins/cloudflare/pubspec.yaml +4 -4
  69. package/dart-src/plugins/cloudflare/test/cloudflare_worker_database_test.dart +2 -2
  70. package/dart-src/plugins/cloudflare/test/d1_bundle_mapper_test.dart +2 -2
  71. package/dart-src/plugins/cloudflare/test/d1_database_test.dart +2 -2
  72. package/dart-src/plugins/cloudflare/test/mock_d1_client.dart +2 -2
  73. package/dart-src/plugins/cloudflare/test/mock_r2_client.dart +2 -2
  74. package/dart-src/plugins/cloudflare/test/r2_storage_test.dart +2 -2
  75. package/dart-src/plugins/plugin-core/lib/{flutter_patcher_plugin_core.dart → flutter_ota_kit_plugin_core.dart} +1 -1
  76. package/dart-src/plugins/plugin-core/lib/src/create_blob_database_plugin.dart +1 -1
  77. package/dart-src/plugins/plugin-core/lib/src/filter_compatible_app_versions.dart +1 -1
  78. package/dart-src/plugins/plugin-core/lib/src/get_update_info.dart +1 -1
  79. package/dart-src/plugins/plugin-core/lib/src/request_update_bundle_state.dart +1 -1
  80. package/dart-src/plugins/plugin-core/lib/src/resolve_update_info_from_bundles.dart +1 -1
  81. package/dart-src/plugins/plugin-core/lib/src/types.dart +2 -2
  82. package/dart-src/plugins/plugin-core/pubspec.yaml +3 -3
  83. package/dart-src/plugins/plugin-core/test/asset_storage_layout_test.dart +3 -3
  84. package/dart-src/plugins/plugin-core/test/bundle_unit_of_work_store_test.dart +2 -2
  85. package/dart-src/plugins/plugin-core/test/bundle_unit_of_work_test.dart +2 -2
  86. package/dart-src/plugins/plugin-core/test/create_blob_database_plugin_test.dart +2 -2
  87. package/dart-src/plugins/plugin-core/test/create_database_plugin_test.dart +5 -5
  88. package/dart-src/plugins/plugin-core/test/create_storage_plugin_test.dart +2 -2
  89. package/dart-src/plugins/plugin-core/test/generate_min_bundle_id_test.dart +2 -2
  90. package/dart-src/plugins/plugin-core/test/get_update_info_test.dart +2 -2
  91. package/dart-src/plugins/plugin-core/test/helpers_test.dart +2 -2
  92. package/dart-src/plugins/plugin-core/test/query_bundles_test.dart +2 -2
  93. package/dart-src/plugins/plugin-core/test/request_update_bundle_state_test.dart +3 -3
  94. package/dart-src/plugins/plugin-core/test/uuidv7_test.dart +1 -1
  95. package/dart-src/plugins/postgres/lib/{flutter_patcher_postgres.dart → flutter_ota_kit_postgres.dart} +1 -1
  96. package/dart-src/plugins/postgres/lib/src/postgres_bundle_mapper.dart +1 -1
  97. package/dart-src/plugins/postgres/lib/src/postgres_database.dart +2 -2
  98. package/dart-src/plugins/postgres/lib/src/postgres_get_update_info.dart +2 -2
  99. package/dart-src/plugins/postgres/lib/src/postgres_storage.dart +9 -9
  100. package/dart-src/plugins/postgres/lib/src/postgres_types.dart +1 -1
  101. package/dart-src/plugins/postgres/pubspec.yaml +4 -4
  102. package/dart-src/plugins/postgres/test/mock_postgres_client.dart +2 -2
  103. package/dart-src/plugins/postgres/test/postgres_bundle_mapper_test.dart +2 -2
  104. package/dart-src/plugins/postgres/test/postgres_database_test.dart +2 -2
  105. package/dart-src/plugins/supabase/lib/edge.dart +1 -1
  106. package/dart-src/plugins/supabase/lib/{flutter_patcher_supabase.dart → flutter_ota_kit_supabase.dart} +1 -1
  107. package/dart-src/plugins/supabase/lib/src/supabase_bundle_mapper.dart +1 -1
  108. package/dart-src/plugins/supabase/lib/src/supabase_client_http.dart +60 -20
  109. package/dart-src/plugins/supabase/lib/src/supabase_database.dart +2 -2
  110. package/dart-src/plugins/supabase/lib/src/supabase_edge_function_database.dart +1 -1
  111. package/dart-src/plugins/supabase/lib/src/supabase_edge_function_storage.dart +1 -1
  112. package/dart-src/plugins/supabase/lib/src/supabase_storage.dart +2 -5
  113. package/dart-src/plugins/supabase/pubspec.yaml +4 -4
  114. package/dart-src/plugins/supabase/test/mock_supabase_client.dart +5 -5
  115. package/dart-src/plugins/supabase/test/supabase_bundle_mapper_test.dart +3 -3
  116. package/dart-src/plugins/supabase/test/supabase_database_test.dart +3 -3
  117. package/dart-src/plugins/supabase/test/supabase_storage_test.dart +2 -2
  118. package/migrations/aws/README.md +7 -0
  119. package/migrations/cloudflare/0001_hot-updater_init.sql +16 -0
  120. package/migrations/cloudflare/0002_hot-updater_0.13.0.sql +8 -0
  121. package/migrations/cloudflare/0003_hot-updater_0.18.0.sql +38 -0
  122. package/migrations/cloudflare/0004_hot-updater_0.29.0.sql +11 -0
  123. package/migrations/cloudflare/0005_hot-updater_0.31.0.sql +24 -0
  124. package/migrations/cloudflare/bundles.sql +42 -0
  125. package/migrations/postgres/bundles.sql +45 -0
  126. package/migrations/postgres/get_target_app_version_list.sql +21 -0
  127. package/migrations/postgres/get_update_info_by_app_version.sql +126 -0
  128. package/migrations/postgres/get_update_info_by_fingerprint_hash.sql +125 -0
  129. package/migrations/postgres/hash_user_id.sql +30 -0
  130. package/migrations/postgres/is_device_eligible.sql +237 -0
  131. package/package.json +4 -4
  132. package/scripts/postinstall.js +15 -15
  133. package/scripts/vendor.sh +1 -1
  134. package/dart-src/packages/cli-tools/.dart_tool/package_config.json +0 -496
  135. package/dart-src/packages/cli-tools/.dart_tool/package_graph.json +0 -743
  136. package/dart-src/packages/cli-tools/pubspec.lock +0 -638
  137. package/dart-src/packages/cli-tools/test/standalone_backend_test.dart +0 -315
  138. package/dart-src/plugins/aws/lib/src/aws_lambda_edge_storage.dart +0 -72
  139. package/dart-src/plugins/aws/test/aws_lambda_edge_storage_test.dart +0 -190
  140. package/dart-src/plugins/standalone/.gitkeep +0 -0
  141. package/dart-src/plugins/standalone/lib/flutter_patcher_standalone.dart +0 -12
  142. package/dart-src/plugins/standalone/lib/src/standalone_config.dart +0 -107
  143. package/dart-src/plugins/standalone/lib/src/standalone_database.dart +0 -233
  144. package/dart-src/plugins/standalone/lib/src/standalone_storage.dart +0 -175
  145. package/dart-src/plugins/standalone/pubspec.yaml +0 -18
  146. package/dart-src/plugins/standalone/test/standalone_test.dart +0 -264
  147. /package/dart-src/plugins/cloudflare/lib/{flutter_patcher_cloudflare.dart → flutter_ota_kit_cloudflare.dart} +0 -0
@@ -1,13 +1,15 @@
1
+ import 'dart:convert';
1
2
  import 'dart:io';
2
3
 
3
4
  import 'package:args/args.dart';
4
- import 'package:flutter_patcher_cli/flutter_patcher_cli.dart';
5
+ import 'package:flutter_ota_kit_cli/flutter_ota_kit_cli.dart';
6
+ import 'package:http/http.dart' as http;
5
7
  import 'package:path/path.dart' as p;
6
8
  import 'package:postgres/postgres.dart';
7
9
 
8
10
  import '../ui/ui.dart';
9
11
 
10
- /// `flutter_patcher migrate` — run backend SQL migrations.
12
+ /// `flutter_ota_kit migrate` — run backend SQL migrations.
11
13
  class MigrateCommand extends FlutterPatcherCommand {
12
14
  @override
13
15
  String get name => 'migrate';
@@ -21,20 +23,44 @@ class MigrateCommand extends FlutterPatcherCommand {
21
23
  ..addOption('backend', abbr: 'b', help: 'Backend provider.')
22
24
  ..addOption('database-url',
23
25
  help: 'Postgres connection string (or DATABASE_URL env).')
26
+ ..addOption('management-key',
27
+ help: 'Supabase Management API key (or SUPABASE_MANAGEMENT_KEY env) — '
28
+ 'runs migrations without a separate Postgres connection.')
24
29
  ..addOption('migrations-dir',
25
30
  help: 'Directory of *.sql migration files (ordered by name).')
26
- ..addFlag('dry-run', help: 'Print migrations instead of applying them.');
31
+ ..addFlag('dry-run', abbr: 'd', help: 'Print migrations instead of applying them.');
27
32
 
28
- /// Default migrations dir lives next to the CLI entrypoint; the backend
29
- /// selects which dialect's migrations are used (cloudflare = D1 SQLite).
33
+ /// Default migrations dir lives next to the CLI entrypoint; each backend has
34
+ /// its own dialect's migrations (cloudflare = D1 SQLite, postgres = plain
35
+ /// Postgres, supabase = PostgREST, aws = no relational SQL).
30
36
  String defaultMigrationsDir(String provider) {
31
37
  final sub = switch (provider) {
38
+ 'supabase' => 'supabase',
39
+ 'postgres' => 'postgres',
32
40
  'cloudflare' => 'cloudflare',
41
+ 'aws' => 'aws',
33
42
  _ => 'supabase',
34
43
  };
35
44
  return p.join(p.dirname(Platform.script.path), '..', 'migrations', sub);
36
45
  }
37
46
 
47
+ List<File> _listMigrations(String dir) {
48
+ final migrationsDir = Directory(dir);
49
+ if (!migrationsDir.existsSync()) {
50
+ throw StateError('Migrations directory not found: $dir');
51
+ }
52
+ final files = migrationsDir
53
+ .listSync()
54
+ .whereType<File>()
55
+ .where((f) => f.path.endsWith('.sql'))
56
+ .toList()
57
+ ..sort((a, b) => a.path.compareTo(b.path));
58
+ if (files.isEmpty) {
59
+ throw StateError('No *.sql files in $dir');
60
+ }
61
+ return files;
62
+ }
63
+
38
64
  List<String> _splitStatements(String sql) {
39
65
  return sql
40
66
  .split(';')
@@ -46,21 +72,26 @@ class MigrateCommand extends FlutterPatcherCommand {
46
72
  @override
47
73
  Future<int> run() => runGuarded(() async {
48
74
  final provider = argResults!['backend'] as String? ?? 'supabase';
75
+
76
+ // Backends with no relational SQL handled out-of-band (S3 blob store).
77
+ // Short-circuit before any SQL discovery so a missing or empty
78
+ // (README-only) migrations dir does not abort.
79
+ if (provider == 'aws') {
80
+ final dir = argResults!['migrations-dir'] as String? ??
81
+ defaultMigrationsDir(provider);
82
+ banner('migrate');
83
+ box('migrate', [
84
+ 'Migrations for "$provider" do not run through this command:',
85
+ ' • aws → S3 blob store, no relational SQL (see $dir/README.md)',
86
+ '',
87
+ 'See the README under $dir for the intended setup.',
88
+ ]);
89
+ return;
90
+ }
91
+
49
92
  final dir = argResults!['migrations-dir'] as String? ??
50
93
  defaultMigrationsDir(provider);
51
- final migrationsDir = Directory(dir);
52
- if (!migrationsDir.existsSync()) {
53
- throw StateError('Migrations directory not found: $dir');
54
- }
55
- final files = migrationsDir
56
- .listSync()
57
- .whereType<File>()
58
- .where((f) => f.path.endsWith('.sql'))
59
- .toList()
60
- ..sort((a, b) => a.path.compareTo(b.path));
61
- if (files.isEmpty) {
62
- throw StateError('No *.sql files in $dir');
63
- }
94
+ final files = _listMigrations(dir);
64
95
 
65
96
  if (argResults!['dry-run'] as bool) {
66
97
  banner('migrate · dry-run');
@@ -71,67 +102,185 @@ class MigrateCommand extends FlutterPatcherCommand {
71
102
  return;
72
103
  }
73
104
 
74
- final url = argResults!['database-url'] as String? ??
75
- Platform.environment['DATABASE_URL'];
76
- if (provider == 'cloudflare' || provider == 'aws') {
105
+ if (provider == 'cloudflare') {
77
106
  banner('migrate');
78
107
  box('migrate', [
79
- 'Migrations for "$provider" are applied via its platform tooling:',
80
- ' • cloudflare → `wrangler d1 execute`',
81
- ' • aws → AWS console (S3/DynamoDB)',
108
+ 'Cloudflare (D1 SQLite) migrations run via `wrangler d1 execute`:',
109
+ ' wrangler d1 execute <DB> --file=$dir/0001_hot-updater_init.sql',
110
+ ' (apply each *.sql under $dir in filename order)',
82
111
  '',
83
- 'The SQL files under ${dir} show the intended schema.',
112
+ 'The SQL files under $dir show the intended D1 schema.',
84
113
  ]);
85
114
  return;
86
115
  }
87
- if (url == null || url.isEmpty) {
88
- throw StateError('Provide --database-url or set DATABASE_URL.');
89
- }
90
116
 
91
- final uri = Uri.parse(url);
92
- final endpoint = Endpoint(
93
- host: uri.host,
94
- port: uri.port == 0 ? 5432 : uri.port,
95
- database: uri.path.isEmpty ? 'postgres' : uri.path.substring(1),
96
- username: uri.userInfo.isEmpty ? null : uri.userInfo.split(':').first,
97
- password: uri.userInfo.contains(':')
98
- ? uri.userInfo.split(':').last
99
- : null,
100
- );
101
- banner('migrate');
102
- final conn = await Connection.open(
103
- endpoint,
104
- settings: ConnectionSettings(sslMode: SslMode.disable),
105
- );
106
- try {
107
- await conn.execute(
108
- 'CREATE TABLE IF NOT EXISTS _flutter_patcher_migrations '
109
- '(name text primary key, applied_at timestamptz default now())',
110
- queryMode: QueryMode.simple,
117
+ if (provider == 'supabase') {
118
+ final cfg = loadConfig() ??
119
+ FlutterPatcherConfig(
120
+ provider: 'supabase',
121
+ supabase: const SupabaseConfigJson(),
111
122
  );
112
- for (final file in files) {
113
- final name = p.basename(file.path);
114
- final escaped = name.replaceAll("'", "''");
115
- final existing = await conn.execute(
116
- "SELECT 1 FROM _flutter_patcher_migrations WHERE name = '$escaped'",
117
- queryMode: QueryMode.simple,
118
- );
119
- if (existing.isNotEmpty) {
120
- stdout.writeln(' ${dim('skip')} $name (already applied)');
121
- continue;
122
- }
123
- final statements = _splitStatements(file.readAsStringSync());
124
- for (final stmt in statements) {
125
- await conn.execute(stmt, queryMode: QueryMode.simple);
126
- }
127
- await conn.execute(
128
- "INSERT INTO _flutter_patcher_migrations(name) VALUES ('$escaped')",
129
- queryMode: QueryMode.simple,
123
+ final mgmtKey = argResults!['management-key'] as String? ??
124
+ Platform.environment['SUPABASE_MANAGEMENT_KEY'] ??
125
+ cfg.supabase.managementKey;
126
+ final pgUrl = argResults!['database-url'] as String? ??
127
+ Platform.environment['DATABASE_URL'] ??
128
+ cfg.supabase.databaseUrl;
129
+ if (mgmtKey != null && mgmtKey.isNotEmpty) {
130
+ await _runViaManagementApi(mgmtKey, files);
131
+ return;
132
+ }
133
+ if (pgUrl != null && pgUrl.isNotEmpty) {
134
+ await _runViaPostgres(pgUrl, files);
135
+ return;
136
+ }
137
+ throw StateError(
138
+ 'For the supabase backend, provide either:\n'
139
+ ' • --management-key (or SUPABASE_MANAGEMENT_KEY, or set it via '
140
+ '`flutter-ota init`) — a Supabase Management API key, OR\n'
141
+ ' • --database-url (or DATABASE_URL, or set it via `init`) — a '
142
+ 'Postgres connection string.',
143
+ );
144
+ }
145
+
146
+ if (provider == 'postgres') {
147
+ final pgUrl = argResults!['database-url'] as String? ??
148
+ Platform.environment['DATABASE_URL'];
149
+ if (pgUrl == null || pgUrl.isEmpty) {
150
+ throw StateError(
151
+ 'For the postgres backend, provide --database-url or set DATABASE_URL.',
130
152
  );
131
- step('applied $name');
132
153
  }
133
- } finally {
134
- await conn.close();
154
+ await _runViaPostgres(pgUrl, files);
155
+ return;
135
156
  }
157
+
158
+ throw StateError('Unknown backend provider: "$provider".');
136
159
  });
160
+
161
+ /// Run migrations through the Supabase Management API (no Postgres creds
162
+ /// needed). Derived from the Supabase project ref in the configured URL.
163
+ Future<void> _runViaManagementApi(String mgmtKey, List<File> files) async {
164
+ final cfg = resolveSupabaseConfig(
165
+ loadConfig() ??
166
+ FlutterPatcherConfig(
167
+ provider: 'supabase',
168
+ supabase: SupabaseConfigJson(),
169
+ ),
170
+ );
171
+ final ref = Uri.parse(cfg.supabaseUrl).host.split('.').first;
172
+ final endpoint =
173
+ 'https://api.supabase.com/v1/projects/$ref/database/query';
174
+ banner('migrate · supabase (management api)');
175
+ for (final file in files) {
176
+ final sql = file.readAsStringSync();
177
+ final res = await http.post(
178
+ Uri.parse(endpoint),
179
+ headers: {
180
+ 'Authorization': 'Bearer $mgmtKey',
181
+ 'Content-Type': 'application/json',
182
+ },
183
+ body: jsonEncode({'query': sql}),
184
+ );
185
+ if (res.statusCode >= 400) {
186
+ throw StateError(
187
+ 'Migration ${p.basename(file.path)} failed '
188
+ '(${res.statusCode}): ${res.body}',
189
+ );
190
+ }
191
+ step('applied ${p.basename(file.path)}');
192
+ }
193
+ await _ensureSupabaseBucket();
194
+ }
195
+
196
+ /// Create the Supabase Storage bucket (public) if it doesn't exist, so
197
+ /// `deploy` can upload artifacts without a manual setup step.
198
+ Future<void> _ensureSupabaseBucket() async {
199
+ final storage = resolveSupabaseStorageConfig(
200
+ loadConfig() ??
201
+ FlutterPatcherConfig(
202
+ provider: 'supabase',
203
+ supabase: SupabaseConfigJson(),
204
+ ),
205
+ );
206
+ if (storage.supabaseServiceRoleKey == null) {
207
+ box('migrate', [
208
+ 'Skipped storage bucket creation — set supabase.serviceRoleKey '
209
+ '(or SUPABASE_SERVICE_ROLE_KEY) to auto-create the '
210
+ '"${storage.bucketName}" bucket.',
211
+ ]);
212
+ return;
213
+ }
214
+ final res = await http.post(
215
+ Uri.parse('${storage.supabaseUrl}/storage/v1/bucket'),
216
+ headers: {
217
+ 'Authorization': 'Bearer ${storage.supabaseServiceRoleKey}',
218
+ 'apikey': storage.supabaseServiceRoleKey!,
219
+ 'Content-Type': 'application/json',
220
+ },
221
+ body: jsonEncode({
222
+ 'name': storage.bucketName,
223
+ 'public': true,
224
+ }),
225
+ );
226
+ if (res.statusCode >= 400) {
227
+ final body = jsonDecode(res.body) as Map<String, dynamic>;
228
+ final msg = (body['message'] ?? body['error'] ?? '').toString();
229
+ if (!msg.contains('already exists') && res.statusCode != 409) {
230
+ box('migrate', [
231
+ 'Warning: could not create bucket "${storage.bucketName}": $msg',
232
+ ]);
233
+ return;
234
+ }
235
+ }
236
+ step('ensured storage bucket "${storage.bucketName}"');
237
+ }
238
+
239
+ /// Original path: connect directly to Postgres and execute each statement.
240
+ Future<void> _runViaPostgres(String url, List<File> files) async {
241
+ final uri = Uri.parse(url);
242
+ final endpoint = Endpoint(
243
+ host: uri.host,
244
+ port: uri.port == 0 ? 5432 : uri.port,
245
+ database: uri.path.isEmpty ? 'postgres' : uri.path.substring(1),
246
+ username: uri.userInfo.isEmpty ? null : uri.userInfo.split(':').first,
247
+ password: uri.userInfo.contains(':')
248
+ ? uri.userInfo.split(':').last
249
+ : null,
250
+ );
251
+ banner('migrate · postgres');
252
+ final conn = await Connection.open(
253
+ endpoint,
254
+ settings: ConnectionSettings(sslMode: SslMode.disable),
255
+ );
256
+ try {
257
+ await conn.execute(
258
+ 'CREATE TABLE IF NOT EXISTS _flutter_ota_kit_migrations '
259
+ '(name text primary key, applied_at timestamptz default now())',
260
+ queryMode: QueryMode.simple,
261
+ );
262
+ for (final file in files) {
263
+ final name = p.basename(file.path);
264
+ final escaped = name.replaceAll("'", "''");
265
+ final existing = await conn.execute(
266
+ "SELECT 1 FROM _flutter_ota_kit_migrations WHERE name = '$escaped'",
267
+ queryMode: QueryMode.simple,
268
+ );
269
+ if (existing.isNotEmpty) {
270
+ stdout.writeln(' ${dim('skip')} $name (already applied)');
271
+ continue;
272
+ }
273
+ for (final stmt in _splitStatements(file.readAsStringSync())) {
274
+ await conn.execute(stmt, queryMode: QueryMode.simple);
275
+ }
276
+ await conn.execute(
277
+ "INSERT INTO _flutter_ota_kit_migrations(name) VALUES ('$escaped')",
278
+ queryMode: QueryMode.simple,
279
+ );
280
+ step('applied $name');
281
+ }
282
+ } finally {
283
+ await conn.close();
284
+ }
285
+ }
137
286
  }
@@ -1,10 +1,10 @@
1
1
  import 'package:args/args.dart';
2
- import 'package:flutter_patcher_cli/flutter_patcher_cli.dart';
3
- import 'package:flutter_patcher_core/flutter_patcher_core.dart';
2
+ import 'package:flutter_ota_kit_cli/flutter_ota_kit_cli.dart';
3
+ import 'package:flutter_ota_kit_core/flutter_ota_kit_core.dart';
4
4
 
5
5
  import '../ui/ui.dart';
6
6
 
7
- /// `flutter_patcher rollback` — roll a channel back to a previous bundle.
7
+ /// `flutter_ota_kit rollback` — roll a channel back to a previous bundle.
8
8
  class RollbackCommand extends FlutterPatcherCommand {
9
9
  RollbackCommand({this.config, this.backendOverride});
10
10
 
@@ -22,7 +22,7 @@ class RollbackCommand extends FlutterPatcherCommand {
22
22
  ArgParser get argParser => ArgParser()
23
23
  ..addOption('backend', abbr: 'b', help: 'Backend provider.')
24
24
  ..addOption('channel', abbr: 'c', help: 'Channel to roll back.')
25
- ..addOption('bundle-id', help: 'Roll back to this specific bundle id.')
25
+ ..addOption('bundle-id', abbr: 'i', help: 'Roll back to this specific bundle id.')
26
26
  ..addOption('platform', abbr: 'p', help: 'Platform filter (e.g. android).');
27
27
 
28
28
  @override
@@ -30,7 +30,7 @@ class RollbackCommand extends FlutterPatcherCommand {
30
30
  final channel = argResults!['channel'] as String?;
31
31
  if (channel == null || channel.isEmpty) {
32
32
  throw PackException(
33
- 'Usage: flutter_patcher rollback --channel <channel> [--bundle-id <id>]',
33
+ 'Usage: flutter_ota_kit rollback --channel <channel> [--bundle-id <id>]',
34
34
  64,
35
35
  );
36
36
  }
@@ -0,0 +1,117 @@
1
+ import 'package:args/args.dart';
2
+ import 'package:flutter_ota_kit_cli/flutter_ota_kit_cli.dart';
3
+
4
+ import '../ui/ui.dart';
5
+
6
+ /// `flutter_ota_kit storage` — inspect and clean the blob storage bucket
7
+ /// directly from the CLI (no need to open the provider dashboard).
8
+ class StorageCommand extends FlutterPatcherCommand {
9
+ StorageCommand({this.config, this.backendOverride}) {
10
+ addSubcommand(StorageListCommand(config: config, backendOverride: backendOverride));
11
+ addSubcommand(StorageDeleteCommand(config: config, backendOverride: backendOverride));
12
+ }
13
+
14
+ final FlutterPatcherConfig? config;
15
+ final Backend? backendOverride;
16
+
17
+ @override
18
+ String get name => 'storage';
19
+
20
+ @override
21
+ String get description =>
22
+ 'Inspect and manage bundle storage objects (find / delete orphans).';
23
+ }
24
+
25
+ class StorageListCommand extends FlutterPatcherCommand {
26
+ StorageListCommand({this.config, this.backendOverride});
27
+
28
+ final FlutterPatcherConfig? config;
29
+ final Backend? backendOverride;
30
+
31
+ @override
32
+ String get name => 'list';
33
+
34
+ @override
35
+ String get description => 'List storage objects.';
36
+
37
+ @override
38
+ ArgParser get argParser => ArgParser()
39
+ ..addOption('backend', abbr: 'b', help: 'Backend provider.')
40
+ ..addOption('prefix', help: 'Key prefix filter (e.g. bundles).');
41
+
42
+ @override
43
+ Future<int> run() => runGuarded(() async {
44
+ final cfg = effectiveConfig(config ?? loadConfig(), argResults!);
45
+ final backend = requireBackend(cfg, override: backendOverride);
46
+ final prefix = argResults!['prefix'] as String?;
47
+ banner('storage · list');
48
+ final objects = await backend.storage.listObjects(
49
+ prefix == null || prefix.isEmpty ? null : prefix,
50
+ );
51
+ if (objects.isEmpty) {
52
+ step('(no objects)');
53
+ return;
54
+ }
55
+ final lines = <String>[];
56
+ for (final o in objects) {
57
+ final size = o.size >= 1024 * 1024
58
+ ? '${(o.size / (1024 * 1024)).toStringAsFixed(2)} MB'
59
+ : o.size >= 1024
60
+ ? '${(o.size / 1024).toStringAsFixed(1)} KB'
61
+ : '${o.size} B';
62
+ lines
63
+ ..add(kv('key', cyan(o.key)))
64
+ ..add(kv('size', size))
65
+ ..add('');
66
+ }
67
+ box('${objects.length} objects', lines);
68
+ });
69
+ }
70
+
71
+ class StorageDeleteCommand extends FlutterPatcherCommand {
72
+ StorageDeleteCommand({this.config, this.backendOverride});
73
+
74
+ final FlutterPatcherConfig? config;
75
+ final Backend? backendOverride;
76
+
77
+ @override
78
+ String get name => 'delete';
79
+
80
+ @override
81
+ String get description => 'Delete storage object(s) by key or full URI.';
82
+
83
+ @override
84
+ ArgParser get argParser => ArgParser()
85
+ ..addOption('backend', abbr: 'b', help: 'Backend provider.')
86
+ ..addMultiOption('key', help: 'Storage key to delete (repeatable).')
87
+ ..addOption('uri', help: 'Full storage URI to delete.');
88
+
89
+ @override
90
+ Future<int> run() => runGuarded(() async {
91
+ final keys = (argResults!['key'] as List<String>?) ?? <String>[];
92
+ final uri = argResults!['uri'] as String?;
93
+ if (keys.isEmpty && (uri == null || uri.isEmpty)) {
94
+ throw PackException(
95
+ 'Usage: flutter-ota storage delete --key <key> [--key <key>...] '
96
+ '| --uri <storageUri>',
97
+ 64,
98
+ );
99
+ }
100
+ final cfg = effectiveConfig(config ?? loadConfig(), argResults!);
101
+ final backend = requireBackend(cfg, override: backendOverride);
102
+ banner('storage · delete');
103
+ if (keys.isNotEmpty) {
104
+ await spinner(
105
+ () => backend.storage.deleteObjects(keys),
106
+ 'Deleting ${keys.length} object(s)',
107
+ done: 'Deleted',
108
+ );
109
+ } else {
110
+ await spinner(
111
+ () => backend.storage.delete(uri!),
112
+ 'Deleting $uri',
113
+ done: 'Deleted',
114
+ );
115
+ }
116
+ });
117
+ }