@_nazmiforreal/flutter-ota 0.1.2 → 0.1.4
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/.dart_tool/package_config.json +496 -0
- package/dart-src/packages/cli-tools/.dart_tool/package_graph.json +743 -0
- package/dart-src/packages/cli-tools/lib/src/cli_base.dart +7 -6
- package/dart-src/packages/cli-tools/lib/src/commands/build.dart +46 -49
- package/dart-src/packages/cli-tools/lib/src/commands/bundle.dart +44 -23
- package/dart-src/packages/cli-tools/lib/src/commands/channel.dart +27 -17
- package/dart-src/packages/cli-tools/lib/src/commands/console.dart +11 -6
- package/dart-src/packages/cli-tools/lib/src/commands/deploy.dart +39 -22
- package/dart-src/packages/cli-tools/lib/src/commands/doctor.dart +22 -13
- package/dart-src/packages/cli-tools/lib/src/commands/init.dart +117 -36
- package/dart-src/packages/cli-tools/lib/src/commands/keys.dart +15 -9
- package/dart-src/packages/cli-tools/lib/src/commands/migrate.dart +14 -8
- package/dart-src/packages/cli-tools/lib/src/commands/rollback.dart +37 -12
- package/dart-src/packages/cli-tools/lib/src/config.dart +13 -3
- package/dart-src/packages/cli-tools/lib/src/operations.dart +45 -0
- package/dart-src/packages/cli-tools/lib/src/pack.dart +9 -0
- package/dart-src/packages/cli-tools/lib/src/ui/ui.dart +234 -0
- package/dart-src/packages/cli-tools/pubspec.lock +638 -0
- package/dart-src/packages/cli-tools/test/backends_test.dart +207 -0
- package/dart-src/packages/cli-tools/test/build_pack_test.dart +101 -0
- package/dart-src/packages/cli-tools/test/cli_test.dart +442 -0
- package/dart-src/packages/cli-tools/test/mocks/mock_aws_s3_client.dart +83 -0
- package/dart-src/packages/cli-tools/test/mocks/mock_d1_client.dart +256 -0
- package/dart-src/packages/cli-tools/test/mocks/mock_postgres_client.dart +347 -0
- package/dart-src/packages/cli-tools/test/standalone_backend_test.dart +315 -0
- package/dart-src/packages/core/test/rollout_test.dart +128 -0
- package/dart-src/packages/core/test/semver_test.dart +52 -0
- package/dart-src/packages/core/test/semver_vectors.json +1 -0
- package/dart-src/packages/core/test/types_test.dart +129 -0
- package/dart-src/packages/core/test/uuid_test.dart +38 -0
- package/dart-src/plugins/aws/test/aws_database_test.dart +167 -0
- package/dart-src/plugins/aws/test/aws_lambda_edge_storage_test.dart +190 -0
- package/dart-src/plugins/aws/test/aws_storage_test.dart +73 -0
- package/dart-src/plugins/aws/test/mock_aws_s3_client.dart +83 -0
- package/dart-src/plugins/cloudflare/test/cloudflare_worker_database_test.dart +122 -0
- package/dart-src/plugins/cloudflare/test/d1_bundle_mapper_test.dart +106 -0
- package/dart-src/plugins/cloudflare/test/d1_database_test.dart +190 -0
- package/dart-src/plugins/cloudflare/test/mock_d1_client.dart +256 -0
- package/dart-src/plugins/cloudflare/test/mock_r2_client.dart +83 -0
- package/dart-src/plugins/cloudflare/test/r2_storage_test.dart +77 -0
- package/dart-src/plugins/plugin-core/test/asset_storage_layout_test.dart +166 -0
- package/dart-src/plugins/plugin-core/test/bundle_unit_of_work_store_test.dart +74 -0
- package/dart-src/plugins/plugin-core/test/bundle_unit_of_work_test.dart +332 -0
- package/dart-src/plugins/plugin-core/test/create_blob_database_plugin_test.dart +711 -0
- package/dart-src/plugins/plugin-core/test/create_database_plugin_test.dart +294 -0
- package/dart-src/plugins/plugin-core/test/create_storage_plugin_test.dart +192 -0
- package/dart-src/plugins/plugin-core/test/generate_min_bundle_id_test.dart +51 -0
- package/dart-src/plugins/plugin-core/test/get_update_info_test.dart +1600 -0
- package/dart-src/plugins/plugin-core/test/helpers_test.dart +259 -0
- package/dart-src/plugins/plugin-core/test/query_bundles_test.dart +139 -0
- package/dart-src/plugins/plugin-core/test/request_update_bundle_state_test.dart +86 -0
- package/dart-src/plugins/plugin-core/test/uuidv7_test.dart +77 -0
- package/dart-src/plugins/postgres/test/mock_postgres_client.dart +297 -0
- package/dart-src/plugins/postgres/test/postgres_bundle_mapper_test.dart +140 -0
- package/dart-src/plugins/postgres/test/postgres_database_test.dart +181 -0
- package/dart-src/plugins/standalone/test/standalone_test.dart +264 -0
- package/dart-src/plugins/supabase/test/mock_supabase_client.dart +654 -0
- package/dart-src/plugins/supabase/test/supabase_bundle_mapper_test.dart +112 -0
- package/dart-src/plugins/supabase/test/supabase_database_test.dart +207 -0
- package/dart-src/plugins/supabase/test/supabase_storage_test.dart +175 -0
- package/package.json +1 -1
- package/scripts/vendor.sh +29 -0
|
@@ -2,12 +2,17 @@ import 'dart:io';
|
|
|
2
2
|
|
|
3
3
|
import 'package:args/args.dart';
|
|
4
4
|
import 'package:args/command_runner.dart';
|
|
5
|
+
import 'package:flutter_patcher_cli/flutter_patcher_cli.dart';
|
|
5
6
|
|
|
6
|
-
import '../
|
|
7
|
-
import '../cli_base.dart';
|
|
8
|
-
import '../config.dart';
|
|
7
|
+
import '../ui/ui.dart';
|
|
9
8
|
|
|
10
9
|
/// `flutter_patcher init` — scaffold a project config file.
|
|
10
|
+
///
|
|
11
|
+
/// Prompts (interactively, when attached to a terminal) for the credentials the
|
|
12
|
+
/// **selected** backend provider needs, defaulting each field to its
|
|
13
|
+
/// corresponding environment variable so CI runs can be non-interactive. The
|
|
14
|
+
/// result is saved as `.flutter_patcher.json` (cwd) or the global config when
|
|
15
|
+
/// `--global` is passed.
|
|
11
16
|
class InitCommand extends Command<int> {
|
|
12
17
|
InitCommand({this.config, this.backendOverride});
|
|
13
18
|
|
|
@@ -23,60 +28,136 @@ class InitCommand extends Command<int> {
|
|
|
23
28
|
|
|
24
29
|
@override
|
|
25
30
|
ArgParser get argParser => ArgParser()
|
|
26
|
-
..addOption('provider',
|
|
27
|
-
|
|
28
|
-
..addOption('key', help: 'Supabase service role key.')
|
|
29
|
-
..addOption('anon-key', help: 'Supabase anon key.')
|
|
30
|
-
..addOption('bucket', defaultsTo: 'bundles', help: 'Storage bucket.')
|
|
31
|
-
..addOption('base-path', help: 'Storage base path.')
|
|
31
|
+
..addOption('provider',
|
|
32
|
+
abbr: 'p', defaultsTo: 'supabase', help: 'Backend provider.')
|
|
32
33
|
..addOption('channel', defaultsTo: 'production', help: 'Default channel.')
|
|
33
34
|
..addOption('platform', defaultsTo: 'android', help: 'Default platform.')
|
|
34
35
|
..addOption('source', defaultsTo: './dist', help: 'Default deploy source.')
|
|
36
|
+
..addFlag('global', help: 'Write to the global ~/.flutter_patcher config.')
|
|
35
37
|
..addFlag('force', abbr: 'f', help: 'Overwrite an existing config.');
|
|
36
38
|
|
|
37
|
-
String?
|
|
39
|
+
String? _p(String label, String? current) {
|
|
38
40
|
if (!stdin.hasTerminal) return current;
|
|
39
|
-
|
|
41
|
+
final hint = current != null && current.isNotEmpty ? ' [$current]' : '';
|
|
42
|
+
stdout.write('$label$hint: ');
|
|
40
43
|
final line = stdin.readLineSync();
|
|
41
|
-
|
|
44
|
+
final trimmed = (line ?? '').trim();
|
|
45
|
+
return trimmed.isEmpty ? current : trimmed;
|
|
42
46
|
}
|
|
43
47
|
|
|
44
48
|
@override
|
|
45
49
|
Future<int> run() => runGuarded(() async {
|
|
46
|
-
final file =
|
|
50
|
+
final file = argResults!['global'] as bool
|
|
51
|
+
? configCandidates().last
|
|
52
|
+
: configCandidates().first;
|
|
47
53
|
if (file.existsSync() && !(argResults!['force'] as bool)) {
|
|
48
|
-
throw
|
|
54
|
+
throw PackException(
|
|
49
55
|
'Config already exists at ${file.path}. Use --force to overwrite.',
|
|
56
|
+
64,
|
|
50
57
|
);
|
|
51
58
|
}
|
|
52
59
|
|
|
53
60
|
final provider = argResults!['provider'] as String;
|
|
54
|
-
final url = _prompt('Supabase URL', argResults!['url'] as String?);
|
|
55
|
-
final key = _prompt(
|
|
56
|
-
'Supabase service role key',
|
|
57
|
-
argResults!['key'] as String?,
|
|
58
|
-
);
|
|
59
|
-
final anonKey = argResults!['anon-key'] as String?;
|
|
60
|
-
final bucket = argResults!['bucket'] as String? ?? 'bundles';
|
|
61
|
-
final basePath = argResults!['base-path'] as String?;
|
|
62
61
|
final channel = argResults!['channel'] as String? ?? 'production';
|
|
63
62
|
final platform = argResults!['platform'] as String? ?? 'android';
|
|
64
63
|
final source = argResults!['source'] as String? ?? './dist';
|
|
64
|
+
final env = Platform.environment;
|
|
65
|
+
|
|
66
|
+
banner('init');
|
|
67
|
+
|
|
68
|
+
late FlutterPatcherConfig cfg;
|
|
69
|
+
switch (provider) {
|
|
70
|
+
case 'postgres':
|
|
71
|
+
cfg = FlutterPatcherConfig(
|
|
72
|
+
provider: provider,
|
|
73
|
+
channel: channel,
|
|
74
|
+
platform: platform,
|
|
75
|
+
source: source,
|
|
76
|
+
supabase: const SupabaseConfigJson(),
|
|
77
|
+
postgres: PostgresConfigJson(
|
|
78
|
+
host: _p('Postgres host', env['POSTGRES_HOST']),
|
|
79
|
+
port: _p('Postgres port', env['POSTGRES_PORT'] ?? '5432'),
|
|
80
|
+
database: _p('Postgres database', env['POSTGRES_DB'] ?? 'postgres'),
|
|
81
|
+
username: _p('Postgres username', env['POSTGRES_USER']),
|
|
82
|
+
password: _p('Postgres password', env['POSTGRES_PASSWORD']),
|
|
83
|
+
sslMode: _p('Postgres sslmode', env['POSTGRES_SSLMODE']),
|
|
84
|
+
basePath: _p('Storage base path', env['POSTGRES_BASE_PATH']),
|
|
85
|
+
servingBaseUrl:
|
|
86
|
+
_p('Serving base url', env['POSTGRES_SERVING_BASE_URL']),
|
|
87
|
+
),
|
|
88
|
+
);
|
|
89
|
+
case 'cloudflare':
|
|
90
|
+
cfg = FlutterPatcherConfig(
|
|
91
|
+
provider: provider,
|
|
92
|
+
channel: channel,
|
|
93
|
+
platform: platform,
|
|
94
|
+
source: source,
|
|
95
|
+
supabase: const SupabaseConfigJson(),
|
|
96
|
+
cloudflare: CloudflareConfigJson(
|
|
97
|
+
accountId: _p('Cloudflare account id', env['CLOUDFLARE_ACCOUNT_ID']),
|
|
98
|
+
d1DatabaseId:
|
|
99
|
+
_p('Cloudflare D1 database id', env['CLOUDFLARE_D1_DATABASE_ID']),
|
|
100
|
+
apiToken: _p('Cloudflare API token', env['CLOUDFLARE_API_TOKEN']),
|
|
101
|
+
r2Bucket:
|
|
102
|
+
_p('R2 bucket', env['R2_BUCKET'] ?? 'bundles'),
|
|
103
|
+
r2AccessKeyId: _p('R2 access key id', env['R2_ACCESS_KEY_ID']),
|
|
104
|
+
r2SecretAccessKey:
|
|
105
|
+
_p('R2 secret access key', env['R2_SECRET_ACCESS_KEY']),
|
|
106
|
+
r2BasePath: _p('R2 base path', env['R2_BASE_PATH']),
|
|
107
|
+
),
|
|
108
|
+
);
|
|
109
|
+
case 'aws':
|
|
110
|
+
cfg = FlutterPatcherConfig(
|
|
111
|
+
provider: provider,
|
|
112
|
+
channel: channel,
|
|
113
|
+
platform: platform,
|
|
114
|
+
source: source,
|
|
115
|
+
supabase: const SupabaseConfigJson(),
|
|
116
|
+
aws: AwsConfigJson(
|
|
117
|
+
bucket: _p('AWS bucket', env['AWS_BUCKET']),
|
|
118
|
+
region: _p('AWS region', env['AWS_REGION']),
|
|
119
|
+
accessKeyId: _p('AWS access key id', env['AWS_ACCESS_KEY_ID']),
|
|
120
|
+
secretAccessKey:
|
|
121
|
+
_p('AWS secret access key', env['AWS_SECRET_ACCESS_KEY']),
|
|
122
|
+
endpoint: _p('AWS endpoint', env['AWS_ENDPOINT']),
|
|
123
|
+
basePath: _p('AWS base path', env['AWS_BASE_PATH']),
|
|
124
|
+
sessionToken: _p('AWS session token', env['AWS_SESSION_TOKEN']),
|
|
125
|
+
),
|
|
126
|
+
);
|
|
127
|
+
case 'standalone':
|
|
128
|
+
cfg = FlutterPatcherConfig(
|
|
129
|
+
provider: provider,
|
|
130
|
+
channel: channel,
|
|
131
|
+
platform: platform,
|
|
132
|
+
source: source,
|
|
133
|
+
supabase: const SupabaseConfigJson(),
|
|
134
|
+
standalone: StandaloneConfigJson(
|
|
135
|
+
baseUrl: _p('Standalone base url', env['STANDALONE_BASE_URL']),
|
|
136
|
+
),
|
|
137
|
+
);
|
|
138
|
+
case 'supabase':
|
|
139
|
+
default:
|
|
140
|
+
cfg = FlutterPatcherConfig(
|
|
141
|
+
provider: 'supabase',
|
|
142
|
+
channel: channel,
|
|
143
|
+
platform: platform,
|
|
144
|
+
source: source,
|
|
145
|
+
supabase: SupabaseConfigJson(
|
|
146
|
+
url: _p('Supabase URL', env['SUPABASE_URL']),
|
|
147
|
+
serviceRoleKey:
|
|
148
|
+
_p('Supabase service role key', env['SUPABASE_SERVICE_ROLE_KEY']),
|
|
149
|
+
anonKey: _p('Supabase anon key', env['SUPABASE_ANON_KEY']),
|
|
150
|
+
bucket: _p('Storage bucket', env['SUPABASE_BUCKET'] ?? 'bundles'),
|
|
151
|
+
basePath: _p('Storage base path', env['SUPABASE_BASE_PATH']),
|
|
152
|
+
),
|
|
153
|
+
);
|
|
154
|
+
}
|
|
65
155
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
anonKey: anonKey,
|
|
72
|
-
bucket: bucket,
|
|
73
|
-
basePath: basePath,
|
|
74
|
-
),
|
|
75
|
-
channel: channel,
|
|
76
|
-
platform: platform,
|
|
77
|
-
source: source,
|
|
156
|
+
saveConfig(cfg, global: argResults!['global'] as bool);
|
|
157
|
+
step('Wrote ${file.path}');
|
|
158
|
+
stdout.writeln(
|
|
159
|
+
' ${yellow('⚠')} Secrets are stored in plaintext. Restrict file '
|
|
160
|
+
'permissions and use a secrets manager in production.',
|
|
78
161
|
);
|
|
79
|
-
saveConfig(cfg);
|
|
80
|
-
stdout.writeln('Wrote ${file.path}');
|
|
81
162
|
});
|
|
82
163
|
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import 'dart:convert';
|
|
2
|
-
import 'dart:io';
|
|
3
2
|
|
|
4
3
|
import 'package:args/args.dart';
|
|
5
4
|
import 'package:args/command_runner.dart';
|
|
@@ -8,6 +7,7 @@ import '../backend.dart';
|
|
|
8
7
|
import '../cli_base.dart';
|
|
9
8
|
import '../config.dart';
|
|
10
9
|
import '../sign.dart';
|
|
10
|
+
import '../ui/ui.dart';
|
|
11
11
|
|
|
12
12
|
/// `flutter_patcher keys` — generate an Ed25519 keypair for bundle signing.
|
|
13
13
|
class KeysCommand extends Command<int> {
|
|
@@ -29,12 +29,19 @@ class KeysCommand extends Command<int> {
|
|
|
29
29
|
|
|
30
30
|
@override
|
|
31
31
|
Future<int> run() => runGuarded(() async {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
32
|
+
banner('keys');
|
|
33
|
+
final (privateB64, publicB64) = await spinner(
|
|
34
|
+
() => generateEd25519KeyPair(),
|
|
35
|
+
'Generating Ed25519 keypair',
|
|
36
|
+
done: 'Keypair generated',
|
|
37
|
+
);
|
|
38
|
+
box('ed25519 keys', [
|
|
39
|
+
'${bold('private')} (keep secret — use with `deploy --key`):',
|
|
40
|
+
' ${red(privateB64)}',
|
|
41
|
+
'',
|
|
42
|
+
'${bold('public')} (safe to embed / configure on device):',
|
|
43
|
+
' ${green(publicB64)}',
|
|
44
|
+
]);
|
|
38
45
|
if (argResults!['save'] as bool) {
|
|
39
46
|
final file = configCandidates().first;
|
|
40
47
|
final json = file.existsSync() && file.readAsStringSync().trim().isNotEmpty
|
|
@@ -42,8 +49,7 @@ class KeysCommand extends Command<int> {
|
|
|
42
49
|
: <String, dynamic>{};
|
|
43
50
|
writePath(json, 'publicKey', publicB64);
|
|
44
51
|
_save(json);
|
|
45
|
-
|
|
46
|
-
stdout.writeln('Saved public key to ${file.path}');
|
|
52
|
+
step('Saved public key to ${file.path}');
|
|
47
53
|
}
|
|
48
54
|
});
|
|
49
55
|
|
|
@@ -2,10 +2,11 @@ import 'dart:io';
|
|
|
2
2
|
|
|
3
3
|
import 'package:args/args.dart';
|
|
4
4
|
import 'package:args/command_runner.dart';
|
|
5
|
+
import 'package:flutter_patcher_cli/flutter_patcher_cli.dart';
|
|
5
6
|
import 'package:path/path.dart' as p;
|
|
6
7
|
import 'package:postgres/postgres.dart';
|
|
7
8
|
|
|
8
|
-
import '../
|
|
9
|
+
import '../ui/ui.dart';
|
|
9
10
|
|
|
10
11
|
/// `flutter_patcher migrate` — run backend SQL migrations.
|
|
11
12
|
class MigrateCommand extends Command<int> {
|
|
@@ -63,6 +64,7 @@ class MigrateCommand extends Command<int> {
|
|
|
63
64
|
}
|
|
64
65
|
|
|
65
66
|
if (argResults!['dry-run'] as bool) {
|
|
67
|
+
banner('migrate · dry-run');
|
|
66
68
|
for (final f in files) {
|
|
67
69
|
stdout.writeln('--- ${p.basename(f.path)} ---');
|
|
68
70
|
stdout.writeln(f.readAsStringSync());
|
|
@@ -73,11 +75,14 @@ class MigrateCommand extends Command<int> {
|
|
|
73
75
|
final url = argResults!['database-url'] as String? ??
|
|
74
76
|
Platform.environment['DATABASE_URL'];
|
|
75
77
|
if (provider == 'cloudflare' || provider == 'aws') {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
'
|
|
79
|
-
'
|
|
80
|
-
|
|
78
|
+
banner('migrate');
|
|
79
|
+
box('migrate', [
|
|
80
|
+
'Migrations for "$provider" are applied via its platform tooling:',
|
|
81
|
+
' • cloudflare → `wrangler d1 execute`',
|
|
82
|
+
' • aws → AWS console (S3/DynamoDB)',
|
|
83
|
+
'',
|
|
84
|
+
'The SQL files under ${dir} show the intended schema.',
|
|
85
|
+
]);
|
|
81
86
|
return;
|
|
82
87
|
}
|
|
83
88
|
if (url == null || url.isEmpty) {
|
|
@@ -94,6 +99,7 @@ class MigrateCommand extends Command<int> {
|
|
|
94
99
|
? uri.userInfo.split(':').last
|
|
95
100
|
: null,
|
|
96
101
|
);
|
|
102
|
+
banner('migrate');
|
|
97
103
|
final conn = await Connection.open(
|
|
98
104
|
endpoint,
|
|
99
105
|
settings: ConnectionSettings(sslMode: SslMode.disable),
|
|
@@ -112,7 +118,7 @@ class MigrateCommand extends Command<int> {
|
|
|
112
118
|
queryMode: QueryMode.simple,
|
|
113
119
|
);
|
|
114
120
|
if (existing.isNotEmpty) {
|
|
115
|
-
stdout.writeln('skip $name (already applied)');
|
|
121
|
+
stdout.writeln(' ${dim('skip')} $name (already applied)');
|
|
116
122
|
continue;
|
|
117
123
|
}
|
|
118
124
|
final statements = _splitStatements(file.readAsStringSync());
|
|
@@ -123,7 +129,7 @@ class MigrateCommand extends Command<int> {
|
|
|
123
129
|
"INSERT INTO _flutter_patcher_migrations(name) VALUES ('$escaped')",
|
|
124
130
|
queryMode: QueryMode.simple,
|
|
125
131
|
);
|
|
126
|
-
|
|
132
|
+
step('applied $name');
|
|
127
133
|
}
|
|
128
134
|
} finally {
|
|
129
135
|
await conn.close();
|
|
@@ -1,14 +1,11 @@
|
|
|
1
|
-
import 'dart:io';
|
|
2
|
-
|
|
3
1
|
import 'package:args/args.dart';
|
|
4
2
|
import 'package:args/command_runner.dart';
|
|
3
|
+
import 'package:flutter_patcher_cli/flutter_patcher_cli.dart';
|
|
4
|
+
import 'package:flutter_patcher_core/flutter_patcher_core.dart';
|
|
5
5
|
|
|
6
|
-
import '../
|
|
7
|
-
import '../cli_base.dart';
|
|
8
|
-
import '../config.dart';
|
|
9
|
-
import '../operations.dart';
|
|
6
|
+
import '../ui/ui.dart';
|
|
10
7
|
|
|
11
|
-
/// `flutter_patcher rollback` —
|
|
8
|
+
/// `flutter_patcher rollback` — roll a channel back to a previous bundle.
|
|
12
9
|
class RollbackCommand extends Command<int> {
|
|
13
10
|
RollbackCommand({this.config, this.backendOverride});
|
|
14
11
|
|
|
@@ -20,22 +17,50 @@ class RollbackCommand extends Command<int> {
|
|
|
20
17
|
|
|
21
18
|
@override
|
|
22
19
|
String get description =>
|
|
23
|
-
'
|
|
20
|
+
'Roll a channel back: disable the latest enabled bundle (or a specific --bundle-id).';
|
|
24
21
|
|
|
25
22
|
@override
|
|
26
23
|
ArgParser get argParser => ArgParser()
|
|
27
24
|
..addOption('backend', abbr: 'b', help: 'Backend provider.')
|
|
28
|
-
..addOption('channel', abbr: 'c', help: 'Channel to roll back.')
|
|
25
|
+
..addOption('channel', abbr: 'c', help: 'Channel to roll back.')
|
|
26
|
+
..addOption('bundle-id', help: 'Roll back to this specific bundle id.')
|
|
27
|
+
..addOption('platform', abbr: 'p', help: 'Platform filter (e.g. android).');
|
|
29
28
|
|
|
30
29
|
@override
|
|
31
30
|
Future<int> run() => runGuarded(() async {
|
|
32
31
|
final channel = argResults!['channel'] as String?;
|
|
33
32
|
if (channel == null || channel.isEmpty) {
|
|
34
|
-
throw
|
|
33
|
+
throw PackException(
|
|
34
|
+
'Usage: flutter_patcher rollback --channel <channel> [--bundle-id <id>]',
|
|
35
|
+
64,
|
|
36
|
+
);
|
|
35
37
|
}
|
|
38
|
+
final bundleId = argResults!['bundle-id'] as String?;
|
|
39
|
+
final platformRaw = argResults!['platform'] as String?;
|
|
40
|
+
final platform =
|
|
41
|
+
platformRaw == null ? null : Platform.fromValue(platformRaw);
|
|
42
|
+
|
|
36
43
|
final cfg = effectiveConfig(config ?? loadConfig(), argResults!);
|
|
37
44
|
final backend = requireBackend(cfg, override: backendOverride);
|
|
38
|
-
|
|
39
|
-
|
|
45
|
+
|
|
46
|
+
banner('rollback');
|
|
47
|
+
final (disabled, live) = await spinner(
|
|
48
|
+
() async {
|
|
49
|
+
if (bundleId != null && bundleId.isNotEmpty) {
|
|
50
|
+
return rollbackToBundle(backend, channel, bundleId,
|
|
51
|
+
platform: platform);
|
|
52
|
+
}
|
|
53
|
+
final id = await rollbackChannel(backend, channel);
|
|
54
|
+
final nowLive = (await getChannel(backend, channel))?.id ?? '';
|
|
55
|
+
return (<String>[id], nowLive);
|
|
56
|
+
},
|
|
57
|
+
'Rolling back channel "$channel"',
|
|
58
|
+
done: 'Rolled back',
|
|
59
|
+
);
|
|
60
|
+
box('rollback', [
|
|
61
|
+
kv('channel', channel),
|
|
62
|
+
kv('now live', cyan(live)),
|
|
63
|
+
kv('disabled', disabled.isEmpty ? dim('(none)') : disabled.join(', ')),
|
|
64
|
+
]);
|
|
40
65
|
});
|
|
41
66
|
}
|
|
@@ -161,13 +161,23 @@ FlutterPatcherConfig? loadConfig() {
|
|
|
161
161
|
return null;
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
-
/// Persist a config
|
|
165
|
-
|
|
166
|
-
|
|
164
|
+
/// Persist a config. By default writes the project file (cwd); pass
|
|
165
|
+
/// [global: true] to write the global `~/.flutter_patcher/config.json`.
|
|
166
|
+
void saveConfig(FlutterPatcherConfig config, {bool global = false}) {
|
|
167
|
+
final file = global ? configCandidates().last : configCandidates().first;
|
|
167
168
|
file.parent.createSync(recursive: true);
|
|
168
169
|
file.writeAsStringSync(
|
|
169
170
|
const JsonEncoder.withIndent(' ').convert(config.toJson()),
|
|
170
171
|
);
|
|
172
|
+
// Config may contain secrets (service-role keys, DB passwords, tokens). On
|
|
173
|
+
// POSIX systems tighten to owner-only read/write so they aren't world-readable.
|
|
174
|
+
if (!Platform.isWindows) {
|
|
175
|
+
try {
|
|
176
|
+
Process.runSync('chmod', ['600', file.path]);
|
|
177
|
+
} catch (_) {
|
|
178
|
+
// Non-fatal: chmod may be unavailable in some sandboxes.
|
|
179
|
+
}
|
|
180
|
+
}
|
|
171
181
|
}
|
|
172
182
|
|
|
173
183
|
/// Nested dot-path get/set helpers used by `config get/set`.
|
|
@@ -193,6 +193,51 @@ Future<String> rollbackChannel(Backend backend, String channel) async {
|
|
|
193
193
|
return target.id;
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
+
/// Roll back a channel to a specific [targetBundleId]: disable every enabled
|
|
197
|
+
/// bundle newer than it (by creation order) and ensure the target is enabled.
|
|
198
|
+
///
|
|
199
|
+
/// Returns `(disabledBundleIds, liveBundleId)`.
|
|
200
|
+
Future<(List<String>, String)> rollbackToBundle(
|
|
201
|
+
Backend backend,
|
|
202
|
+
String channel,
|
|
203
|
+
String targetBundleId, {
|
|
204
|
+
Platform? platform,
|
|
205
|
+
}) async {
|
|
206
|
+
final res = await backend.db.getBundles(
|
|
207
|
+
DatabaseBundleQueryOptions(
|
|
208
|
+
where: DatabaseBundleQueryWhere(channel: channel, platform: platform),
|
|
209
|
+
orderBy: const DatabaseBundleQueryOrder(direction: 'desc'),
|
|
210
|
+
limit: 100,
|
|
211
|
+
),
|
|
212
|
+
);
|
|
213
|
+
final bundles = res.data;
|
|
214
|
+
Bundle? target;
|
|
215
|
+
for (final b in bundles) {
|
|
216
|
+
if (b.id == targetBundleId) {
|
|
217
|
+
target = b;
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (target == null) {
|
|
222
|
+
throw StateError(
|
|
223
|
+
'Bundle "$targetBundleId" not found on channel "$channel".',
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
final targetIndex = bundles.indexOf(target);
|
|
227
|
+
final disabled = <String>[];
|
|
228
|
+
for (var i = 0; i < targetIndex; i++) {
|
|
229
|
+
if (bundles[i].enabled) {
|
|
230
|
+
disabled.add(bundles[i].id);
|
|
231
|
+
await backend.db.updateBundle(bundles[i].id, {'enabled': false});
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
if (!target.enabled) {
|
|
235
|
+
await backend.db.updateBundle(target.id, {'enabled': true});
|
|
236
|
+
}
|
|
237
|
+
await backend.db.commitBundle();
|
|
238
|
+
return (disabled, target.id);
|
|
239
|
+
}
|
|
240
|
+
|
|
196
241
|
/// List channels configured on the backend.
|
|
197
242
|
Future<List<String>> listChannels(Backend backend) => backend.db.getChannels();
|
|
198
243
|
|
|
@@ -42,6 +42,7 @@ Future<PackResult> packPatch({
|
|
|
42
42
|
String? abi,
|
|
43
43
|
List<String> requestedAssets = const [],
|
|
44
44
|
required String out,
|
|
45
|
+
void Function(int done, int total, String label)? onProgress,
|
|
45
46
|
}) async {
|
|
46
47
|
final apkFile = File(apkPath);
|
|
47
48
|
if (!apkFile.existsSync()) {
|
|
@@ -72,6 +73,7 @@ Future<PackResult> packPatch({
|
|
|
72
73
|
version: version,
|
|
73
74
|
targetVersionCode: targetVersionCode,
|
|
74
75
|
requestedAssets: requestedAssets,
|
|
76
|
+
onProgress: onProgress,
|
|
75
77
|
);
|
|
76
78
|
return result;
|
|
77
79
|
}
|
|
@@ -83,6 +85,7 @@ PackResult _writePatchPackage({
|
|
|
83
85
|
required String version,
|
|
84
86
|
required int targetVersionCode,
|
|
85
87
|
required List<String> requestedAssets,
|
|
88
|
+
void Function(int done, int total, String label)? onProgress,
|
|
86
89
|
}) {
|
|
87
90
|
final patchFiles = <String, _PatchAssetFile>{};
|
|
88
91
|
final operations = <Map<String, dynamic>>[];
|
|
@@ -169,9 +172,14 @@ PackResult _writePatchPackage({
|
|
|
169
172
|
|
|
170
173
|
final package = Archive()
|
|
171
174
|
..addFile(_jsonArchiveFile('manifest.json', zipManifest));
|
|
175
|
+
final progressTotal = libs.length + 1;
|
|
176
|
+
onProgress?.call(0, progressTotal, 'libapp.so');
|
|
177
|
+
var progressDone = 0;
|
|
172
178
|
for (final (currentAbi, soBytes) in libs) {
|
|
173
179
|
package.addFile(
|
|
174
180
|
ArchiveFile('lib/$currentAbi/libapp.so', soBytes.length, soBytes));
|
|
181
|
+
progressDone++;
|
|
182
|
+
onProgress?.call(progressDone, progressTotal, 'lib/$currentAbi/libapp.so');
|
|
175
183
|
}
|
|
176
184
|
|
|
177
185
|
if (requestedAssets.isNotEmpty) {
|
|
@@ -194,6 +202,7 @@ PackResult _writePatchPackage({
|
|
|
194
202
|
final List<int> packageBytes = ZipEncoder().encode(package) ?? const <int>[];
|
|
195
203
|
final outZip = File('${outDir.path}/patch.zip');
|
|
196
204
|
outZip.writeAsBytesSync(packageBytes);
|
|
205
|
+
onProgress?.call(progressTotal, progressTotal, 'patch.zip');
|
|
197
206
|
|
|
198
207
|
final payloadMd5 = md5.convert(packageBytes).toString();
|
|
199
208
|
final outerManifest = <String, dynamic>{
|