@_nazmiforreal/flutter-ota 0.1.5 → 0.1.6

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.
@@ -19,11 +19,11 @@
19
19
  "flutter_patcher_postgres",
20
20
  "flutter_patcher_standalone",
21
21
  "flutter_patcher_supabase",
22
+ "http",
22
23
  "path",
23
24
  "postgres"
24
25
  ],
25
26
  "devDependencies": [
26
- "http",
27
27
  "test"
28
28
  ]
29
29
  },
@@ -1,7 +1,9 @@
1
+ import 'dart:convert';
1
2
  import 'dart:io';
2
3
 
3
4
  import 'package:args/args.dart';
4
5
  import 'package:flutter_patcher_cli/flutter_patcher_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
 
@@ -21,6 +23,9 @@ 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
31
  ..addFlag('dry-run', help: 'Print migrations instead of applying them.');
@@ -35,6 +40,23 @@ class MigrateCommand extends FlutterPatcherCommand {
35
40
  return p.join(p.dirname(Platform.script.path), '..', 'migrations', sub);
36
41
  }
37
42
 
43
+ List<File> _listMigrations(String dir) {
44
+ final migrationsDir = Directory(dir);
45
+ if (!migrationsDir.existsSync()) {
46
+ throw StateError('Migrations directory not found: $dir');
47
+ }
48
+ final files = migrationsDir
49
+ .listSync()
50
+ .whereType<File>()
51
+ .where((f) => f.path.endsWith('.sql'))
52
+ .toList()
53
+ ..sort((a, b) => a.path.compareTo(b.path));
54
+ if (files.isEmpty) {
55
+ throw StateError('No *.sql files in $dir');
56
+ }
57
+ return files;
58
+ }
59
+
38
60
  List<String> _splitStatements(String sql) {
39
61
  return sql
40
62
  .split(';')
@@ -48,19 +70,7 @@ class MigrateCommand extends FlutterPatcherCommand {
48
70
  final provider = argResults!['backend'] as String? ?? 'supabase';
49
71
  final dir = argResults!['migrations-dir'] as String? ??
50
72
  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
- }
73
+ final files = _listMigrations(dir);
64
74
 
65
75
  if (argResults!['dry-run'] as bool) {
66
76
  banner('migrate · dry-run');
@@ -71,8 +81,6 @@ class MigrateCommand extends FlutterPatcherCommand {
71
81
  return;
72
82
  }
73
83
 
74
- final url = argResults!['database-url'] as String? ??
75
- Platform.environment['DATABASE_URL'];
76
84
  if (provider == 'cloudflare' || provider == 'aws') {
77
85
  banner('migrate');
78
86
  box('migrate', [
@@ -80,58 +88,164 @@ class MigrateCommand extends FlutterPatcherCommand {
80
88
  ' • cloudflare → `wrangler d1 execute`',
81
89
  ' • aws → AWS console (S3/DynamoDB)',
82
90
  '',
83
- 'The SQL files under ${dir} show the intended schema.',
91
+ 'The SQL files under $dir show the intended schema.',
84
92
  ]);
85
93
  return;
86
94
  }
87
- if (url == null || url.isEmpty) {
95
+
96
+ if (provider == 'supabase') {
97
+ final mgmtKey = argResults!['management-key'] as String? ??
98
+ Platform.environment['SUPABASE_MANAGEMENT_KEY'];
99
+ final pgUrl = argResults!['database-url'] as String? ??
100
+ Platform.environment['DATABASE_URL'];
101
+ if (mgmtKey != null && mgmtKey.isNotEmpty) {
102
+ await _runViaManagementApi(mgmtKey, files);
103
+ return;
104
+ }
105
+ if (pgUrl != null && pgUrl.isNotEmpty) {
106
+ await _runViaPostgres(pgUrl, files);
107
+ return;
108
+ }
109
+ throw StateError(
110
+ 'For the supabase backend, provide either:\n'
111
+ ' • --management-key (or SUPABASE_MANAGEMENT_KEY) — a Supabase '
112
+ 'Management API key, OR\n'
113
+ ' • --database-url (or DATABASE_URL) — a Postgres connection string.',
114
+ );
115
+ }
116
+
117
+ // Any other provider (e.g. postgres) requires a Postgres connection.
118
+ final pgUrl = argResults!['database-url'] as String? ??
119
+ Platform.environment['DATABASE_URL'];
120
+ if (pgUrl == null || pgUrl.isEmpty) {
88
121
  throw StateError('Provide --database-url or set DATABASE_URL.');
89
122
  }
123
+ await _runViaPostgres(pgUrl, files);
124
+ });
90
125
 
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,
126
+ /// Run migrations through the Supabase Management API (no Postgres creds
127
+ /// needed). Derived from the Supabase project ref in the configured URL.
128
+ Future<void> _runViaManagementApi(String mgmtKey, List<File> files) async {
129
+ final cfg = resolveSupabaseConfig(
130
+ loadConfig() ??
131
+ FlutterPatcherConfig(
132
+ provider: 'supabase',
133
+ supabase: SupabaseConfigJson(),
134
+ ),
135
+ );
136
+ final ref = Uri.parse(cfg.supabaseUrl).host.split('.').first;
137
+ final endpoint =
138
+ 'https://api.supabase.com/v1/projects/$ref/database/query';
139
+ banner('migrate · supabase (management api)');
140
+ for (final file in files) {
141
+ final sql = file.readAsStringSync();
142
+ final res = await http.post(
143
+ Uri.parse(endpoint),
144
+ headers: {
145
+ 'Authorization': 'Bearer $mgmtKey',
146
+ 'Content-Type': 'application/json',
147
+ },
148
+ body: jsonEncode({'query': sql}),
149
+ );
150
+ if (res.statusCode >= 400) {
151
+ throw StateError(
152
+ 'Migration ${p.basename(file.path)} failed '
153
+ '(${res.statusCode}): ${res.body}',
100
154
  );
101
- banner('migrate');
102
- final conn = await Connection.open(
103
- endpoint,
104
- settings: ConnectionSettings(sslMode: SslMode.disable),
155
+ }
156
+ step('applied ${p.basename(file.path)}');
157
+ }
158
+ await _ensureSupabaseBucket();
159
+ }
160
+
161
+ /// Create the Supabase Storage bucket (public) if it doesn't exist, so
162
+ /// `deploy` can upload artifacts without a manual setup step.
163
+ Future<void> _ensureSupabaseBucket() async {
164
+ final storage = resolveSupabaseStorageConfig(
165
+ loadConfig() ??
166
+ FlutterPatcherConfig(
167
+ provider: 'supabase',
168
+ supabase: SupabaseConfigJson(),
169
+ ),
170
+ );
171
+ if (storage.supabaseServiceRoleKey == null) {
172
+ box('migrate', [
173
+ 'Skipped storage bucket creation — set supabase.serviceRoleKey '
174
+ '(or SUPABASE_SERVICE_ROLE_KEY) to auto-create the '
175
+ '"${storage.bucketName}" bucket.',
176
+ ]);
177
+ return;
178
+ }
179
+ final res = await http.post(
180
+ Uri.parse('${storage.supabaseUrl}/storage/v1/bucket'),
181
+ headers: {
182
+ 'Authorization': 'Bearer ${storage.supabaseServiceRoleKey}',
183
+ 'apikey': storage.supabaseServiceRoleKey!,
184
+ 'Content-Type': 'application/json',
185
+ },
186
+ body: jsonEncode({
187
+ 'name': storage.bucketName,
188
+ 'public': true,
189
+ }),
190
+ );
191
+ if (res.statusCode >= 400) {
192
+ final body = jsonDecode(res.body) as Map<String, dynamic>;
193
+ final msg = (body['message'] ?? body['error'] ?? '').toString();
194
+ if (!msg.contains('already exists') && res.statusCode != 409) {
195
+ box('migrate', [
196
+ 'Warning: could not create bucket "${storage.bucketName}": $msg',
197
+ ]);
198
+ return;
199
+ }
200
+ }
201
+ step('ensured storage bucket "${storage.bucketName}"');
202
+ }
203
+
204
+ /// Original path: connect directly to Postgres and execute each statement.
205
+ Future<void> _runViaPostgres(String url, List<File> files) async {
206
+ final uri = Uri.parse(url);
207
+ final endpoint = Endpoint(
208
+ host: uri.host,
209
+ port: uri.port == 0 ? 5432 : uri.port,
210
+ database: uri.path.isEmpty ? 'postgres' : uri.path.substring(1),
211
+ username: uri.userInfo.isEmpty ? null : uri.userInfo.split(':').first,
212
+ password: uri.userInfo.contains(':')
213
+ ? uri.userInfo.split(':').last
214
+ : null,
215
+ );
216
+ banner('migrate · postgres');
217
+ final conn = await Connection.open(
218
+ endpoint,
219
+ settings: ConnectionSettings(sslMode: SslMode.disable),
220
+ );
221
+ try {
222
+ await conn.execute(
223
+ 'CREATE TABLE IF NOT EXISTS _flutter_patcher_migrations '
224
+ '(name text primary key, applied_at timestamptz default now())',
225
+ queryMode: QueryMode.simple,
226
+ );
227
+ for (final file in files) {
228
+ final name = p.basename(file.path);
229
+ final escaped = name.replaceAll("'", "''");
230
+ final existing = await conn.execute(
231
+ "SELECT 1 FROM _flutter_patcher_migrations WHERE name = '$escaped'",
232
+ queryMode: QueryMode.simple,
105
233
  );
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,
111
- );
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,
130
- );
131
- step('applied $name');
132
- }
133
- } finally {
134
- await conn.close();
234
+ if (existing.isNotEmpty) {
235
+ stdout.writeln(' ${dim('skip')} $name (already applied)');
236
+ continue;
135
237
  }
136
- });
238
+ for (final stmt in _splitStatements(file.readAsStringSync())) {
239
+ await conn.execute(stmt, queryMode: QueryMode.simple);
240
+ }
241
+ await conn.execute(
242
+ "INSERT INTO _flutter_patcher_migrations(name) VALUES ('$escaped')",
243
+ queryMode: QueryMode.simple,
244
+ );
245
+ step('applied $name');
246
+ }
247
+ } finally {
248
+ await conn.close();
249
+ }
250
+ }
137
251
  }
@@ -5,14 +5,15 @@ import 'ui/ui.dart';
5
5
 
6
6
  /// Custom [CommandRunner] that renders a clean, colorized top-level help.
7
7
  class FlutterPatcherRunner extends CommandRunner<int> {
8
- FlutterPatcherRunner(String name, String description)
9
- : super(name, description);
8
+ FlutterPatcherRunner(super.name, super.description);
10
9
 
11
10
  @override
12
11
  String get usage {
13
12
  final names = commands.keys.toList()..sort();
14
13
  var maxName = 0;
15
- for (final n in names) maxName = maxName < n.length ? n.length : maxName;
14
+ for (final n in names) {
15
+ maxName = maxName < n.length ? n.length : maxName;
16
+ }
16
17
  final buf = StringBuffer();
17
18
  buf.writeln(' ${cyan('▶')} ${cyan(bold(executableName))} '
18
19
  '${dim('·')} ${bold(description)}');
@@ -53,7 +54,7 @@ abstract class FlutterPatcherCommand extends Command<int> {
53
54
  @override
54
55
  String get usage {
55
56
  final buf = StringBuffer();
56
- buf.writeln(' ${cyan(bold(name))} ${dim('·')} ${description}');
57
+ buf.writeln(' ${cyan(bold(name))} ${dim('·')} $description');
57
58
  buf.writeln('');
58
59
  buf.writeln(' ${bold('USAGE')}');
59
60
  buf.writeln(' ${_fullName(this)} [arguments]');
@@ -173,7 +173,9 @@ void box(String title, List<String> lines) {
173
173
  final width = _boxWidth(lines, title);
174
174
  final inner = width - 4;
175
175
  final wrapped = <String>[];
176
- for (final l in lines) wrapped.addAll(_wrap(l, inner));
176
+ for (final l in lines) {
177
+ wrapped.addAll(_wrap(l, inner));
178
+ }
177
179
 
178
180
  final titleText = ' $title ';
179
181
  final tVisible = _dispWidth(titleText);
@@ -259,7 +259,7 @@ packages:
259
259
  source: hosted
260
260
  version: "2.26.0"
261
261
  http:
262
- dependency: "direct dev"
262
+ dependency: "direct main"
263
263
  description:
264
264
  name: http
265
265
  sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
@@ -31,7 +31,7 @@ dependencies:
31
31
  path: ../../plugins/aws
32
32
  flutter_patcher_standalone:
33
33
  path: ../../plugins/standalone
34
+ http: ^1.2.0
34
35
 
35
36
  dev_dependencies:
36
37
  test: ^1.25.0
37
- http: ^1.2.0
@@ -245,17 +245,23 @@ class _StorageBucket implements SupabaseStorageBucketLike {
245
245
  String path,
246
246
  int expiresIn,
247
247
  ) async {
248
- final uri = Uri.parse(
249
- '$_baseUrl/storage/v1/object/sign/$_bucket/$path',
250
- ).replace(queryParameters: {'expiresIn': expiresIn.toString()});
251
- final res = await http.post(uri, headers: _headers);
248
+ final uri = Uri.parse('$_baseUrl/storage/v1/object/sign/$_bucket/$path');
249
+ final res = await http.post(
250
+ uri,
251
+ headers: {..._headers, 'Content-Type': 'application/json'},
252
+ body: jsonEncode({'expiresIn': expiresIn}),
253
+ );
252
254
  if (res.statusCode >= 400) {
253
255
  return _MockSignedUrlResult(null, jsonDecode(res.body));
254
256
  }
255
257
  final body = jsonDecode(res.body) as Map<String, dynamic>;
258
+ final signed = body['signedURL'] as String?;
259
+ // Supabase returns a relative path ("/object/sign/...?token=..."); make it
260
+ // absolute against the project base URL.
256
261
  return _MockSignedUrlResult(
257
- '$_baseUrl/storage/v1/object/sign/$_bucket/$path?...${body['signedURL']}',
258
- null);
262
+ signed == null ? null : '$_baseUrl/storage/v1$signed',
263
+ null,
264
+ );
259
265
  }
260
266
 
261
267
  @override
@@ -287,6 +293,22 @@ class _StorageBucket implements SupabaseStorageBucketLike {
287
293
  );
288
294
  }
289
295
 
296
+ Future<void> _ensureBucket() async {
297
+ final res = await http.post(
298
+ Uri.parse('$_baseUrl/storage/v1/bucket'),
299
+ headers: {..._headers, 'Content-Type': 'application/json'},
300
+ body: jsonEncode({'name': _bucket, 'public': true}),
301
+ );
302
+ // 400/409 just mean it already exists — that's fine.
303
+ if (res.statusCode >= 400) {
304
+ final body = jsonDecode(res.body) as Map<String, dynamic>;
305
+ final msg = (body['message'] ?? body['error'] ?? '').toString();
306
+ if (!msg.contains('already exists') && res.statusCode != 409) {
307
+ throw StateError('Failed to create bucket "$_bucket": $msg');
308
+ }
309
+ }
310
+ }
311
+
290
312
  @override
291
313
  Future<SupabaseUploadResult> upload(
292
314
  String path,
@@ -294,20 +316,34 @@ class _StorageBucket implements SupabaseStorageBucketLike {
294
316
  String? contentType,
295
317
  String? cacheControl,
296
318
  }) async {
297
- final uri = Uri.parse('$_baseUrl/storage/v1/object/$_bucket/$path');
298
- final res = await http.post(
299
- uri,
300
- headers: {
301
- ..._headers,
302
- 'Content-Type': contentType ?? 'application/octet-stream',
303
- if (cacheControl != null) 'Cache-Control': cacheControl,
304
- },
305
- body: fileBytes,
306
- );
307
- if (res.statusCode >= 400) {
308
- return _MockUploadResult(null, jsonDecode(res.body));
319
+ Future<_MockUploadResult> doUpload() async {
320
+ final uri = Uri.parse('$_baseUrl/storage/v1/object/$_bucket/$path');
321
+ final res = await http.post(
322
+ uri,
323
+ headers: {
324
+ ..._headers,
325
+ 'Content-Type': contentType ?? 'application/octet-stream',
326
+ if (cacheControl != null) 'Cache-Control': cacheControl,
327
+ },
328
+ body: fileBytes,
329
+ );
330
+ if (res.statusCode >= 400) {
331
+ return _MockUploadResult(null, jsonDecode(res.body));
332
+ }
333
+ return _MockUploadResult(jsonDecode(res.body), null);
334
+ }
335
+
336
+ final first = await doUpload();
337
+ if (first.error != null) {
338
+ final msg =
339
+ (first.error is Map ? (first.error as Map)['message'] : first.error)
340
+ .toString();
341
+ if (msg.contains('Bucket not found')) {
342
+ await _ensureBucket();
343
+ return doUpload();
344
+ }
309
345
  }
310
- return _MockUploadResult(jsonDecode(res.body), null);
346
+ return first;
311
347
  }
312
348
 
313
349
  @override
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@_nazmiforreal/flutter-ota",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "Command-line interface for flutter_patcher — an OTA code-push tool for Flutter, hot-updater compatible.",
5
5
  "bin": {
6
6
  "flutter-patcher": "./bin/flutter-patcher.js"