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/init.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""`mkit init`: link an app folder to the engine.
|
|
2
|
+
|
|
3
|
+
Writes three small files and nothing else:
|
|
4
|
+
|
|
5
|
+
mkit.toml settings, detected from the project, committed
|
|
6
|
+
mkit.ps1 PowerShell launcher (./mkit): one Ctrl+C stops it
|
|
7
|
+
mkit.bat cmd launcher: runs the installed engine on this folder
|
|
8
|
+
mkit.sh macOS/Linux launcher, same thing
|
|
9
|
+
|
|
10
|
+
and makes sure `.env` (this app's secrets) is gitignored. Existing files are
|
|
11
|
+
left alone unless --force is given.
|
|
12
|
+
"""
|
|
13
|
+
import re
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from .. import __version__
|
|
17
|
+
from ..errors import UsageError
|
|
18
|
+
from ..registry import Arg, command
|
|
19
|
+
|
|
20
|
+
# No < > | & here: the text is echoed by cmd.exe, which reads those as redirects.
|
|
21
|
+
# The engine that writes a new mkit.toml is the oldest one known to read it.
|
|
22
|
+
REQUIRES = '.'.join(__version__.split('.')[:2])
|
|
23
|
+
|
|
24
|
+
INSTALL_HINT = 'Install it with: uv tool install mkit-cli (or pipx install mkit-cli)'
|
|
25
|
+
|
|
26
|
+
# mkit.exe by full name: plain `mkit` from inside mkit.bat can resolve to this
|
|
27
|
+
# very file (cmd searches the current folder first) and call itself forever.
|
|
28
|
+
BAT = f'''@echo off
|
|
29
|
+
where mkit.exe >nul 2>nul || (echo mkit is not installed. {INSTALL_HINT} 1>&2 & exit /b 9009)
|
|
30
|
+
mkit.exe --project "%~dp0." %*
|
|
31
|
+
'''
|
|
32
|
+
|
|
33
|
+
# PowerShell runs .\mkit.ps1 ahead of .\mkit.bat for `./mkit`, and a script,
|
|
34
|
+
# unlike a batch file, stops on one Ctrl+C: cmd's "Terminate batch job (Y/N)?"
|
|
35
|
+
# only exists for .bat/.cmd.
|
|
36
|
+
PS1 = f'''# mkit launcher for PowerShell: ./mkit run
|
|
37
|
+
if (-not (Get-Command mkit.exe -ErrorAction SilentlyContinue)) {{
|
|
38
|
+
Write-Error 'mkit is not installed. {INSTALL_HINT}'
|
|
39
|
+
exit 9009
|
|
40
|
+
}}
|
|
41
|
+
& mkit.exe --project $PSScriptRoot @args
|
|
42
|
+
exit $LASTEXITCODE
|
|
43
|
+
'''
|
|
44
|
+
|
|
45
|
+
SH = f'''#!/usr/bin/env sh
|
|
46
|
+
set -eu
|
|
47
|
+
command -v mkit >/dev/null 2>&1 || {{ echo "mkit is not installed. {INSTALL_HINT}" >&2; exit 127; }}
|
|
48
|
+
exec mkit --project "$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" "$@"
|
|
49
|
+
'''
|
|
50
|
+
|
|
51
|
+
_APP_ID = re.compile(r'''applicationId\s*=?\s*["']([\w.]+)["']''')
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _read(path):
|
|
55
|
+
try:
|
|
56
|
+
return path.read_text(encoding='utf-8', errors='replace')
|
|
57
|
+
except OSError:
|
|
58
|
+
return ''
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def detect(root):
|
|
62
|
+
"""(kind, package, name) guessed from the files present; any may be None."""
|
|
63
|
+
root = Path(root)
|
|
64
|
+
if (root / 'pubspec.yaml').is_file():
|
|
65
|
+
kind = 'flutter'
|
|
66
|
+
gradle_dir = root / 'android' / 'app'
|
|
67
|
+
m = re.search(r'^name:\s*(\S+)', _read(root / 'pubspec.yaml'), re.M)
|
|
68
|
+
name = m.group(1).replace('_', ' ').title().replace(' ', '') if m else None
|
|
69
|
+
else:
|
|
70
|
+
gradle_dir = root / 'app'
|
|
71
|
+
wear = (root / 'wear').is_dir() and any((root / 'wear' / f).is_file()
|
|
72
|
+
for f in ('build.gradle.kts', 'build.gradle'))
|
|
73
|
+
kind = 'android-wear' if wear else 'android'
|
|
74
|
+
name = None
|
|
75
|
+
for settings in ('settings.gradle.kts', 'settings.gradle'):
|
|
76
|
+
m = re.search(r'rootProject\.name\s*=\s*["\']([^"\']+)', _read(root / settings))
|
|
77
|
+
if m:
|
|
78
|
+
name = m.group(1)
|
|
79
|
+
break
|
|
80
|
+
package = None
|
|
81
|
+
for f in ('build.gradle.kts', 'build.gradle'):
|
|
82
|
+
m = _APP_ID.search(_read(gradle_dir / f))
|
|
83
|
+
if m:
|
|
84
|
+
package = m.group(1)
|
|
85
|
+
break
|
|
86
|
+
if not (gradle_dir / 'build.gradle.kts').is_file() and not (gradle_dir / 'build.gradle').is_file():
|
|
87
|
+
kind = kind if kind == 'flutter' else None
|
|
88
|
+
return kind, package, name or root.name
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def render_toml(name, kind, package):
|
|
92
|
+
return f'''# mkit settings for {name}. Secrets never go here: they live in .env
|
|
93
|
+
# (this app) and ~/.mkit/.env (all apps). `mkit doctor` checks everything.
|
|
94
|
+
requires = ">={REQUIRES}"
|
|
95
|
+
|
|
96
|
+
[app]
|
|
97
|
+
name = "{name}"
|
|
98
|
+
kind = "{kind}" # flutter | android | android-wear
|
|
99
|
+
package = "{package}"
|
|
100
|
+
activity = ".MainActivity"
|
|
101
|
+
|
|
102
|
+
# A table switches a feature on. Leave one out and every step that uses it is
|
|
103
|
+
# skipped quietly, because this app does not use it.
|
|
104
|
+
|
|
105
|
+
# [play]
|
|
106
|
+
# internal_testing_url = ""
|
|
107
|
+
|
|
108
|
+
# [store]
|
|
109
|
+
# listing_dir = "store/listing" # everything the store shows lives under store/
|
|
110
|
+
# art_dir = "store"
|
|
111
|
+
# icon = "android/app/src/main/ic_launcher-playstore.png"
|
|
112
|
+
# whatsnew_dir = "distribution/whatsnew"
|
|
113
|
+
|
|
114
|
+
# [firebase]
|
|
115
|
+
# rules = true
|
|
116
|
+
# update_doc = "config/app"
|
|
117
|
+
|
|
118
|
+
# [notify.telegram]
|
|
119
|
+
'''
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
ENV_TEMPLATE = """\
|
|
123
|
+
# Secrets for this app. Gitignored, and never committed - `mkit doctor` names
|
|
124
|
+
# the key it wants when one is missing, and prints none of them.
|
|
125
|
+
#
|
|
126
|
+
# Each is the service account JSON, base64 encoded, on one line:
|
|
127
|
+
# Windows : [Convert]::ToBase64String([IO.File]::ReadAllBytes("key.json"))
|
|
128
|
+
# macOS : base64 -i key.json
|
|
129
|
+
# Linux : base64 -w0 key.json
|
|
130
|
+
#
|
|
131
|
+
# Anything every app shares goes in ~/.mkit/.env instead of here.
|
|
132
|
+
|
|
133
|
+
# Google Play, for releases, the listing, products, reviews and vitals.
|
|
134
|
+
# Play Console > Users and permissions, invite the service account.
|
|
135
|
+
# MKIT_PLAY_SERVICE_ACCOUNT_JSON_BASE64=
|
|
136
|
+
|
|
137
|
+
# Firebase, for Firestore rules, Remote Config and the update document.
|
|
138
|
+
# Project settings > Service accounts > Generate new private key.
|
|
139
|
+
# MKIT_FIREBASE_SERVICE_ACCOUNT_JSON_BASE64=
|
|
140
|
+
|
|
141
|
+
# A Telegram bot, if this app sends a message after a release.
|
|
142
|
+
# {"bot_token": "...", "chat_id": 123}, base64 encoded.
|
|
143
|
+
# MKIT_TELEGRAM_JSON_BASE64=
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _ensure_gitignored(root, entry='.env'):
|
|
148
|
+
gi = root / '.gitignore'
|
|
149
|
+
text = _read(gi)
|
|
150
|
+
lines = [l.strip() for l in text.splitlines()]
|
|
151
|
+
if entry in lines or f'/{entry}' in lines:
|
|
152
|
+
return False
|
|
153
|
+
with open(gi, 'a', encoding='utf-8', newline='\n') as f:
|
|
154
|
+
if text and not text.endswith('\n'):
|
|
155
|
+
f.write('\n')
|
|
156
|
+
f.write(f'\n# mkit: this app\'s secrets\n{entry}\n')
|
|
157
|
+
return True
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@command('init', 'Link this app folder to mkit (mkit.toml + launchers)', risk='write',
|
|
161
|
+
needs_project=False,
|
|
162
|
+
args=(Arg('path', 'app folder (default: current folder)', kind='positional', nargs='?'),
|
|
163
|
+
Arg('kind', 'override the detected kind', choices=('flutter', 'android', 'android-wear')),
|
|
164
|
+
Arg('package', 'override the detected package id'),
|
|
165
|
+
Arg('name', 'override the detected app name'),
|
|
166
|
+
Arg('force', 'overwrite existing mkit.toml and launchers', kind='flag')),
|
|
167
|
+
examples=('mkit init', 'mkit init H:/flutter/onGoing/listkin --name Listkin'))
|
|
168
|
+
def init(ctx, args):
|
|
169
|
+
root = Path(args.path or '.').resolve()
|
|
170
|
+
if not root.is_dir():
|
|
171
|
+
raise UsageError(f'{root} is not a folder')
|
|
172
|
+
kind, package, name = detect(root)
|
|
173
|
+
kind = args.kind or kind
|
|
174
|
+
package = args.package or package
|
|
175
|
+
name = args.name or name
|
|
176
|
+
if not kind:
|
|
177
|
+
raise UsageError(f'could not tell what kind of app {root} is',
|
|
178
|
+
hint='Pass --kind flutter|android|android-wear.')
|
|
179
|
+
|
|
180
|
+
written, kept = [], []
|
|
181
|
+
files = {'mkit.toml': render_toml(name, kind, package or ''), 'mkit.bat': BAT, 'mkit.ps1': PS1,
|
|
182
|
+
'mkit.sh': SH}
|
|
183
|
+
# The .env is the step everybody gets stuck on: which keys, in what format,
|
|
184
|
+
# from where. A commented skeleton answers all three in the folder where it
|
|
185
|
+
# is needed. It is never replaced, not even with --force: that file holds
|
|
186
|
+
# the only copy of somebody's credentials.
|
|
187
|
+
if not (root / '.env').exists():
|
|
188
|
+
files['.env'] = ENV_TEMPLATE
|
|
189
|
+
else:
|
|
190
|
+
kept.append('.env (exists; left alone)')
|
|
191
|
+
for fname, content in files.items():
|
|
192
|
+
target = root / fname
|
|
193
|
+
if target.exists() and not args.force:
|
|
194
|
+
kept.append(fname)
|
|
195
|
+
continue
|
|
196
|
+
newline = '\r\n' if fname.endswith('.bat') else '\n'
|
|
197
|
+
with open(target, 'w', encoding='utf-8', newline=newline) as f:
|
|
198
|
+
f.write(content)
|
|
199
|
+
written.append(fname)
|
|
200
|
+
if _ensure_gitignored(root):
|
|
201
|
+
written.append('.gitignore (.env)')
|
|
202
|
+
|
|
203
|
+
out = ctx.out
|
|
204
|
+
if not out.json:
|
|
205
|
+
out.heading(f'{name} {kind} {package or "package not found"}')
|
|
206
|
+
for f in written:
|
|
207
|
+
out.line(f' wrote {f}', 'green')
|
|
208
|
+
for f in kept:
|
|
209
|
+
out.line(f' kept {f} (exists; --force to replace)', 'dim')
|
|
210
|
+
out.line()
|
|
211
|
+
out.line('Next: add secrets to .env, switch features on in mkit.toml, then `mkit doctor`.')
|
|
212
|
+
return {'root': str(root), 'kind': kind, 'package': package, 'name': name,
|
|
213
|
+
'written': written, 'kept': kept}
|
mkit/commands/insight.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""How the app is doing: installs, conversion, vitals, crashes, reviews."""
|
|
2
|
+
from datetime import datetime, timezone
|
|
3
|
+
|
|
4
|
+
from .. import safety
|
|
5
|
+
from ..cloud import auth, insight
|
|
6
|
+
from ..registry import Arg, command, group
|
|
7
|
+
|
|
8
|
+
group('reviews', 'Play reviews: read and reply')
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _wrap(fn, what):
|
|
12
|
+
try:
|
|
13
|
+
return fn()
|
|
14
|
+
except Exception as e:
|
|
15
|
+
raise auth.translate(e, what) from None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@command('stats', 'Installs, uninstalls, active devices, store conversion, ratings, countries',
|
|
19
|
+
risk='read', needs=('play',),
|
|
20
|
+
args=(Arg('days', 'daily rows to show (human output)', type=int, default=14),))
|
|
21
|
+
def stats(ctx, args):
|
|
22
|
+
data = _wrap(lambda: insight.stats(ctx), 'Play reports bucket')
|
|
23
|
+
out = ctx.out
|
|
24
|
+
if not out.json:
|
|
25
|
+
if data['window']:
|
|
26
|
+
out.line(f'{ctx.project.name} {data["window"][0]} to {data["window"][1]} '
|
|
27
|
+
'(Play reports lag 3-7 days)', 'dim')
|
|
28
|
+
s, i, r = data['store'], data['installs'], data['ratings']
|
|
29
|
+
out.heading('store listing')
|
|
30
|
+
out.line(f' visitors {s["visitors"]} acquisitions {s["acquisitions"]}'
|
|
31
|
+
+ (f' conversion {s["conversion"]}%' if s['conversion'] is not None else ''))
|
|
32
|
+
out.heading('installs')
|
|
33
|
+
out.line(f' installs {i["installs"]} uninstalls {i["uninstalls"]} '
|
|
34
|
+
f'net {i["installs"] - i["uninstalls"]:+d}')
|
|
35
|
+
out.line(f' active devices: latest {i["active_latest"]}, peak {i["active_peak"]}')
|
|
36
|
+
out.heading('ratings')
|
|
37
|
+
out.line(f' {r["count"]} rating(s)' + (f', average {r["average"]}' if r['average'] else ''))
|
|
38
|
+
if data['countries']:
|
|
39
|
+
out.heading('installs by country')
|
|
40
|
+
out.line(' ' + ' '.join(f'{c} {n}' for c, n in list(data['countries'].items())[:12] if n))
|
|
41
|
+
if data['daily']:
|
|
42
|
+
out.heading('daily')
|
|
43
|
+
out.table([(d['date'], d['installs'], d['uninstalls'], d['active'])
|
|
44
|
+
for d in data['daily'][-args.days:]], ('date', 'installs', 'uninstalls', 'active'))
|
|
45
|
+
return data
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@command('vitals', 'Crash and ANR rates per version (Play Developer Reporting API)', risk='read',
|
|
49
|
+
needs=('play',), args=(Arg('days', 'window in days', type=int, default=28),))
|
|
50
|
+
def vitals(ctx, args):
|
|
51
|
+
data = insight.vitals(ctx, args.days)
|
|
52
|
+
if not ctx.out.json:
|
|
53
|
+
for key, label, rate, perceived in (('crash', 'crashes', 'crashRate', 'userPerceivedCrashRate'),
|
|
54
|
+
('anr', 'ANRs', 'anrRate', 'userPerceivedAnrRate')):
|
|
55
|
+
block = data[key]
|
|
56
|
+
ctx.out.heading(f'{label}' + (f' {block["window"][0]} to {block["window"][1]}' if block.get('window') else ''))
|
|
57
|
+
if not block['rows']:
|
|
58
|
+
ctx.out.line(f' {block.get("note", "no data")}')
|
|
59
|
+
continue
|
|
60
|
+
ctx.out.table([(r['versionCode'], r['users'], f'{r.get(rate, 0):.2f}%', f'{r.get(perceived, 0):.2f}%')
|
|
61
|
+
for r in block['rows']], ('versionCode', 'users', label, 'user-perceived'))
|
|
62
|
+
ctx.out.line()
|
|
63
|
+
ctx.out.line("Play's bad-behaviour thresholds: user-perceived crashes 1.09%, ANRs 0.47%", 'dim')
|
|
64
|
+
return data
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@command('crashes', 'Top crash and ANR clusters with where they happen', risk='read', needs=('play',),
|
|
68
|
+
args=(Arg('limit', 'how many', type=int, default=10),))
|
|
69
|
+
def crashes(ctx, args):
|
|
70
|
+
issues = insight.crashes(ctx, args.limit)
|
|
71
|
+
if not ctx.out.json:
|
|
72
|
+
if not issues:
|
|
73
|
+
ctx.out.line('no crash or ANR clusters reported')
|
|
74
|
+
for i in issues:
|
|
75
|
+
ctx.out.line(f' {i["reports"]:>5} reports {i["users"]:>4} users {i["type"]} '
|
|
76
|
+
f'v{i["firstVersion"]}-{i["lastVersion"]}')
|
|
77
|
+
ctx.out.line(f' {i["cause"] or ""}', 'bold')
|
|
78
|
+
ctx.out.line(f' {i["location"] or ""}', 'dim')
|
|
79
|
+
return {'issues': issues}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@command('reviews list', 'Reviews with text from the last 7 days (the API limit)', risk='read',
|
|
83
|
+
needs=('play',),
|
|
84
|
+
args=(Arg('limit', 'how many', type=int, default=50),
|
|
85
|
+
Arg('translate', 'translate into this language, e.g. en'),
|
|
86
|
+
Arg('unanswered', 'only reviews without a reply', kind='flag')))
|
|
87
|
+
def reviews_list(ctx, args):
|
|
88
|
+
items = _wrap(lambda: insight.reviews(ctx, args.limit, args.translate), 'Play reviews')
|
|
89
|
+
if args.unanswered:
|
|
90
|
+
items = [r for r in items if not r['replied']]
|
|
91
|
+
if not ctx.out.json:
|
|
92
|
+
if not items:
|
|
93
|
+
ctx.out.line('no reviews with text in the last 7 days (older ones: `mkit stats` has ratings)')
|
|
94
|
+
for r in items:
|
|
95
|
+
when = datetime.fromtimestamp(int(r['date']), timezone.utc).date() if r['date'] else ''
|
|
96
|
+
ctx.out.line(f' {"*" * (r["stars"] or 0):5} {when} {r["author"]} v{r["version"] or "?"} '
|
|
97
|
+
f'{"replied" if r["replied"] else "NO REPLY"} id {r["id"]}')
|
|
98
|
+
ctx.out.line(f' {r["text"]}')
|
|
99
|
+
return {'reviews': items}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@command('reviews reply', 'Reply to a review (public, 350 characters)', risk='publish', needs=('play',),
|
|
103
|
+
args=(Arg('id', 'review id from `mkit reviews list`', kind='positional'),
|
|
104
|
+
Arg('text', 'the reply', kind='positional')))
|
|
105
|
+
def reviews_reply(ctx, args):
|
|
106
|
+
safety.confirm(ctx, 'Post a public reply to a review', [args.text])
|
|
107
|
+
if ctx.dry_run:
|
|
108
|
+
return {'id': args.id, 'text': args.text, 'dry_run': True}
|
|
109
|
+
_wrap(lambda: insight.reply(ctx, args.id, args.text), 'Play reviews')
|
|
110
|
+
return {'id': args.id, 'replied': True}
|
mkit/commands/meta.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Commands that describe mkit itself: for agents first, humans second."""
|
|
2
|
+
from .. import __version__
|
|
3
|
+
from ..errors import EXIT_CODES, NotFound
|
|
4
|
+
from ..registry import GROUPS, REGISTRY, RISKS, Arg, command
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@command('commands', 'List every command with its risk class and what it needs',
|
|
8
|
+
risk='read', needs_project=False,
|
|
9
|
+
examples=('mkit commands', 'mkit commands --json'))
|
|
10
|
+
def commands(ctx, args):
|
|
11
|
+
"""Every registered command. In JSON mode each entry carries a JSON Schema of
|
|
12
|
+
its input, so an agent can build a valid call without reading help text."""
|
|
13
|
+
entries = [REGISTRY[n].describe() for n in sorted(REGISTRY)]
|
|
14
|
+
out = ctx.out
|
|
15
|
+
if not out.json:
|
|
16
|
+
for risk in RISKS:
|
|
17
|
+
rows = [(e['name'], e['summary']) for e in entries if e['risk'] == risk]
|
|
18
|
+
if rows:
|
|
19
|
+
out.heading(f'{risk}')
|
|
20
|
+
out.table(rows, ('command', 'what'))
|
|
21
|
+
out.line()
|
|
22
|
+
out.line('publish commands always ask first; agents pass --yes --confirm <package>.', 'dim')
|
|
23
|
+
return {'version': __version__, 'groups': GROUPS, 'commands': entries,
|
|
24
|
+
'exit_codes': {str(k): v for k, v in EXIT_CODES.items()}}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@command('schema', 'Input schema and details of one command', risk='read', needs_project=False,
|
|
28
|
+
args=(Arg('name', 'command name, e.g. "deploy" or "listing plan"', kind='positional', nargs='+'),),
|
|
29
|
+
examples=('mkit schema deploy --json', 'mkit schema listing plan'))
|
|
30
|
+
def schema(ctx, args):
|
|
31
|
+
name = ' '.join(args.name)
|
|
32
|
+
if name not in REGISTRY:
|
|
33
|
+
raise NotFound(f'no command "{name}"', hint='`mkit commands` lists them all.')
|
|
34
|
+
entry = REGISTRY[name].describe()
|
|
35
|
+
entry['description'] = REGISTRY[name].description
|
|
36
|
+
if not ctx.out.json:
|
|
37
|
+
ctx.out.heading(f'mkit {name} [{entry["risk"]}]')
|
|
38
|
+
ctx.out.line(entry['description'] or entry['summary'])
|
|
39
|
+
props = entry['input']['properties']
|
|
40
|
+
if props:
|
|
41
|
+
ctx.out.table([(k, v.get('type'), v.get('description', '')) for k, v in props.items()],
|
|
42
|
+
('argument', 'type', 'what'))
|
|
43
|
+
return entry
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# What the Play Developer API cannot do, checked against the API reference on
|
|
47
|
+
# 2026-09-18. `applies` narrows the list to what matters for this app.
|
|
48
|
+
MANUAL = (
|
|
49
|
+
{'id': 'create-app', 'what': 'Create the app and make its first upload',
|
|
50
|
+
'where': 'Play Console > Create app', 'applies': 'new apps only'},
|
|
51
|
+
{'id': 'privacy-policy', 'what': 'Set the privacy policy URL (needed before data safety)',
|
|
52
|
+
'where': 'App content > Privacy policy', 'applies': 'every app'},
|
|
53
|
+
{'id': 'datasafety-export', 'what': 'Read back the data safety answers Play holds (the API only uploads; `mkit datasafety build` writes the CSV from mkit.toml, `apply` uploads it)',
|
|
54
|
+
'where': 'App content > Data safety > Export to CSV', 'applies': 'only to check what is declared now'},
|
|
55
|
+
{'id': 'declarations', 'what': 'App content declarations other than data safety: ads, target '
|
|
56
|
+
'audience, content rating, permissions and foreground services, account deletion URL',
|
|
57
|
+
'where': 'App content', 'applies': 'every app, and again when the app changes'},
|
|
58
|
+
{'id': 'production-access', 'what': 'Production access for a new personal developer '
|
|
59
|
+
'account: 12 testers opted in for 14 days of closed testing, then apply',
|
|
60
|
+
'where': 'Play Console > Testing > Closed testing, then Production', 'applies': 'new personal accounts'},
|
|
61
|
+
{'id': 'app-access', 'what': 'Test credentials for an app behind a sign-in, so Play review '
|
|
62
|
+
'can get past the login screen', 'where': 'App content > App access', 'applies': 'apps that need an account'},
|
|
63
|
+
{'id': 'managed-publishing', 'what': 'Turn managed publishing on/off, press Publish, and "Send '
|
|
64
|
+
'for review" for changes held back', 'where': 'Publishing overview', 'applies': 'when used'},
|
|
65
|
+
{'id': 'tester-emails', 'what': 'Tester email lists (the API only takes Google Groups)',
|
|
66
|
+
'where': 'Testing > Testers', 'applies': 'closed testing with email lists'},
|
|
67
|
+
{'id': 'track-countries', 'what': 'Countries for a testing track (the API can only read them)',
|
|
68
|
+
'where': 'Testing > track > Countries/regions', 'applies': 'testing tracks'},
|
|
69
|
+
{'id': 'custom-listings', 'what': 'Custom store listings and store listing experiments',
|
|
70
|
+
'where': 'Grow users > Store presence', 'applies': 'when used'},
|
|
71
|
+
{'id': 'crashlytics', 'what': 'Crashlytics issues (no stable service-account API; `mkit crashes` '
|
|
72
|
+
'reads Play vitals instead)', 'where': 'Firebase console > Crashlytics', 'applies': 'firebase apps'},
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@command('manual', 'What only Play Console can do (no API), as a checklist', risk='read',
|
|
77
|
+
needs_project=False, examples=('mkit manual',))
|
|
78
|
+
def manual(ctx, args):
|
|
79
|
+
if not ctx.out.json:
|
|
80
|
+
ctx.out.heading('Only in Play Console (no API)')
|
|
81
|
+
for m in MANUAL:
|
|
82
|
+
ctx.out.line(f' - {m["what"]}')
|
|
83
|
+
ctx.out.line(f' {m["where"]} ({m["applies"]})', 'dim')
|
|
84
|
+
return {'manual': list(MANUAL)}
|
mkit/commands/money.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""In-app products, subscriptions and prices."""
|
|
2
|
+
from .. import safety
|
|
3
|
+
from ..cloud import auth, money
|
|
4
|
+
from ..errors import DRIFT
|
|
5
|
+
from ..registry import Arg, command, group
|
|
6
|
+
|
|
7
|
+
group('products', 'one-time products, their offers, and subscriptions')
|
|
8
|
+
group('prices', "Google Play's regional prices")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _svc(ctx):
|
|
12
|
+
return auth.play(ctx, timeout=120), ctx.project.get('play.package') or ctx.project.package
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@command('products list', 'What Play holds: every one-time product and subscription', risk='read',
|
|
16
|
+
needs=('play',))
|
|
17
|
+
def products_list(ctx, args):
|
|
18
|
+
svc, package = _svc(ctx)
|
|
19
|
+
try:
|
|
20
|
+
products, subs = money.list_remote(svc, package)
|
|
21
|
+
except Exception as e:
|
|
22
|
+
raise auth.translate(e, 'Play') from None
|
|
23
|
+
rows = []
|
|
24
|
+
for p in products:
|
|
25
|
+
for o in p.get('purchaseOptions', []) or [{}]:
|
|
26
|
+
rows.append({'kind': 'product', 'id': p['productId'], 'part': o.get('purchaseOptionId', ''),
|
|
27
|
+
'state': o.get('state', ''), 'title': (p.get('listings') or [{}])[0].get('title', '')})
|
|
28
|
+
for s in subs:
|
|
29
|
+
for b in s.get('basePlans', []) or [{}]:
|
|
30
|
+
rows.append({'kind': 'subscription', 'id': s['productId'], 'part': b.get('basePlanId', ''),
|
|
31
|
+
'state': b.get('state', ''), 'title': (s.get('listings') or [{}])[0].get('title', '')})
|
|
32
|
+
if not ctx.out.json:
|
|
33
|
+
if rows:
|
|
34
|
+
ctx.out.table([(r['kind'], r['id'], r['part'], r['state'], r['title']) for r in rows],
|
|
35
|
+
('kind', 'id', 'option/plan', 'state', 'title'))
|
|
36
|
+
else:
|
|
37
|
+
ctx.out.line('no products or subscriptions on Play')
|
|
38
|
+
return {'items': rows}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _show(out, changes):
|
|
42
|
+
if out.json:
|
|
43
|
+
return
|
|
44
|
+
if not changes:
|
|
45
|
+
out.line(' no changes: Play matches the products file', 'green')
|
|
46
|
+
for c in changes:
|
|
47
|
+
detail = ''
|
|
48
|
+
if c['kind'] == 'offer':
|
|
49
|
+
detail = _offer_detail(c)
|
|
50
|
+
elif c['kind'] == 'trial':
|
|
51
|
+
detail = {'create': f'free for {c["trial"]}, then activate',
|
|
52
|
+
'update': f'length {c.get("from")} -> {c["trial"]}',
|
|
53
|
+
'activate': f'state {c.get("state")} -> ACTIVE'}[c['action']]
|
|
54
|
+
elif c['action'] == 'update':
|
|
55
|
+
parts = []
|
|
56
|
+
if c['prices']:
|
|
57
|
+
parts.append('prices ' + ', '.join(f'{r} {v["from"]}->{v["to"]}' for r, v in sorted(c['prices'].items())))
|
|
58
|
+
if c['listings']:
|
|
59
|
+
parts.append('listing ' + ', '.join(c['listings']))
|
|
60
|
+
if c.get('state') != 'ACTIVE':
|
|
61
|
+
parts.append(f'state {c.get("state")} -> ACTIVE')
|
|
62
|
+
detail = '; '.join(parts)
|
|
63
|
+
out.line(f' {c["action"]:8} {c["kind"]:12} {c["id"]} {detail}')
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _day(value):
|
|
67
|
+
"""2026-12-31T00:00:00Z reads as 2026-12-31; any other time stays whole."""
|
|
68
|
+
if isinstance(value, str) and value.endswith('T00:00:00Z'):
|
|
69
|
+
return value[:10]
|
|
70
|
+
return value
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _offer_detail(c):
|
|
74
|
+
window = c['body']['discountedOffer']
|
|
75
|
+
if c['action'] == 'create':
|
|
76
|
+
when = f'until {_day(window["endTime"])}'
|
|
77
|
+
if 'startTime' in window:
|
|
78
|
+
when = f'{_day(window["startTime"])} to {_day(window["endTime"])}'
|
|
79
|
+
return f'{c["discount_pct"]}% off in {c["regions"]} region(s), {when}, then activate'
|
|
80
|
+
parts = []
|
|
81
|
+
for field, v in c['changes'].items():
|
|
82
|
+
if field == 'regions':
|
|
83
|
+
parts.append(f'add region(s) {", ".join(v["added"])}')
|
|
84
|
+
elif field == 'discount_pct':
|
|
85
|
+
fix = f' (fix {", ".join(v["regions"])})' if v.get('regions') else ''
|
|
86
|
+
parts.append(f'discount {v["from"]}% -> {v["to"]}%{fix}')
|
|
87
|
+
elif field == 'tags':
|
|
88
|
+
parts.append(f'tags {v["from"]} -> {v["to"]}')
|
|
89
|
+
else:
|
|
90
|
+
parts.append(f'{field} {_day(v["from"]) or "none"} -> {_day(v["to"])}')
|
|
91
|
+
if c.get('state') != 'ACTIVE':
|
|
92
|
+
parts.append(f'state {c.get("state")} -> ACTIVE')
|
|
93
|
+
return '; '.join(parts)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _public(changes):
|
|
97
|
+
return [{k: v for k, v in c.items() if k != 'body'} for c in changes]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@command('products plan', 'Compare the products file with Play (changes nothing)', risk='plan',
|
|
101
|
+
needs=('play', 'store'))
|
|
102
|
+
def products_plan(ctx, args):
|
|
103
|
+
data = money.load(ctx.project)
|
|
104
|
+
svc, package = _svc(ctx)
|
|
105
|
+
try:
|
|
106
|
+
changes = money.plan(svc, package, data)
|
|
107
|
+
except Exception as e:
|
|
108
|
+
raise auth.translate(e, 'Play') from None
|
|
109
|
+
_show(ctx.out, changes)
|
|
110
|
+
if changes:
|
|
111
|
+
ctx.exit_code = DRIFT
|
|
112
|
+
return {'changes': _public(changes)}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@command('products apply',
|
|
116
|
+
'Create or update products, their offers and subscriptions to match the file, and activate them',
|
|
117
|
+
risk='publish', needs=('play', 'store'))
|
|
118
|
+
def products_apply(ctx, args):
|
|
119
|
+
data = money.load(ctx.project)
|
|
120
|
+
svc, package = _svc(ctx)
|
|
121
|
+
try:
|
|
122
|
+
changes = money.plan(svc, package, data)
|
|
123
|
+
_show(ctx.out, changes)
|
|
124
|
+
if not changes:
|
|
125
|
+
return {'changes': [], 'changed': False}
|
|
126
|
+
safety.confirm(ctx, f'Apply {len(changes)} product change(s) to {package}',
|
|
127
|
+
[f'{c["action"]} {c["kind"]} {c["id"]}' for c in changes])
|
|
128
|
+
if ctx.dry_run:
|
|
129
|
+
return {'changes': _public(changes), 'dry_run': True}
|
|
130
|
+
money.apply(svc, package, data, changes)
|
|
131
|
+
except Exception as e:
|
|
132
|
+
raise auth.translate(e, 'Play') from None
|
|
133
|
+
if not ctx.out.json:
|
|
134
|
+
ctx.out.line(f'{len(changes)} change(s) applied and activated', 'green')
|
|
135
|
+
return {'changes': _public(changes), 'changed': True}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@command('prices convert', "Google Play's own local price for every region, from one price",
|
|
139
|
+
risk='read', needs=('play',),
|
|
140
|
+
args=(Arg('amount', 'price, tax exclusive, e.g. 4.99', kind='positional', type=float),
|
|
141
|
+
Arg('currency', 'currency of the amount', default='USD')),
|
|
142
|
+
examples=('mkit prices convert 4.99',))
|
|
143
|
+
def prices_convert(ctx, args):
|
|
144
|
+
svc, package = _svc(ctx)
|
|
145
|
+
try:
|
|
146
|
+
result = money.convert(svc, package, args.amount, args.currency)
|
|
147
|
+
except Exception as e:
|
|
148
|
+
raise auth.translate(e, 'Play') from None
|
|
149
|
+
if not ctx.out.json:
|
|
150
|
+
ctx.out.table([(r, v['currency'], v['amount']) for r, v in sorted(result.items())],
|
|
151
|
+
('region', 'currency', 'amount'))
|
|
152
|
+
return {'amount': args.amount, 'currency': args.currency, 'regions': result}
|