mkit-cli 0.3.1__py3-none-any.whl
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.
- mkit/__init__.py +5 -0
- mkit/art.py +1220 -0
- mkit/aso.py +359 -0
- mkit/cli.py +176 -0
- mkit/cloud/__init__.py +1 -0
- mkit/cloud/auth.py +130 -0
- mkit/cloud/fbproject.py +180 -0
- mkit/cloud/firebase.py +164 -0
- mkit/cloud/insight.py +286 -0
- mkit/cloud/money.py +676 -0
- mkit/cloud/play.py +497 -0
- mkit/cloud/store.py +390 -0
- mkit/commands/__init__.py +2 -0
- mkit/commands/art.py +116 -0
- mkit/commands/aso.py +130 -0
- mkit/commands/dev.py +256 -0
- mkit/commands/doctor.py +191 -0
- mkit/commands/firebase_cmds.py +194 -0
- mkit/commands/init.py +213 -0
- mkit/commands/insight.py +110 -0
- mkit/commands/meta.py +84 -0
- mkit/commands/money.py +152 -0
- mkit/commands/release.py +430 -0
- mkit/commands/selfcmd.py +167 -0
- mkit/commands/store.py +263 -0
- mkit/config.py +146 -0
- mkit/data/art/art.toml +119 -0
- mkit/data/art/en.json +38 -0
- mkit/data/datasafety-questions.csv +783 -0
- mkit/datasafety.py +302 -0
- mkit/device.py +246 -0
- mkit/docgen.py +100 -0
- mkit/errors.py +78 -0
- mkit/git.py +95 -0
- mkit/hooks.py +32 -0
- mkit/kinds.py +350 -0
- mkit/notify.py +49 -0
- mkit/output.py +377 -0
- mkit/proc.py +154 -0
- mkit/registry.py +99 -0
- mkit/safety.py +60 -0
- mkit/secrets.py +148 -0
- mkit/toolchain.py +210 -0
- mkit_cli-0.3.1.dist-info/METADATA +179 -0
- mkit_cli-0.3.1.dist-info/RECORD +48 -0
- mkit_cli-0.3.1.dist-info/WHEEL +4 -0
- mkit_cli-0.3.1.dist-info/entry_points.txt +2 -0
- mkit_cli-0.3.1.dist-info/licenses/LICENSE +21 -0
mkit/commands/dev.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""Everyday development: run on a device, emulators, logs, version bump."""
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from .. import device as dev
|
|
6
|
+
from .. import kinds, proc, toolchain
|
|
7
|
+
from ..errors import UsageError
|
|
8
|
+
from ..git import BUMP_PREFIX, Repo
|
|
9
|
+
from ..registry import Arg, command
|
|
10
|
+
|
|
11
|
+
DEVICE_ARG = Arg('device', 'adb serial to use when several are attached')
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def default_log_tags(project):
|
|
15
|
+
tags = project.get('app.log_tags')
|
|
16
|
+
if tags is not None:
|
|
17
|
+
return list(tags)
|
|
18
|
+
return ['flutter:V', 'AndroidRuntime:E'] if project.kind == 'flutter' else []
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@command('run', 'Build, install and launch the app on a device', risk='read',
|
|
22
|
+
args=(Arg('phone', 'use a physical phone', kind='flag', short='p'),
|
|
23
|
+
Arg('emulator', 'use a running emulator', kind='flag', short='e'),
|
|
24
|
+
Arg('clean', 'first-install state: uninstall for every user and wipe backup first',
|
|
25
|
+
kind='flag', short='c'),
|
|
26
|
+
Arg('test', "run the project's tests on the device instead", kind='flag', short='t'),
|
|
27
|
+
Arg('release', 'build the release variant', kind='flag', short='r'),
|
|
28
|
+
Arg('logs', "native apps: follow this app's logcat afterwards "
|
|
29
|
+
"(Shift+R rebuilds, installs and restarts)", kind='flag', short='l'),
|
|
30
|
+
Arg('install', 'Flutter apps: build, install and open without attaching '
|
|
31
|
+
'(no hot reload); used automatically when not at a terminal', kind='flag'),
|
|
32
|
+
DEVICE_ARG),
|
|
33
|
+
examples=('mkit run', 'mkit run -p', 'mkit run -c -p', 'mkit run -r', 'mkit run -t',
|
|
34
|
+
'mkit run --install -l'),
|
|
35
|
+
description='Flutter apps run through `flutter run`: r hot reload, R hot restart, q quit, '
|
|
36
|
+
'logs in the same terminal. Native apps are built with Gradle, installed and '
|
|
37
|
+
'opened; -l then follows logcat, where Shift+R builds and installs the '
|
|
38
|
+
'current code and restarts the app.')
|
|
39
|
+
def run(ctx, args):
|
|
40
|
+
if args.phone and args.emulator:
|
|
41
|
+
raise UsageError('pass only one of -p or -e')
|
|
42
|
+
out, project = ctx.out, ctx.project
|
|
43
|
+
kind = kinds.for_project(project)
|
|
44
|
+
adb = dev.Adb(project.root)
|
|
45
|
+
serial = dev.pick(adb, 'phone' if args.phone else 'emulator' if args.emulator else None, args.device)
|
|
46
|
+
env = dict(os.environ, ANDROID_SERIAL=serial)
|
|
47
|
+
model = adb.model(serial)
|
|
48
|
+
variant = 'release' if args.release else 'debug'
|
|
49
|
+
package = kind.installed_package(args.release)
|
|
50
|
+
if not args.release and package == project.package:
|
|
51
|
+
# Same id as the store build, and a different signature: Android can
|
|
52
|
+
# only install one over the other by removing it, data and all.
|
|
53
|
+
out.warn(f'debug builds install as {package}, the same package as the Play app, so '
|
|
54
|
+
'installing replaces it. Give the debug build type an applicationIdSuffix '
|
|
55
|
+
'(".debug") to keep both.')
|
|
56
|
+
|
|
57
|
+
# flutter run stays attached to the terminal, so it is only for a person
|
|
58
|
+
# at one; an agent's shell gets the install-and-open path instead.
|
|
59
|
+
attached = not out.json and sys.stdin.isatty()
|
|
60
|
+
if project.kind == 'flutter' and not args.test and not args.install and attached:
|
|
61
|
+
if args.clean:
|
|
62
|
+
dev.clean_state(adb, serial, package)
|
|
63
|
+
out.line(f'flutter run on {model} ({serial}) r hot reload, R hot restart, q quit', 'bold')
|
|
64
|
+
ctx.exit_code = proc.interactive(
|
|
65
|
+
[toolchain.tool('flutter'), 'run', '-d', serial] + (['--release'] if args.release else []),
|
|
66
|
+
cwd=project.root, env=env)
|
|
67
|
+
return {'device': serial, 'flutter_run': True}
|
|
68
|
+
|
|
69
|
+
if args.test:
|
|
70
|
+
toolchain.find_jdk(project)
|
|
71
|
+
env.update({k: os.environ[k] for k in ('JAVA_HOME', 'ANDROID_HOME') if k in os.environ})
|
|
72
|
+
out.line(f'testing on {model} ({serial})', 'bold')
|
|
73
|
+
ctx.exit_code = kind.test(serial, env)
|
|
74
|
+
return {'device': serial, 'tests': 'passed' if ctx.exit_code == 0 else 'failed'}
|
|
75
|
+
|
|
76
|
+
steps = 4 if args.clean else 3
|
|
77
|
+
out.start(f'{project.name} run', steps, f'{variant} on {model}')
|
|
78
|
+
out.step(f'Build {variant} APK')
|
|
79
|
+
toolchain.find_jdk(project)
|
|
80
|
+
env.update({k: os.environ[k] for k in ('JAVA_HOME', 'ANDROID_HOME') if k in os.environ})
|
|
81
|
+
package, apk = kind.assemble(variant, env)
|
|
82
|
+
out.done(f'{apk.stat().st_size / 1_000_000:.1f} MB')
|
|
83
|
+
|
|
84
|
+
if args.clean:
|
|
85
|
+
out.step('Clean install state')
|
|
86
|
+
removed, transports = dev.clean_state(adb, serial, package)
|
|
87
|
+
out.done(f'removed for {removed} user(s), backup wiped on {transports} transport(s)'
|
|
88
|
+
if removed or transports else 'nothing installed')
|
|
89
|
+
|
|
90
|
+
def install(built):
|
|
91
|
+
result = adb('install', '-r', str(built), device=serial, timeout=600)
|
|
92
|
+
if 'Success' not in result:
|
|
93
|
+
raise UsageError(f'install failed: {result.strip()[-300:]}', code='INSTALL_FAILED',
|
|
94
|
+
hint='If the signatures differ, uninstall the app first (mkit run -c).')
|
|
95
|
+
|
|
96
|
+
out.step('Install')
|
|
97
|
+
install(apk)
|
|
98
|
+
out.done(package)
|
|
99
|
+
|
|
100
|
+
out.step('Launch')
|
|
101
|
+
component = dev.launch(adb, serial, package)
|
|
102
|
+
out.done(component)
|
|
103
|
+
out.finish(f'{package} running on {model}')
|
|
104
|
+
if args.logs:
|
|
105
|
+
def rebuild():
|
|
106
|
+
"""What Shift+R does: the same build and install this command just did.
|
|
107
|
+
|
|
108
|
+
Shift+R used to relaunch whatever was already installed, so somebody who had edited
|
|
109
|
+
a file and pressed it watched the log of the code they had changed away from, with
|
|
110
|
+
nothing on screen saying why it had not taken.
|
|
111
|
+
"""
|
|
112
|
+
_, built = kind.assemble(variant, env)
|
|
113
|
+
install(built)
|
|
114
|
+
|
|
115
|
+
dev.follow_logs(adb, serial, package, default_log_tags(project), rebuild=rebuild)
|
|
116
|
+
return {'device': serial, 'model': model, 'package': package, 'variant': variant, 'apk': str(apk)}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@command('devices', 'List attached devices and emulators', risk='read', needs_project=False)
|
|
120
|
+
def devices(ctx, args):
|
|
121
|
+
adb = dev.Adb(ctx.project.root if ctx.project else None)
|
|
122
|
+
rows = [{'serial': s, 'state': st, 'type': 'emulator' if dev.is_emulator(s) else 'phone',
|
|
123
|
+
'model': adb.model(s) if st == 'device' else ''} for s, st in adb.devices()]
|
|
124
|
+
if not ctx.out.json:
|
|
125
|
+
if rows:
|
|
126
|
+
ctx.out.table([(r['serial'], r['type'], r['state'], r['model']) for r in rows],
|
|
127
|
+
('serial', 'type', 'state', 'model'))
|
|
128
|
+
else:
|
|
129
|
+
ctx.out.line('no devices attached')
|
|
130
|
+
return {'devices': rows}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@command('logs', "Follow the app's logcat (Shift+R restarts the app)", risk='read',
|
|
134
|
+
args=(DEVICE_ARG, Arg('all', "the whole device log, not just this app's", kind='flag'),
|
|
135
|
+
Arg('release', 'the release (Play) install instead of the debug one',
|
|
136
|
+
kind='flag', short='r')),
|
|
137
|
+
description='Follows the debug install by default, the same one `mkit run` puts on the '
|
|
138
|
+
'device; Shift+R restarts that app. -r switches to the release install.')
|
|
139
|
+
def logs(ctx, args):
|
|
140
|
+
adb = dev.Adb(ctx.project.root)
|
|
141
|
+
serial = dev.pick(adb, None, args.device)
|
|
142
|
+
# The debug install unless asked: it is the one being developed, and the
|
|
143
|
+
# store package used to be restarted by Shift+R here instead.
|
|
144
|
+
package = kinds.for_project(ctx.project).installed_package(args.release)
|
|
145
|
+
dev.follow_logs(adb, serial, package, default_log_tags(ctx.project), everything=args.all)
|
|
146
|
+
return {'device': serial, 'package': package}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@command('emu', 'Start an Android emulator (cold boot) and wait for it', risk='read',
|
|
150
|
+
needs_project=False,
|
|
151
|
+
args=(Arg('avd', 'emulator name (default: ask, or the only one)', kind='positional', nargs='?'),
|
|
152
|
+
Arg('list', 'only list the emulators', kind='flag')),
|
|
153
|
+
examples=('mkit emu', 'mkit emu Pixel_8_API_35', 'mkit emu --list'))
|
|
154
|
+
def emu(ctx, args):
|
|
155
|
+
root = ctx.project.root if ctx.project else None
|
|
156
|
+
exe, names = dev.avds(root)
|
|
157
|
+
out = ctx.out
|
|
158
|
+
if args.list or not names:
|
|
159
|
+
if not out.json:
|
|
160
|
+
for n in names:
|
|
161
|
+
out.line(f' {n}')
|
|
162
|
+
if not names:
|
|
163
|
+
out.line("no emulators; create one in Android Studio's Device Manager")
|
|
164
|
+
return {'avds': names}
|
|
165
|
+
if args.avd:
|
|
166
|
+
if args.avd not in names:
|
|
167
|
+
raise UsageError(f'unknown emulator "{args.avd}"; have: {", ".join(names)}')
|
|
168
|
+
avd = args.avd
|
|
169
|
+
elif len(names) == 1 or out.json:
|
|
170
|
+
avd = names[0]
|
|
171
|
+
else:
|
|
172
|
+
for i, n in enumerate(names, 1):
|
|
173
|
+
out.line(f' {i}) {n}')
|
|
174
|
+
choice = input(f'\nWhich one? (1-{len(names)}, Enter for 1) ').strip() or '1'
|
|
175
|
+
try:
|
|
176
|
+
avd = names[int(choice) - 1]
|
|
177
|
+
except (ValueError, IndexError):
|
|
178
|
+
raise UsageError(f'not one of the choices: {choice}') from None
|
|
179
|
+
out.start('emulator', 1, avd)
|
|
180
|
+
out.step(f'Boot {avd}')
|
|
181
|
+
serial = dev.start_emulator(dev.Adb(root), exe, avd, root)
|
|
182
|
+
out.done(serial)
|
|
183
|
+
out.finish(f'{avd} ready')
|
|
184
|
+
return {'avd': avd, 'serial': serial}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@command('studio', 'Open this app in Android Studio', risk='read',
|
|
188
|
+
args=(Arg('android', 'Flutter apps: open the android/ Gradle project instead',
|
|
189
|
+
kind='flag', short='a', long=False),),
|
|
190
|
+
examples=('mkit studio', 'mkit studio -a'),
|
|
191
|
+
description="Opens the folder Android Studio should have: the repo root for a Flutter "
|
|
192
|
+
"app, where the Flutter plugin gives Dart analysis, hot reload and the "
|
|
193
|
+
"device list, with android/ indexed underneath. -a opens android/ on its "
|
|
194
|
+
"own instead, a plain Gradle project for native work (manifests, AGP, "
|
|
195
|
+
"Gradle plugins) with no Dart support. Native apps open at their Gradle "
|
|
196
|
+
"root either way. Studio is left running when mkit exits.")
|
|
197
|
+
def studio(ctx, args):
|
|
198
|
+
project = ctx.project
|
|
199
|
+
kind = kinds.for_project(project)
|
|
200
|
+
folder = kind.android_dir if args.android else project.root
|
|
201
|
+
exe = toolchain.studio(project)
|
|
202
|
+
if ctx.dry_run:
|
|
203
|
+
if not ctx.out.json:
|
|
204
|
+
ctx.out.line(f'would open {folder} in {exe}')
|
|
205
|
+
return {'folder': str(folder), 'studio': exe, 'pid': None, 'dry_run': True}
|
|
206
|
+
pid = proc.detach([exe, str(folder)], cwd=project.root)
|
|
207
|
+
if not ctx.out.json:
|
|
208
|
+
where = folder if folder == project.root else folder.relative_to(project.root)
|
|
209
|
+
ctx.out.line(f'Android Studio opening {where}')
|
|
210
|
+
return {'folder': str(folder), 'studio': exe, 'pid': pid}
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@command('version', "Show this app's version, as the version file has it", risk='read')
|
|
214
|
+
def version(ctx, args):
|
|
215
|
+
kind = kinds.for_project(ctx.project)
|
|
216
|
+
name, code = kind.current_version()
|
|
217
|
+
if not ctx.out.json:
|
|
218
|
+
ctx.out.line(f'{name} ({code}) {kind.version.path.relative_to(ctx.project.root)}')
|
|
219
|
+
return {'versionName': name, 'versionCode': code, 'file': str(kind.version.path)}
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@command('bump', 'Bump the version name and code (and commit it, unless --no-commit)', risk='write',
|
|
223
|
+
args=(Arg('level', 'which part of the name', kind='positional', nargs='?',
|
|
224
|
+
choices=('major', 'minor', 'patch'), default='patch'),
|
|
225
|
+
Arg('no_commit', 'change the file only', kind='flag')),
|
|
226
|
+
examples=('mkit bump', 'mkit bump minor', 'mkit bump --no-commit'))
|
|
227
|
+
def bump(ctx, args):
|
|
228
|
+
kind = kinds.for_project(ctx.project)
|
|
229
|
+
old_name, old_code, name, code = kind.bump(args.level)
|
|
230
|
+
committed = None
|
|
231
|
+
if not args.no_commit:
|
|
232
|
+
repo = Repo(ctx.project.root)
|
|
233
|
+
committed = repo.commit_files([kind.version.path],
|
|
234
|
+
f'{BUMP_PREFIX}{name} (versionCode {code})')[:8]
|
|
235
|
+
if not ctx.out.json:
|
|
236
|
+
ctx.out.line(f'{old_name} ({old_code}) -> {name} ({code})' + (f' commit {committed}' if committed else ''))
|
|
237
|
+
return {'from': {'versionName': old_name, 'versionCode': old_code},
|
|
238
|
+
'to': {'versionName': name, 'versionCode': code}, 'commit': committed}
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
@command('gradle', "Run Gradle tasks with the right JDK and folder (lint, clean, test, profiles...)",
|
|
242
|
+
risk='write',
|
|
243
|
+
args=(Arg('tasks', 'Gradle tasks and flags, passed through as-is', kind='positional', nargs='...'),),
|
|
244
|
+
examples=('mkit gradle :app:lintDebug', 'mkit gradle clean', 'mkit gradle :app:testDebugUnitTest',
|
|
245
|
+
'mkit gradle :app:generateBaselineProfile'))
|
|
246
|
+
def gradle(ctx, args):
|
|
247
|
+
"""Anything a project did through Gradle before, with the JDK mkit picks and
|
|
248
|
+
from the Gradle root (android/ for Flutter apps). Output streams as-is."""
|
|
249
|
+
if not args.tasks:
|
|
250
|
+
raise UsageError('name at least one Gradle task, e.g. mkit gradle :app:lintDebug')
|
|
251
|
+
kind = kinds.for_project(ctx.project)
|
|
252
|
+
jdk = toolchain.find_jdk(ctx.project)
|
|
253
|
+
env = dict(os.environ, JAVA_HOME=jdk)
|
|
254
|
+
ctx.exit_code = proc.interactive([toolchain.gradlew(kind.android_dir), *args.tasks],
|
|
255
|
+
cwd=kind.android_dir, env=env)
|
|
256
|
+
return {'tasks': args.tasks, 'exit': ctx.exit_code}
|
mkit/commands/doctor.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""`mkit doctor`: what this project uses, and whether each part can work.
|
|
2
|
+
|
|
3
|
+
Every feature in mkit.toml is shown as one of:
|
|
4
|
+
|
|
5
|
+
on switched on and everything it needs is present
|
|
6
|
+
broken switched on but a credential or tool is missing; steps using it
|
|
7
|
+
are skipped in pipelines and fail when asked for directly
|
|
8
|
+
off not in mkit.toml; this app does not use it
|
|
9
|
+
|
|
10
|
+
Credentials are opened and parsed, and the non-secret parts (the service
|
|
11
|
+
account email and its Cloud project) are shown, because "the key is there but
|
|
12
|
+
it belongs to another project" is the mistake worth catching early.
|
|
13
|
+
"""
|
|
14
|
+
import os
|
|
15
|
+
import platform
|
|
16
|
+
import shutil
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from .. import __version__, config
|
|
21
|
+
from ..errors import FAILED, MkitError
|
|
22
|
+
from ..registry import command
|
|
23
|
+
|
|
24
|
+
# feature -> credentials it cannot work without
|
|
25
|
+
FEATURE_CREDENTIALS = {
|
|
26
|
+
'play': ('PLAY_SERVICE_ACCOUNT',),
|
|
27
|
+
'firebase': ('FIREBASE_SERVICE_ACCOUNT',),
|
|
28
|
+
'notify.telegram': ('TELEGRAM',),
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
# Store keys with **no default**, and the commands that cannot run without
|
|
32
|
+
# them. Only these: `listing_dir` falls back to docs/store-listing, `art_dir` to
|
|
33
|
+
# store/ (or [art] out), and `icon` to the art folder's own icon.png, so their
|
|
34
|
+
# absence blocks nothing. The first version of this check listed all five and
|
|
35
|
+
# told two apps their art was broken when it was not.
|
|
36
|
+
#
|
|
37
|
+
# Not a problem either, even for these three: an app with no in-app purchases
|
|
38
|
+
# has no products file on purpose. It is a note about what this app cannot do.
|
|
39
|
+
STORE_KEYS = {
|
|
40
|
+
'whatsnew_dir': "whatsnew check, and release notes on deploy",
|
|
41
|
+
'datasafety': 'datasafety build/check/apply',
|
|
42
|
+
'products': 'products plan/apply',
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
FEATURE_WHAT = {
|
|
46
|
+
'play': 'Google Play: upload, tracks, listing, products, reviews, vitals',
|
|
47
|
+
'store': 'store listing, art, what\'s new and data safety files',
|
|
48
|
+
'firebase': 'Firestore rules, update doc, Remote Config, symbols',
|
|
49
|
+
'notify.telegram': 'Telegram message after deploy and promote',
|
|
50
|
+
'aso': 'keyword rank and winnability checks',
|
|
51
|
+
'hooks': 'project-specific scripts at fixed pipeline points',
|
|
52
|
+
'art': 'Play screenshots and the feature graphic, drawn from device captures',
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def sdk_dir(project=None):
|
|
57
|
+
for key in ('ANDROID_HOME', 'ANDROID_SDK_ROOT'):
|
|
58
|
+
if os.environ.get(key):
|
|
59
|
+
return Path(os.environ[key])
|
|
60
|
+
if project:
|
|
61
|
+
for props in (project.root / 'local.properties', project.root / 'android' / 'local.properties'):
|
|
62
|
+
if props.is_file():
|
|
63
|
+
for line in props.read_text(encoding='utf-8', errors='replace').splitlines():
|
|
64
|
+
if line.startswith('sdk.dir='):
|
|
65
|
+
return Path(line[8:].replace('\\:', ':').replace('\\\\', '\\'))
|
|
66
|
+
guess = Path.home() / ('AppData/Local/Android/Sdk' if os.name == 'nt' else 'Android/Sdk')
|
|
67
|
+
return guess if guess.is_dir() else None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def find_tool(name, project=None):
|
|
71
|
+
if name == 'adb':
|
|
72
|
+
# The SDK's adb first: other tools (scrcpy, phone suites) ship their own
|
|
73
|
+
# adb on PATH, and two adb versions keep killing each other's server.
|
|
74
|
+
sdk = sdk_dir(project)
|
|
75
|
+
if sdk:
|
|
76
|
+
exe = sdk / 'platform-tools' / ('adb.exe' if os.name == 'nt' else 'adb')
|
|
77
|
+
if exe.is_file():
|
|
78
|
+
return str(exe)
|
|
79
|
+
return shutil.which(name)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _credential(ctx, name):
|
|
83
|
+
source = ctx.secrets.source(name)
|
|
84
|
+
if not source:
|
|
85
|
+
return {'name': name, 'status': 'missing'}
|
|
86
|
+
try:
|
|
87
|
+
data = ctx.secrets.json(name)
|
|
88
|
+
except MkitError as e:
|
|
89
|
+
return {'name': name, 'status': 'unreadable', 'source': source, 'error': e.message}
|
|
90
|
+
info = {'name': name, 'status': 'ok', 'source': source}
|
|
91
|
+
for key in ('client_email', 'project_id'):
|
|
92
|
+
if data.get(key):
|
|
93
|
+
info[key] = data[key]
|
|
94
|
+
if name == 'TELEGRAM' and not (data.get('bot_token') and data.get('chat_id')):
|
|
95
|
+
info.update(status='unreadable', error='needs bot_token and chat_id')
|
|
96
|
+
return info
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@command('doctor', 'Check tools, settings and credentials for this project', risk='read',
|
|
100
|
+
needs_project=False, examples=('mkit doctor', 'mkit doctor --json'))
|
|
101
|
+
def doctor(ctx, args):
|
|
102
|
+
out = ctx.out
|
|
103
|
+
project = ctx.project
|
|
104
|
+
report = {
|
|
105
|
+
'engine': {'version': __version__, 'python': platform.python_version(),
|
|
106
|
+
'executable': sys.executable, 'home': str(config.home())},
|
|
107
|
+
'project': None, 'tools': [], 'features': [], 'problems': [], 'unavailable': [],
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if project is None:
|
|
111
|
+
report['problems'].append('no mkit.toml here or above; run `mkit init` in the app folder')
|
|
112
|
+
else:
|
|
113
|
+
report['project'] = {'root': str(project.root), 'name': project.name,
|
|
114
|
+
'kind': project.kind, 'package': project.package}
|
|
115
|
+
for key in ('app.kind', 'app.package'):
|
|
116
|
+
if not project.get(key):
|
|
117
|
+
report['problems'].append(f'{key} is not set in mkit.toml')
|
|
118
|
+
|
|
119
|
+
wanted = ['git', 'adb']
|
|
120
|
+
if project and project.kind == 'flutter':
|
|
121
|
+
wanted.append('flutter')
|
|
122
|
+
if project and project.section('firebase').get('rules'):
|
|
123
|
+
wanted.append('firebase')
|
|
124
|
+
for tool in wanted:
|
|
125
|
+
path = find_tool(tool, project)
|
|
126
|
+
report['tools'].append({'name': tool, 'path': path, 'status': 'ok' if path else 'missing'})
|
|
127
|
+
if not path:
|
|
128
|
+
report['problems'].append(f'{tool} not found on PATH')
|
|
129
|
+
|
|
130
|
+
if project:
|
|
131
|
+
for feature in config.FEATURES:
|
|
132
|
+
entry = {'feature': feature, 'what': FEATURE_WHAT.get(feature, ''), 'credentials': []}
|
|
133
|
+
if not project.has(feature):
|
|
134
|
+
entry['status'] = 'off'
|
|
135
|
+
else:
|
|
136
|
+
creds = [_credential(ctx, n) for n in FEATURE_CREDENTIALS.get(feature, ())]
|
|
137
|
+
entry['credentials'] = creds
|
|
138
|
+
bad = [c for c in creds if c['status'] != 'ok']
|
|
139
|
+
entry['status'] = 'broken' if bad else 'on'
|
|
140
|
+
if feature == 'store':
|
|
141
|
+
missing = [k for k in STORE_KEYS if not project.get(f'store.{k}')]
|
|
142
|
+
entry['missing'] = missing
|
|
143
|
+
for key in missing:
|
|
144
|
+
report['unavailable'].append(
|
|
145
|
+
f'[store] has no {key}, so `mkit {STORE_KEYS[key]}` cannot run')
|
|
146
|
+
for c in bad:
|
|
147
|
+
report['problems'].append(
|
|
148
|
+
f'[{feature}] {c["name"]} is {c["status"]}'
|
|
149
|
+
+ (f': {c["error"]}' if c.get('error') else
|
|
150
|
+
f'; add MKIT_{c["name"]}_JSON_BASE64 to .env'))
|
|
151
|
+
report['features'].append(entry)
|
|
152
|
+
|
|
153
|
+
if not out.json:
|
|
154
|
+
_print_report(out, report)
|
|
155
|
+
if report['problems']:
|
|
156
|
+
ctx.exit_code = FAILED
|
|
157
|
+
return report
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _print_report(out, report):
|
|
161
|
+
eng = report['engine']
|
|
162
|
+
out.heading(f'mkit {eng["version"]}')
|
|
163
|
+
out.line(f' python {eng["python"]} user settings {eng["home"]}', 'dim')
|
|
164
|
+
proj = report['project']
|
|
165
|
+
if proj:
|
|
166
|
+
out.heading(f'{proj["name"]} {proj["kind"] or "?"} {proj["package"] or "?"}')
|
|
167
|
+
out.line(f' {proj["root"]}', 'dim')
|
|
168
|
+
out.heading('tools')
|
|
169
|
+
out.table([(t['name'], t['status'], t['path'] or '') for t in report['tools']],
|
|
170
|
+
('tool', 'status', 'path'))
|
|
171
|
+
if report['features']:
|
|
172
|
+
out.heading('features')
|
|
173
|
+
rows = []
|
|
174
|
+
for f in report['features']:
|
|
175
|
+
creds = ', '.join(
|
|
176
|
+
f'{c["name"]}: {c["status"]}' + (f' ({c["client_email"]})' if c.get('client_email') else '')
|
|
177
|
+
for c in f['credentials'])
|
|
178
|
+
rows.append((f['feature'], f['status'], creds or f['what']))
|
|
179
|
+
out.table(rows, ('feature', 'status', 'details'))
|
|
180
|
+
out.line()
|
|
181
|
+
if report.get('unavailable'):
|
|
182
|
+
out.line('not configured, so these cannot run:', 'dim')
|
|
183
|
+
for note in report['unavailable']:
|
|
184
|
+
out.line(f' {note}', 'dim')
|
|
185
|
+
out.line()
|
|
186
|
+
if report['problems']:
|
|
187
|
+
out.line(f'{len(report["problems"])} problem(s):', 'yellow')
|
|
188
|
+
for p in report['problems']:
|
|
189
|
+
out.line(f' - {p}', 'yellow')
|
|
190
|
+
else:
|
|
191
|
+
out.line('All good.', 'green')
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Remote Config, Firebase app settings, internal app sharing."""
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .. import kinds, safety, toolchain
|
|
8
|
+
from ..cloud import auth, fbproject
|
|
9
|
+
from ..errors import DRIFT, MkitError, UsageError
|
|
10
|
+
from ..registry import Arg, command, group
|
|
11
|
+
|
|
12
|
+
group('remoteconfig', 'Firebase Remote Config template')
|
|
13
|
+
group('firebase', 'Firebase app settings (SHA fingerprints, google-services.json)')
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@command('remoteconfig pull', 'Save the live Remote Config template to the template file', risk='write',
|
|
17
|
+
needs=('firebase',))
|
|
18
|
+
def rc_pull(ctx, args):
|
|
19
|
+
template, etag = fbproject.rc_get(ctx)
|
|
20
|
+
path = fbproject.template_path(ctx)
|
|
21
|
+
body = {k: template[k] for k in fbproject.TEMPLATE_KEYS if k in template}
|
|
22
|
+
path.write_text(json.dumps(body, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
|
|
23
|
+
version = template.get('version', {}).get('versionNumber')
|
|
24
|
+
if not ctx.out.json:
|
|
25
|
+
ctx.out.line(f'version {version}: {len(body.get("parameters", {}))} parameter(s) -> {path}')
|
|
26
|
+
return {'file': str(path), 'version': version}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@command('remoteconfig plan', 'Compare the template file with the live Remote Config', risk='plan',
|
|
30
|
+
needs=('firebase',))
|
|
31
|
+
def rc_plan(ctx, args):
|
|
32
|
+
remote, _ = fbproject.rc_get(ctx)
|
|
33
|
+
changes = fbproject.rc_diff(remote, fbproject.local_template(ctx))
|
|
34
|
+
if not ctx.out.json:
|
|
35
|
+
for c in changes:
|
|
36
|
+
ctx.out.line(f' {c["change"]:7} {c["parameter"]}')
|
|
37
|
+
if not changes:
|
|
38
|
+
ctx.out.line(' no changes', 'green')
|
|
39
|
+
if changes:
|
|
40
|
+
ctx.exit_code = DRIFT
|
|
41
|
+
return {'version': remote.get('version', {}).get('versionNumber'), 'changes': changes}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@command('remoteconfig apply', 'Publish the template file as the live Remote Config', risk='publish',
|
|
45
|
+
needs=('firebase',))
|
|
46
|
+
def rc_apply(ctx, args):
|
|
47
|
+
remote, etag = fbproject.rc_get(ctx)
|
|
48
|
+
local = fbproject.local_template(ctx)
|
|
49
|
+
changes = fbproject.rc_diff(remote, local)
|
|
50
|
+
if not changes:
|
|
51
|
+
return {'changes': [], 'changed': False}
|
|
52
|
+
safety.confirm(ctx, f'Publish Remote Config ({len(changes)} change(s)) to {fbproject.project_id(ctx)}',
|
|
53
|
+
[f'{c["change"]} {c["parameter"]}' for c in changes])
|
|
54
|
+
version = fbproject.rc_publish(ctx, local, etag, validate_only=ctx.dry_run)
|
|
55
|
+
if not ctx.out.json:
|
|
56
|
+
ctx.out.line('validated, not published (dry run)' if ctx.dry_run
|
|
57
|
+
else f'published version {version.get("versionNumber")}', 'green')
|
|
58
|
+
return {'changes': changes, 'changed': not ctx.dry_run, 'version': version.get('versionNumber')}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@command('remoteconfig versions', 'Recent Remote Config versions', risk='read', needs=('firebase',),
|
|
62
|
+
args=(Arg('limit', 'how many', type=int, default=10),))
|
|
63
|
+
def rc_versions(ctx, args):
|
|
64
|
+
versions = fbproject.rc_versions(ctx, args.limit)
|
|
65
|
+
if not ctx.out.json:
|
|
66
|
+
ctx.out.table([(v.get('versionNumber'), v.get('updateTime', '')[:19], v.get('updateType', ''),
|
|
67
|
+
(v.get('updateUser') or {}).get('email', ''), v.get('description', ''))
|
|
68
|
+
for v in versions], ('version', 'time', 'type', 'by', 'description'))
|
|
69
|
+
return {'versions': versions}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@command('remoteconfig rollback', 'Make an earlier Remote Config version live again', risk='publish',
|
|
73
|
+
needs=('firebase',), args=(Arg('version', 'version number', kind='positional', type=int),))
|
|
74
|
+
def rc_rollback(ctx, args):
|
|
75
|
+
safety.confirm(ctx, f'Roll Remote Config back to version {args.version}')
|
|
76
|
+
if ctx.dry_run:
|
|
77
|
+
return {'version': args.version, 'dry_run': True}
|
|
78
|
+
version = fbproject.rc_rollback(ctx, args.version)
|
|
79
|
+
return {'version': version.get('versionNumber'), 'rolledBackTo': args.version}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@command('firebase sha', 'SHA fingerprints registered for this app in Firebase', risk='read',
|
|
83
|
+
needs=('firebase',))
|
|
84
|
+
def fb_sha(ctx, args):
|
|
85
|
+
app, certs = fbproject.sha_list(ctx)
|
|
86
|
+
if not ctx.out.json:
|
|
87
|
+
ctx.out.line(f'{app.get("displayName") or app["packageName"]} ({app["appId"]})', 'bold')
|
|
88
|
+
for c in certs:
|
|
89
|
+
ctx.out.line(f' {c.get("certType")} {c.get("shaHash")}')
|
|
90
|
+
ctx.out.line('Add the Play App Signing key too: Play Console > Setup > App signing.', 'dim')
|
|
91
|
+
return {'appId': app['appId'], 'certificates': certs}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@command('firebase sha-add', 'Register a signing SHA-1/SHA-256 with Firebase (then pull the config)',
|
|
95
|
+
risk='write', needs=('firebase',), args=(Arg('sha', 'fingerprint, with or without colons', kind='positional'),))
|
|
96
|
+
def fb_sha_add(ctx, args):
|
|
97
|
+
result = fbproject.sha_add(ctx, args.sha)
|
|
98
|
+
if not ctx.out.json:
|
|
99
|
+
ctx.out.line(('added ' if result['changed'] else 'already registered: ') + result['certType'])
|
|
100
|
+
return result
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _write_config(ctx, out=None):
|
|
104
|
+
"""Pull google-services.json into the app module. It lists every Android app
|
|
105
|
+
in the project, so it carries the debug client too once that exists."""
|
|
106
|
+
text = fbproject.config_file(ctx)
|
|
107
|
+
kind = kinds.for_project(ctx.project)
|
|
108
|
+
module_dir = kind.android_dir / ('app' if ctx.project.kind == 'flutter' else kind.module)
|
|
109
|
+
path = ctx.project.root / out if out else module_dir / 'google-services.json'
|
|
110
|
+
changed = not path.is_file() or path.read_text(encoding='utf-8') != text
|
|
111
|
+
if changed:
|
|
112
|
+
path.write_text(text, encoding='utf-8')
|
|
113
|
+
return path, changed
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@command('firebase config', 'Download google-services.json for this app', risk='write', needs=('firebase',),
|
|
117
|
+
args=(Arg('out', 'where to write it (default: the Android app module)'),))
|
|
118
|
+
def fb_config(ctx, args):
|
|
119
|
+
path, changed = _write_config(ctx, args.out)
|
|
120
|
+
if not ctx.out.json:
|
|
121
|
+
ctx.out.line(f'{"wrote" if changed else "unchanged"} {path}')
|
|
122
|
+
return {'file': str(path), 'changed': changed}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _debug_key_fingerprints(ctx):
|
|
126
|
+
"""SHA-1 and SHA-256 of this computer's Android debug key, or {} without one."""
|
|
127
|
+
keystore = Path.home() / '.android' / 'debug.keystore'
|
|
128
|
+
if not keystore.is_file():
|
|
129
|
+
return {}
|
|
130
|
+
jdk = Path(toolchain.find_jdk(ctx.project))
|
|
131
|
+
keytool = jdk / 'bin' / ('keytool.exe' if os.name == 'nt' else 'keytool')
|
|
132
|
+
result = subprocess.run([str(keytool), '-list', '-v', '-keystore', str(keystore),
|
|
133
|
+
'-alias', 'androiddebugkey', '-storepass', 'android', '-keypass', 'android'],
|
|
134
|
+
capture_output=True, text=True, timeout=60)
|
|
135
|
+
return fbproject.parse_fingerprints(result.stdout) if result.returncode == 0 else {}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@command('firebase debug-app', "Register the debug build's package with Firebase, then pull the config",
|
|
139
|
+
risk='write', needs=('firebase',),
|
|
140
|
+
description='For a debug build type with an applicationIdSuffix: Firebase, and the '
|
|
141
|
+
'google-services Gradle plugin, only accept packages registered in the '
|
|
142
|
+
"project. Registers it when missing, adds this computer's debug key "
|
|
143
|
+
'(SHA-1 and SHA-256, for Google sign-in) and writes google-services.json. '
|
|
144
|
+
'Safe to run again.')
|
|
145
|
+
def fb_debug_app(ctx, args):
|
|
146
|
+
out, project = ctx.out, ctx.project
|
|
147
|
+
package = kinds.for_project(project).debug_package()
|
|
148
|
+
if package == project.package:
|
|
149
|
+
raise UsageError(f'debug builds install as {package}, the store package, so there is nothing '
|
|
150
|
+
'to register',
|
|
151
|
+
hint='Give the debug build type an applicationIdSuffix (".debug") first.',
|
|
152
|
+
code='NO_DEBUG_SUFFIX')
|
|
153
|
+
app, created = fbproject.ensure_android_app(ctx, package, f'{project.name} (debug)')
|
|
154
|
+
certificates = []
|
|
155
|
+
for cert_type, sha in _debug_key_fingerprints(ctx).items():
|
|
156
|
+
certificates.append(fbproject.sha_add(ctx, sha, package))
|
|
157
|
+
path, changed = _write_config(ctx)
|
|
158
|
+
if not out.json:
|
|
159
|
+
out.line(f'{"registered" if created else "already registered"} {package}')
|
|
160
|
+
for c in certificates:
|
|
161
|
+
out.line(f' {c["certType"]} {"added" if c["changed"] else "already there"}')
|
|
162
|
+
if not certificates:
|
|
163
|
+
out.line(' no debug key found in ~/.android; Google sign-in in debug builds needs one', 'dim')
|
|
164
|
+
out.line(f'{"wrote" if changed else "unchanged"} {path}')
|
|
165
|
+
return {'package': package, 'appId': app.get('appId'), 'created': created,
|
|
166
|
+
'certificates': certificates, 'file': str(path), 'config_changed': changed}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@command('share', 'Build the release bundle and get an internal app sharing link', risk='write',
|
|
170
|
+
needs=('play',), args=(Arg('no_build', 'upload the bundle already built', kind='flag'),))
|
|
171
|
+
def share(ctx, args):
|
|
172
|
+
kind = kinds.for_project(ctx.project)
|
|
173
|
+
out = ctx.out
|
|
174
|
+
out.start(f'{ctx.project.name} share', 2, 'internal app sharing')
|
|
175
|
+
out.step('Build release bundle')
|
|
176
|
+
if not args.no_build:
|
|
177
|
+
jdk = toolchain.find_jdk(ctx.project)
|
|
178
|
+
kind.bundle(dict(os.environ, JAVA_HOME=jdk))
|
|
179
|
+
module = kind.modules()[0]
|
|
180
|
+
if not module.aab.is_file():
|
|
181
|
+
raise MkitError(f'no bundle at {module.aab}', code='NO_BUNDLE')
|
|
182
|
+
out.done(f'{module.aab.stat().st_size / 1_000_000:.1f} MB')
|
|
183
|
+
out.step('Upload')
|
|
184
|
+
from googleapiclient.http import MediaFileUpload
|
|
185
|
+
svc = auth.play(ctx)
|
|
186
|
+
try:
|
|
187
|
+
result = svc.internalappsharingartifacts().uploadbundle(
|
|
188
|
+
packageName=ctx.project.package,
|
|
189
|
+
media_body=MediaFileUpload(str(module.aab), mimetype='application/octet-stream')).execute()
|
|
190
|
+
except Exception as e:
|
|
191
|
+
raise auth.translate(e, 'Play') from None
|
|
192
|
+
out.done()
|
|
193
|
+
out.finish(result.get('downloadUrl', ''))
|
|
194
|
+
return {'downloadUrl': result.get('downloadUrl'), 'sha256': result.get('sha256')}
|