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/secrets.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Credentials, from two .env files, never from the repo tree.
|
|
2
|
+
|
|
3
|
+
~/.mkit/.env shared by every app: the Play service account (one
|
|
4
|
+
developer account), Telegram, the reports bucket
|
|
5
|
+
<project>/.env this app only: its Firebase service account, overrides
|
|
6
|
+
|
|
7
|
+
The shell environment wins over both, then the project file, then the user
|
|
8
|
+
file. A credential is JSON, stored one of two ways:
|
|
9
|
+
|
|
10
|
+
MKIT_<NAME>_JSON_BASE64=eyJ0eXBlIjoi... base64, survives .env quoting
|
|
11
|
+
MKIT_<NAME>_FILE=C:/keys/play.json a path outside the repo
|
|
12
|
+
|
|
13
|
+
The unprefixed spelling (<NAME>_JSON_BASE64) is accepted too, because the
|
|
14
|
+
older per-project kits and CI used it.
|
|
15
|
+
|
|
16
|
+
Every secret value that is read is registered with the output layer, so it is
|
|
17
|
+
masked if it ever reaches a log line, an error, or a JSON result.
|
|
18
|
+
"""
|
|
19
|
+
import base64
|
|
20
|
+
import binascii
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from . import config
|
|
26
|
+
from .errors import CredentialMissing
|
|
27
|
+
|
|
28
|
+
# name -> what it is for; doctor and error hints use the description.
|
|
29
|
+
KNOWN = {
|
|
30
|
+
'PLAY_SERVICE_ACCOUNT': 'Google Play Android Publisher service account (JSON)',
|
|
31
|
+
'FIREBASE_SERVICE_ACCOUNT': "Firebase/Google Cloud service account for this app's project (JSON)",
|
|
32
|
+
'TELEGRAM': 'Telegram bot, JSON {"bot_token": "...", "chat_id": "..."}',
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
_registered = set()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def register(value):
|
|
39
|
+
"""Mark a string as secret so the output layer masks it."""
|
|
40
|
+
if isinstance(value, str) and len(value) >= 8:
|
|
41
|
+
_registered.add(value)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def redact(text):
|
|
45
|
+
if not isinstance(text, str) or not _registered:
|
|
46
|
+
return text
|
|
47
|
+
for secret in sorted(_registered, key=len, reverse=True):
|
|
48
|
+
if secret in text:
|
|
49
|
+
text = text.replace(secret, '***')
|
|
50
|
+
return text
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def parse_env_file(path):
|
|
54
|
+
"""KEY=VALUE lines; # comments; optional quotes. No interpolation."""
|
|
55
|
+
values = {}
|
|
56
|
+
try:
|
|
57
|
+
with open(path, encoding='utf-8-sig') as f:
|
|
58
|
+
lines = f.read().splitlines()
|
|
59
|
+
except FileNotFoundError:
|
|
60
|
+
return values
|
|
61
|
+
for raw in lines:
|
|
62
|
+
line = raw.strip()
|
|
63
|
+
if not line or line.startswith('#') or '=' not in line:
|
|
64
|
+
continue
|
|
65
|
+
key, value = line.split('=', 1)
|
|
66
|
+
key = key.strip()
|
|
67
|
+
if key.startswith('export '):
|
|
68
|
+
key = key[7:].strip()
|
|
69
|
+
value = value.strip()
|
|
70
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in '"\'':
|
|
71
|
+
value = value[1:-1]
|
|
72
|
+
values[key] = value
|
|
73
|
+
return values
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class Secrets:
|
|
77
|
+
def __init__(self, project_root=None, user_home=None):
|
|
78
|
+
self.user_file = Path(user_home or config.home()) / '.env'
|
|
79
|
+
self.project_file = Path(project_root) / '.env' if project_root else None
|
|
80
|
+
self._user = parse_env_file(self.user_file)
|
|
81
|
+
self._project = parse_env_file(self.project_file) if self.project_file else {}
|
|
82
|
+
|
|
83
|
+
def lookup(self, key):
|
|
84
|
+
"""(value, source) for a plain key, or (None, None)."""
|
|
85
|
+
if os.environ.get(key):
|
|
86
|
+
return os.environ[key], 'environment'
|
|
87
|
+
if self._project.get(key):
|
|
88
|
+
return self._project[key], str(self.project_file)
|
|
89
|
+
if self._user.get(key):
|
|
90
|
+
return self._user[key], str(self.user_file)
|
|
91
|
+
return None, None
|
|
92
|
+
|
|
93
|
+
def value(self, key, default=None):
|
|
94
|
+
"""A plain setting such as MKIT_PLAY_REPORTS_BUCKET."""
|
|
95
|
+
for candidate in (key, key[5:] if key.startswith('MKIT_') else None):
|
|
96
|
+
if candidate:
|
|
97
|
+
found, _ = self.lookup(candidate)
|
|
98
|
+
if found:
|
|
99
|
+
return found
|
|
100
|
+
return default
|
|
101
|
+
|
|
102
|
+
def _raw(self, name):
|
|
103
|
+
for prefix in ('MKIT_', ''):
|
|
104
|
+
b64, source = self.lookup(f'{prefix}{name}_JSON_BASE64')
|
|
105
|
+
if b64:
|
|
106
|
+
return 'base64', b64, source
|
|
107
|
+
path, source = self.lookup(f'{prefix}{name}_FILE')
|
|
108
|
+
if path:
|
|
109
|
+
return 'file', path, source
|
|
110
|
+
return None, None, None
|
|
111
|
+
|
|
112
|
+
def source(self, name):
|
|
113
|
+
"""Where a credential would come from, for doctor. None if absent."""
|
|
114
|
+
_, _, source = self._raw(name)
|
|
115
|
+
return source
|
|
116
|
+
|
|
117
|
+
def has(self, name):
|
|
118
|
+
return self._raw(name)[0] is not None
|
|
119
|
+
|
|
120
|
+
def json(self, name, required=True):
|
|
121
|
+
"""The credential as a dict. Missing and `required` raises exit 3 with
|
|
122
|
+
a hint naming the exact key and file to put it in."""
|
|
123
|
+
form, raw, source = self._raw(name)
|
|
124
|
+
if form is None:
|
|
125
|
+
if not required:
|
|
126
|
+
return None
|
|
127
|
+
raise CredentialMissing(
|
|
128
|
+
f'{KNOWN.get(name, name)} is not set',
|
|
129
|
+
hint=(f'Add MKIT_{name}_JSON_BASE64=<base64 of the JSON> to '
|
|
130
|
+
f'{self.project_file or "<project>/.env"} (this app) or '
|
|
131
|
+
f'{self.user_file} (all apps). `mkit doctor` shows what is set.'),
|
|
132
|
+
code=f'MISSING_{name}')
|
|
133
|
+
register(raw)
|
|
134
|
+
try:
|
|
135
|
+
if form == 'base64':
|
|
136
|
+
text = base64.b64decode(''.join(raw.split()), validate=True).decode('utf-8')
|
|
137
|
+
else:
|
|
138
|
+
text = Path(raw).expanduser().read_text(encoding='utf-8-sig')
|
|
139
|
+
data = json.loads(text)
|
|
140
|
+
except (binascii.Error, UnicodeDecodeError, ValueError, OSError) as e:
|
|
141
|
+
raise CredentialMissing(
|
|
142
|
+
f'{name} from {source} could not be read as JSON ({type(e).__name__})',
|
|
143
|
+
hint='Re-encode it: PowerShell [Convert]::ToBase64String([IO.File]::'
|
|
144
|
+
'ReadAllBytes("key.json")), macOS/Linux base64 -w 0 key.json',
|
|
145
|
+
code=f'BAD_{name}') from e
|
|
146
|
+
for field in ('private_key', 'private_key_id', 'bot_token'):
|
|
147
|
+
register(data.get(field))
|
|
148
|
+
return data
|
mkit/toolchain.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Finding the tools a build needs: Android SDK, adb, emulator, a JDK, Gradle.
|
|
2
|
+
|
|
3
|
+
Nothing here is per project except where a project may pin a value in
|
|
4
|
+
mkit.toml ([app].java_home) or the environment (MKIT_JAVA_HOME, JAVA_HOME,
|
|
5
|
+
ANDROID_HOME).
|
|
6
|
+
"""
|
|
7
|
+
import glob
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from .errors import MkitError
|
|
15
|
+
|
|
16
|
+
EXE = '.exe' if os.name == 'nt' else ''
|
|
17
|
+
|
|
18
|
+
# Gradle 8 runs on JDK 17-24. Android Studio's bundled jbr is tried last: recent
|
|
19
|
+
# Studio builds ship a JDK newer than Gradle 8 accepts.
|
|
20
|
+
JDK_MIN, JDK_MAX = 17, 24
|
|
21
|
+
JDK_CANDIDATES = (
|
|
22
|
+
r'C:\Program Files\Microsoft\jdk-17*',
|
|
23
|
+
r'C:\Program Files\Microsoft\jdk-21*',
|
|
24
|
+
r'C:\Program Files\Eclipse Adoptium\jdk-17*',
|
|
25
|
+
r'C:\Program Files\Eclipse Adoptium\jdk-21*',
|
|
26
|
+
r'C:\Program Files\Java\jdk-17*',
|
|
27
|
+
r'C:\Program Files\Java\jdk-21*',
|
|
28
|
+
os.path.join(str(Path.home()), '.jdks', 'jbr-17*'),
|
|
29
|
+
os.path.join(str(Path.home()), '.jdks', '*-17*'),
|
|
30
|
+
os.path.join(str(Path.home()), '.jdks', '*-21*'),
|
|
31
|
+
'/usr/lib/jvm/*-17*',
|
|
32
|
+
'/usr/lib/jvm/*-21*',
|
|
33
|
+
'/Library/Java/JavaVirtualMachines/*17*/Contents/Home',
|
|
34
|
+
'/Library/Java/JavaVirtualMachines/*21*/Contents/Home',
|
|
35
|
+
r'C:\Program Files\Android\Android Studio\jbr',
|
|
36
|
+
os.path.join(os.environ.get('LOCALAPPDATA', ''), 'Programs', 'Android Studio', 'jbr'),
|
|
37
|
+
'/Applications/Android Studio.app/Contents/jbr/Contents/Home',
|
|
38
|
+
'/opt/android-studio/jbr',
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# Where Android Studio lives, newest first within a pattern. The Toolbox keeps
|
|
43
|
+
# every build in its own folder, so those are globbed and sorted.
|
|
44
|
+
STUDIO_CANDIDATES = (
|
|
45
|
+
os.path.join(os.environ.get('LOCALAPPDATA', ''), 'Programs', 'Android Studio*', 'bin', 'studio64.exe'),
|
|
46
|
+
r'C:\Program Files\Android\Android Studio*\bin\studio64.exe',
|
|
47
|
+
os.path.join(os.environ.get('LOCALAPPDATA', ''), 'JetBrains', 'Toolbox', 'apps', '*ndroid*',
|
|
48
|
+
'**', 'bin', 'studio64.exe'),
|
|
49
|
+
os.path.join(os.environ.get('LOCALAPPDATA', ''), 'Programs', 'Android Studio*', 'bin', 'studio.exe'),
|
|
50
|
+
'/Applications/Android Studio*.app/Contents/MacOS/studio',
|
|
51
|
+
os.path.join(str(Path.home()), 'Applications', 'Android Studio*.app', 'Contents', 'MacOS', 'studio'),
|
|
52
|
+
'/opt/android-studio*/bin/studio.sh',
|
|
53
|
+
'/usr/local/android-studio*/bin/studio.sh',
|
|
54
|
+
os.path.join(str(Path.home()), '.local', 'share', 'JetBrains', 'Toolbox', 'apps', '*ndroid*',
|
|
55
|
+
'**', 'bin', 'studio.sh'),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
STUDIO_EXES = ('studio64.exe', 'studio.exe') if os.name == 'nt' else ('studio.sh', 'studio')
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _studio_inside(path):
|
|
62
|
+
"""The launcher inside a folder somebody pinned: an install dir, a macOS
|
|
63
|
+
bundle, or the bin/ folder itself."""
|
|
64
|
+
path = Path(path)
|
|
65
|
+
if path.is_file():
|
|
66
|
+
return str(path)
|
|
67
|
+
for relative in ('Contents/MacOS/studio', *(f'bin/{e}' for e in STUDIO_EXES), *STUDIO_EXES):
|
|
68
|
+
found = path / relative
|
|
69
|
+
if found.is_file():
|
|
70
|
+
return str(found)
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def studio(project=None):
|
|
75
|
+
"""Android Studio's launcher: [app].studio or MKIT_STUDIO taken as given,
|
|
76
|
+
then the usual install locations, then PATH."""
|
|
77
|
+
pinned = (project.get('app.studio') if project else None) or os.environ.get('MKIT_STUDIO')
|
|
78
|
+
if pinned:
|
|
79
|
+
found = _studio_inside(pinned)
|
|
80
|
+
if not found:
|
|
81
|
+
raise MkitError(f'studio {pinned} is not an Android Studio launcher', code='TOOL_MISSING',
|
|
82
|
+
hint='Point it at studio64.exe / studio.sh, or at the install folder.')
|
|
83
|
+
return found
|
|
84
|
+
for pattern in STUDIO_CANDIDATES:
|
|
85
|
+
for candidate in sorted(glob.glob(pattern, recursive=True), reverse=True):
|
|
86
|
+
if Path(candidate).is_file():
|
|
87
|
+
return candidate
|
|
88
|
+
for name in STUDIO_EXES:
|
|
89
|
+
found = shutil.which(name)
|
|
90
|
+
if found:
|
|
91
|
+
return found
|
|
92
|
+
raise MkitError('Android Studio not found', code='TOOL_MISSING',
|
|
93
|
+
hint='Install it, or set studio under [app] in mkit.toml (or MKIT_STUDIO) '
|
|
94
|
+
'to the studio64.exe / studio.sh path.')
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def tool(name):
|
|
98
|
+
"""A PATH tool's full path. shutil.which follows PATHEXT, so firebase.cmd
|
|
99
|
+
and flutter.bat launch without shell=True."""
|
|
100
|
+
path = shutil.which(name)
|
|
101
|
+
if not path:
|
|
102
|
+
raise MkitError(f'{name} not found on PATH', code='TOOL_MISSING',
|
|
103
|
+
hint=f'Install {name} or add it to PATH, then re-run `mkit doctor`.')
|
|
104
|
+
return path
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _local_properties(root):
|
|
108
|
+
for props in (root / 'local.properties', root / 'android' / 'local.properties'):
|
|
109
|
+
if props.is_file():
|
|
110
|
+
for line in props.read_text(encoding='utf-8', errors='replace').splitlines():
|
|
111
|
+
m = re.match(r'\s*sdk\.dir\s*=\s*(.+?)\s*$', line)
|
|
112
|
+
if m:
|
|
113
|
+
return Path(m.group(1).replace('\\\\', '\\').replace('\\:', ':'))
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def sdk_dir(root=None):
|
|
118
|
+
if root is not None:
|
|
119
|
+
found = _local_properties(Path(root))
|
|
120
|
+
if found and found.is_dir():
|
|
121
|
+
return found
|
|
122
|
+
for key in ('ANDROID_HOME', 'ANDROID_SDK_ROOT'):
|
|
123
|
+
if os.environ.get(key) and Path(os.environ[key]).is_dir():
|
|
124
|
+
return Path(os.environ[key])
|
|
125
|
+
guess = (Path(os.environ.get('LOCALAPPDATA', '')) / 'Android' / 'Sdk' if os.name == 'nt'
|
|
126
|
+
else Path.home() / ('Library/Android/sdk' if os.uname().sysname == 'Darwin' else 'Android/Sdk'))
|
|
127
|
+
return guess if guess.is_dir() else None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def adb(root=None):
|
|
131
|
+
"""The SDK's adb before any on PATH: other tools (scrcpy, phone suites)
|
|
132
|
+
ship their own, and two adb versions keep killing each other's server."""
|
|
133
|
+
sdk = sdk_dir(root)
|
|
134
|
+
if sdk:
|
|
135
|
+
exe = sdk / 'platform-tools' / f'adb{EXE}'
|
|
136
|
+
if exe.is_file():
|
|
137
|
+
return str(exe)
|
|
138
|
+
found = shutil.which('adb')
|
|
139
|
+
if found:
|
|
140
|
+
return found
|
|
141
|
+
raise MkitError('adb not found', code='TOOL_MISSING',
|
|
142
|
+
hint='Install the Android SDK platform-tools, or set ANDROID_HOME.')
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def emulator(root=None):
|
|
146
|
+
sdk = sdk_dir(root)
|
|
147
|
+
exe = sdk / 'emulator' / f'emulator{EXE}' if sdk else None
|
|
148
|
+
if not exe or not exe.is_file():
|
|
149
|
+
raise MkitError('Android emulator not found', code='TOOL_MISSING',
|
|
150
|
+
hint='Install it from the SDK Manager, or set sdk.dir in local.properties.')
|
|
151
|
+
return str(exe)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def java_major(home):
|
|
155
|
+
java = Path(home) / 'bin' / f'java{EXE}'
|
|
156
|
+
if not java.is_file():
|
|
157
|
+
return 0
|
|
158
|
+
try:
|
|
159
|
+
out = subprocess.run([str(java), '-version'], capture_output=True, text=True, timeout=30).stderr
|
|
160
|
+
except Exception:
|
|
161
|
+
return 0
|
|
162
|
+
m = re.search(r'version "(\d+)', out)
|
|
163
|
+
return int(m.group(1)) if m else 0
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def find_jdk(project=None):
|
|
167
|
+
"""A JDK for Gradle (JAVA_HOME) and the Android SDK (ANDROID_HOME), both
|
|
168
|
+
exported for the processes we spawn. See _find_jdk for the JDK order."""
|
|
169
|
+
jdk = _find_jdk(project)
|
|
170
|
+
if not os.environ.get('ANDROID_HOME') and not os.environ.get('ANDROID_SDK_ROOT'):
|
|
171
|
+
# A checkout without local.properties (it is gitignored) still builds.
|
|
172
|
+
sdk = sdk_dir(project.root if project else None)
|
|
173
|
+
if sdk:
|
|
174
|
+
os.environ['ANDROID_HOME'] = str(sdk)
|
|
175
|
+
return jdk
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _find_jdk(project=None):
|
|
179
|
+
"""A JDK Gradle accepts, exported as JAVA_HOME for the processes we spawn.
|
|
180
|
+
|
|
181
|
+
Order: [app].java_home / MKIT_JAVA_HOME (taken as given), JAVA_HOME if it is
|
|
182
|
+
in range, then the first candidate in range. Deliberately not left to
|
|
183
|
+
`flutter build`, which prefers Android Studio's bundled JDK -- the one that
|
|
184
|
+
is often too new.
|
|
185
|
+
"""
|
|
186
|
+
pinned = project.get('app.java_home') if project else os.environ.get('MKIT_APP_JAVA_HOME')
|
|
187
|
+
pinned = pinned or os.environ.get('MKIT_JAVA_HOME')
|
|
188
|
+
if pinned:
|
|
189
|
+
if not java_major(pinned):
|
|
190
|
+
raise MkitError(f'java_home {pinned} has no bin/java', code='TOOL_MISSING')
|
|
191
|
+
os.environ['JAVA_HOME'] = str(pinned)
|
|
192
|
+
return str(pinned)
|
|
193
|
+
current = os.environ.get('JAVA_HOME', '').rstrip('\\/')
|
|
194
|
+
if current and JDK_MIN <= java_major(current) <= JDK_MAX:
|
|
195
|
+
return current
|
|
196
|
+
for pattern in JDK_CANDIDATES:
|
|
197
|
+
for candidate in sorted(glob.glob(pattern), reverse=True):
|
|
198
|
+
if JDK_MIN <= java_major(candidate) <= JDK_MAX:
|
|
199
|
+
os.environ['JAVA_HOME'] = candidate
|
|
200
|
+
return candidate
|
|
201
|
+
raise MkitError(f'no JDK {JDK_MIN}-{JDK_MAX} found', code='TOOL_MISSING',
|
|
202
|
+
hint='Install JDK 17 or 21, or set java_home under [app] in mkit.toml.')
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def gradlew(android_dir):
|
|
206
|
+
path = Path(android_dir) / ('gradlew.bat' if os.name == 'nt' else 'gradlew')
|
|
207
|
+
if not path.is_file():
|
|
208
|
+
raise MkitError(f'no Gradle wrapper at {path}', code='TOOL_MISSING',
|
|
209
|
+
hint='Commit the Gradle wrapper (gradlew, gradlew.bat, gradle/wrapper).')
|
|
210
|
+
return str(path)
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: mkit-cli
|
|
3
|
+
Version: 0.3.1
|
|
4
|
+
Summary: Release Android apps to Google Play from the command line: build, upload, listing, screenshots, data safety, in-app products, vitals and Firebase, in one tool built for humans and AI agents.
|
|
5
|
+
Project-URL: Homepage, https://github.com/almasumdev/mkit-cli
|
|
6
|
+
Project-URL: Documentation, https://github.com/almasumdev/mkit-cli/blob/main/docs/commands.md
|
|
7
|
+
Project-URL: Repository, https://github.com/almasumdev/mkit-cli
|
|
8
|
+
Project-URL: Issues, https://github.com/almasumdev/mkit-cli/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/almasumdev/mkit-cli/blob/main/CHANGELOG.md
|
|
10
|
+
Author: Nurullah Al-Masum
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: android,aso,firebase,flutter,google-play,release
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Classifier: Environment :: Console
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
24
|
+
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
25
|
+
Classifier: Topic :: System :: Software Distribution
|
|
26
|
+
Requires-Python: >=3.11
|
|
27
|
+
Requires-Dist: google-api-python-client>=2.150
|
|
28
|
+
Requires-Dist: google-auth>=2.35
|
|
29
|
+
Requires-Dist: pillow>=11
|
|
30
|
+
Requires-Dist: requests>=2.32
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# mkit
|
|
34
|
+
|
|
35
|
+
[](https://github.com/almasumdev/mkit-cli/actions/workflows/tests.yml)
|
|
36
|
+
[](https://github.com/almasumdev/mkit-cli/blob/main/LICENSE)
|
|
37
|
+
|
|
38
|
+
One command-line tool for everything around an Android app that is not writing
|
|
39
|
+
it: build and install on a device, release to Google Play, keep the store
|
|
40
|
+
listing, screenshots, data safety and in-app products in your repo, read
|
|
41
|
+
installs, vitals, crashes and reviews, check search rank, and drive the
|
|
42
|
+
Firebase side (Firestore rules, Remote Config, an in-app update document).
|
|
43
|
+
|
|
44
|
+
Flutter and native Kotlin apps, including apps with a Wear OS module.
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
mkit deploy # bump, build, upload to Play, publish, notify, push
|
|
48
|
+
mkit run -p # build and install on the phone
|
|
49
|
+
mkit status # what every track holds right now
|
|
50
|
+
mkit crashes # what is breaking in production
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
A release is one command. `mkit deploy` bumps the version, commits it, builds
|
|
54
|
+
the bundle, uploads it with its mapping file and release notes, stages it on a
|
|
55
|
+
track, writes the update document, sends a message, and pushes — and if
|
|
56
|
+
anything fails before Play accepts the build, the version bump undoes itself so
|
|
57
|
+
the repo never claims a release that did not happen.
|
|
58
|
+
|
|
59
|
+
## Why it exists
|
|
60
|
+
|
|
61
|
+
Shipping an Android app means a dozen small jobs that live in different places:
|
|
62
|
+
a Gradle command, the Play Console, the Firebase console, a screenshot folder
|
|
63
|
+
somebody renamed, a changelog nobody updated. Each is easy; together they are a
|
|
64
|
+
release day. mkit puts them behind one tool, with the app's settings in one
|
|
65
|
+
file, so the same release happens the same way every time — and so an AI agent
|
|
66
|
+
can run the safe parts without being handed your Play Console password.
|
|
67
|
+
|
|
68
|
+
It was built to ship a small studio's apps and has been doing that for a while.
|
|
69
|
+
It is opinionated where opinions save time and configurable where apps really
|
|
70
|
+
differ.
|
|
71
|
+
|
|
72
|
+
## Install
|
|
73
|
+
|
|
74
|
+
Requires Python 3.11 or newer. [uv](https://docs.astral.sh/uv/) is the easiest
|
|
75
|
+
way in, and installs mkit as a command of its own rather than into a project:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
uv tool install mkit-cli
|
|
79
|
+
mkit --version
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
With pipx: `pipx install mkit-cli`. With pip: `pip install mkit-cli`.
|
|
83
|
+
|
|
84
|
+
The latest from `main`, between releases:
|
|
85
|
+
`uv tool install git+https://github.com/almasumdev/mkit-cli`
|
|
86
|
+
|
|
87
|
+
`mkit self status` says whether a newer release is out, and `mkit self update`
|
|
88
|
+
gives the upgrade command for whichever of these installed it.
|
|
89
|
+
|
|
90
|
+
## Getting started
|
|
91
|
+
|
|
92
|
+
In an app's folder:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
mkit init # writes mkit.toml, asking what it cannot work out
|
|
96
|
+
mkit doctor # what is configured, what is missing, and what to add
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
`mkit.toml` is the whole of an app's configuration:
|
|
100
|
+
|
|
101
|
+
```toml
|
|
102
|
+
[app]
|
|
103
|
+
name = "My App"
|
|
104
|
+
kind = "flutter" # flutter | android | android-wear
|
|
105
|
+
package = "com.example.app"
|
|
106
|
+
|
|
107
|
+
[play] # switches Play features on
|
|
108
|
+
|
|
109
|
+
[store] # store listing, art and data safety in the repo
|
|
110
|
+
listing_dir = "store/listing"
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Credentials never live in that file. A Google Play service account goes in
|
|
114
|
+
`.env` (per app) or `~/.mkit/.env` (shared); `mkit doctor` names the exact key
|
|
115
|
+
when one is missing, and never prints a secret. Every setting is documented in
|
|
116
|
+
[docs/config.md](https://github.com/almasumdev/mkit-cli/blob/main/docs/config.md).
|
|
117
|
+
|
|
118
|
+
## What it does
|
|
119
|
+
|
|
120
|
+
| | |
|
|
121
|
+
|---|---|
|
|
122
|
+
| **Develop** | `run`, `devices`, `logs`, `emu`, `studio`, `gradle`, `version`, `bump` |
|
|
123
|
+
| **Release** | `deploy`, `status`, `promote`, `rollout`, `share` |
|
|
124
|
+
| **Store** | `listing`, `whatsnew`, `datasafety`, `testers`, `art` |
|
|
125
|
+
| **Money** | `products`, `prices` |
|
|
126
|
+
| **Insight** | `stats`, `vitals`, `crashes`, `reviews`, `aso` |
|
|
127
|
+
| **Firebase** | `rules`, `config`, `remoteconfig`, `firebase` |
|
|
128
|
+
|
|
129
|
+
Every command, flag and example: [docs/commands.md](https://github.com/almasumdev/mkit-cli/blob/main/docs/commands.md),
|
|
130
|
+
generated from the code so it cannot describe a flag that does not exist.
|
|
131
|
+
|
|
132
|
+
Commands are classed by what they can change — `read`, `plan`, `write`,
|
|
133
|
+
`publish` — and anything in the `publish` class refuses to run without an
|
|
134
|
+
explicit confirmation naming the package.
|
|
135
|
+
|
|
136
|
+
Some things have no API and must be done in the Play Console. `mkit manual`
|
|
137
|
+
lists them, so you know what is left rather than discovering it at release
|
|
138
|
+
time.
|
|
139
|
+
|
|
140
|
+
## Working with AI agents
|
|
141
|
+
|
|
142
|
+
mkit answers in JSON whenever it is not attached to a terminal, with one
|
|
143
|
+
envelope on stdout and a documented exit code per outcome, so an agent can
|
|
144
|
+
branch on a result instead of reading prose. [AGENTS.md](https://github.com/almasumdev/mkit-cli/blob/main/AGENTS.md) is the
|
|
145
|
+
contract: which commands are safe to run unasked, which need a human to say so,
|
|
146
|
+
and what an agent must never do.
|
|
147
|
+
|
|
148
|
+
## Documentation
|
|
149
|
+
|
|
150
|
+
| | |
|
|
151
|
+
|---|---|
|
|
152
|
+
| [docs/commands.md](https://github.com/almasumdev/mkit-cli/blob/main/docs/commands.md) | every command and flag |
|
|
153
|
+
| [docs/config.md](https://github.com/almasumdev/mkit-cli/blob/main/docs/config.md) | `mkit.toml`, the store and keyword files, credentials |
|
|
154
|
+
| [docs/store.md](https://github.com/almasumdev/mkit-cli/blob/main/docs/store.md) | the `store/` folder: what lives where, in every language |
|
|
155
|
+
| [docs/store-art.md](https://github.com/almasumdev/mkit-cli/blob/main/docs/store-art.md) | screenshots and the feature graphic, built from real captures |
|
|
156
|
+
| [docs/in-app-update.md](https://github.com/almasumdev/mkit-cli/blob/main/docs/in-app-update.md) | how an app should check for updates: Play first, your policy second |
|
|
157
|
+
| [docs/news-topics.md](https://github.com/almasumdev/mkit-cli/blob/main/docs/news-topics.md) | news and offer notifications over FCM topics |
|
|
158
|
+
| [AGENTS.md](https://github.com/almasumdev/mkit-cli/blob/main/AGENTS.md) | the agent contract |
|
|
159
|
+
| [CONTRIBUTING.md](https://github.com/almasumdev/mkit-cli/blob/main/CONTRIBUTING.md) | changing mkit, tests, versioning |
|
|
160
|
+
| [SECURITY.md](https://github.com/almasumdev/mkit-cli/blob/main/SECURITY.md) | what counts as a security bug here, and how to report it |
|
|
161
|
+
|
|
162
|
+
## Status and scope
|
|
163
|
+
|
|
164
|
+
Developed on Windows against real apps on Google Play; the code paths for
|
|
165
|
+
macOS and Linux exist but are less travelled, and there is no CI yet. The test
|
|
166
|
+
suite runs against a fake Play service — nothing in the tests touches a real
|
|
167
|
+
account.
|
|
168
|
+
|
|
169
|
+
It does not: create an app in the Play Console, answer content rating or target
|
|
170
|
+
audience questionnaires, manage tester email lists (the API only takes Google
|
|
171
|
+
Groups), or publish for you when managed publishing is on. `mkit manual` is the
|
|
172
|
+
full list.
|
|
173
|
+
|
|
174
|
+
Issues and pull requests are welcome. If you use it for your own apps, I would
|
|
175
|
+
like to hear what broke.
|
|
176
|
+
|
|
177
|
+
## Licence
|
|
178
|
+
|
|
179
|
+
MIT. See [LICENSE](https://github.com/almasumdev/mkit-cli/blob/main/LICENSE).
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
mkit/__init__.py,sha256=iUQkWEnJBDh_PVSwZzs2sDbiKY0IYTDKqeV75nFY0-4,227
|
|
2
|
+
mkit/art.py,sha256=2Nkgxzwql02wTQfSeVmgfmufryjjx_Q55Tulh0C-T70,59074
|
|
3
|
+
mkit/aso.py,sha256=XMWDkzAgt40Vg9INSE6War9AEwflNGuznGpCc-H_MzE,15291
|
|
4
|
+
mkit/cli.py,sha256=exINjh598DDfRHhI9VuUCTyaE0fLhdPANBxMpD2sK7M,7246
|
|
5
|
+
mkit/config.py,sha256=mGTMeC90WEVv_LZh8xby9HnniXA2eb7laLSRrv6sfVY,5252
|
|
6
|
+
mkit/datasafety.py,sha256=afHlHx90nV4niOM1qBoZOVK0TR0dH9iPCtFOifAv_18,13066
|
|
7
|
+
mkit/device.py,sha256=8Ooe_MjELX9E4rw4kmBx_aDyE7BFTZj5eRLGNZty9IY,10643
|
|
8
|
+
mkit/docgen.py,sha256=xAZaulKML9j-1xJxuj0EAcWh4G1FcyPgHLoVxeO2Xog,4518
|
|
9
|
+
mkit/errors.py,sha256=1x067e8l288UMeIpG3s6SdxY03pC-7OJLiCpecapnyk,1889
|
|
10
|
+
mkit/git.py,sha256=ofCbNL8Knyz3Sf2msXEQsVSiq-syXVV0zHjgExpnkkk,3705
|
|
11
|
+
mkit/hooks.py,sha256=csh1TPRvL3v2lhNTHUGPj89cNHWHKnj-JxI61m_Gws8,1083
|
|
12
|
+
mkit/kinds.py,sha256=PuQeu4O7mzVV_9VCksY0V3D5tbXDkTfMF43ylsGkhYc,13277
|
|
13
|
+
mkit/notify.py,sha256=fM653ABGjapZd8vdr2IcixAOltPCwpgRxXbdbPPzKEI,1676
|
|
14
|
+
mkit/output.py,sha256=HQLDywJdGfT2TMwiEm46q4qeiCm9UhbnbZVjtQVHGO4,13782
|
|
15
|
+
mkit/proc.py,sha256=-dD8_KKnq4yFaVDr7Nzy5P0EZscaGykt0-Hr-W1OlT4,6179
|
|
16
|
+
mkit/registry.py,sha256=rAL7MwvQPSEhtdUchiEP7W-3druQR552Q1E-GqRD9K4,3610
|
|
17
|
+
mkit/safety.py,sha256=jwShFLnxVHxKyRcAqfSW-gOLMuH4Z3mtOmlk22adQLE,2721
|
|
18
|
+
mkit/secrets.py,sha256=RpTLMzY8096Vvx1Uikp_vUu-9gu7Jfot9W_J6VBz2Ek,5693
|
|
19
|
+
mkit/toolchain.py,sha256=UlPMQ4P0guEGBroG-qAJw8yBeOS6vugGHcvl4OV_WwU,8770
|
|
20
|
+
mkit/cloud/__init__.py,sha256=lcRs1Xr8vS4gTfG1gcHpqiNJDs2wkivGkWZ6cp6LY9s,85
|
|
21
|
+
mkit/cloud/auth.py,sha256=6WPNauTQ9fPiX9QFmhaaVhRnIjYqiN-TzQnIPneB8AQ,5364
|
|
22
|
+
mkit/cloud/fbproject.py,sha256=pGQdS6Q4ycnq0XKmYAo31oz0u-NQno0crJPHmqhE5dM,7731
|
|
23
|
+
mkit/cloud/firebase.py,sha256=KP6IIzfmnnojBWWtNrW_UHNtpUUpk90lh5Pzns8SBos,7453
|
|
24
|
+
mkit/cloud/insight.py,sha256=JtqYB3LNsEPfWJOZTUwuMyDzWFIKkpujpWtAJs3QFME,12947
|
|
25
|
+
mkit/cloud/money.py,sha256=YY_6uGrmGvkOOeCrlblJXlbX128j5uD6op1lHC52Mtk,32107
|
|
26
|
+
mkit/cloud/play.py,sha256=PVbctsOnVzpKdtu8FiL3X1FzrXG0-q8_qqMbsubTvlo,23263
|
|
27
|
+
mkit/cloud/store.py,sha256=g-7Dgmd1B56RLWT5kppdWNXuQLffz0VqjrG0k3niQ-s,17639
|
|
28
|
+
mkit/commands/__init__.py,sha256=99Fc-7Nz1XPlqr55vvnrTg3M3YmxKmCkLuqLjH4Lep8,172
|
|
29
|
+
mkit/commands/art.py,sha256=9hf8sHVRBU8VUwu_9eKT6iWjHZ-QsI9ogTORHTkWIB0,5360
|
|
30
|
+
mkit/commands/aso.py,sha256=pJs8CPQNRQqvkzDcNtv_v8ILS3sZ88QSlo9zbv0_h-Q,7090
|
|
31
|
+
mkit/commands/dev.py,sha256=aH-BtTGQA0-QJndRjejfwtdX8EaingcTCHwIrzb46XI,13042
|
|
32
|
+
mkit/commands/doctor.py,sha256=PU6YRZ_ZC-WU5jJxyz6Ue1NoZW2qkJUsAu2zHvYeXBA,8103
|
|
33
|
+
mkit/commands/firebase_cmds.py,sha256=Q7MriBDRI4A-mtRgF333uLuO_z-n36_bYhpka3V0IPM,9633
|
|
34
|
+
mkit/commands/init.py,sha256=e-p2tQIX9XGDStivRR2UNDp_Nlh-Vt_5yuHcE2TO_LA,8227
|
|
35
|
+
mkit/commands/insight.py,sha256=53FFD6cua2O9OzR3Yud1pptStGSdJccjf5QQGyD9jns,5523
|
|
36
|
+
mkit/commands/meta.py,sha256=6mtY6AKLj0eQg2eU95cJFXUwFuGcvSOWIFc_Plmspug,5010
|
|
37
|
+
mkit/commands/money.py,sha256=K5-ZT2I8Yc-MDi21QPev0ZQBrS0s9j5xhKslTFOfyuY,6325
|
|
38
|
+
mkit/commands/release.py,sha256=XrHv67WWXLYYYqL0k3nT9_Zi688lC3iG9C5SvoyaT88,20888
|
|
39
|
+
mkit/commands/selfcmd.py,sha256=hIjJNJIFfadc4yoyeQurBuGKvbsSBqdDOujU75gGQnE,7463
|
|
40
|
+
mkit/commands/store.py,sha256=jGrm8Ty3LFF0SDFxCUiz4YzKKTrs2vJ5X4QQjT70t24,13070
|
|
41
|
+
mkit/data/datasafety-questions.csv,sha256=FrvFSRxZW99rOqj5JEXVtRkm0mp2quKnCP0SqUWI9yw,178026
|
|
42
|
+
mkit/data/art/art.toml,sha256=nJzDLlj-mKsEbE4uwI_4r6dhG6Tq17DT4wLy1tV-XvA,4885
|
|
43
|
+
mkit/data/art/en.json,sha256=xuEPUT_MzCdP2UOKHShTiCTDzjBgof7fzsOJ1SDi6pI,1115
|
|
44
|
+
mkit_cli-0.3.1.dist-info/METADATA,sha256=1b-FmEsLwE1QXX5_nE4d-W9hJd6Io44asiDaH1dG6eo,8349
|
|
45
|
+
mkit_cli-0.3.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
46
|
+
mkit_cli-0.3.1.dist-info/entry_points.txt,sha256=niTcFqsV7EFp871rsGdY6zWQcHxkzaox5Mj7Fa1KSKs,39
|
|
47
|
+
mkit_cli-0.3.1.dist-info/licenses/LICENSE,sha256=3DBcB7wESKA4pgutHnCDuOq55myaF_ftXfhLcE2muC8,1074
|
|
48
|
+
mkit_cli-0.3.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nurullah Al-Masum
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|