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/output.py
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
"""Output for two readers: a person at a terminal and an agent reading a pipe.
|
|
2
|
+
|
|
3
|
+
Human mode keeps the look of the per-project kits: a bold title, one checked
|
|
4
|
+
line per finished step with its detail underneath, and a sticky run bar as the
|
|
5
|
+
last line, rewritten in place. Detail is buffered until its step closes so it
|
|
6
|
+
always sits under its own heading.
|
|
7
|
+
|
|
8
|
+
JSON mode is for anything that is not a terminal (an agent's shell, CI, a pipe)
|
|
9
|
+
or `--json`. stdout then carries exactly one JSON document, the result, and
|
|
10
|
+
stderr carries one JSON event per line while the command runs:
|
|
11
|
+
|
|
12
|
+
{"event": "step", "title": "Build release bundle", "index": 5, "total": 11}
|
|
13
|
+
{"event": "detail", "text": "..."} {"event": "warn", "text": "..."}
|
|
14
|
+
{"event": "skipped", "step": "...", "reason": "..."}
|
|
15
|
+
{"event": "upload", "percent": 40}
|
|
16
|
+
|
|
17
|
+
The result is always the same envelope, so a caller parses one shape:
|
|
18
|
+
|
|
19
|
+
{"ok": true, "command": "doctor", "exit": 0, "result": {...},
|
|
20
|
+
"warnings": [...], "skipped": [...]}
|
|
21
|
+
{"ok": false, "command": "deploy", "exit": 3, "error": {"code", "message", "hint", "retryable"}}
|
|
22
|
+
|
|
23
|
+
MKIT_OUTPUT=human|json overrides the terminal check. Every string passes
|
|
24
|
+
through secrets.redact before it is written anywhere.
|
|
25
|
+
"""
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import shutil
|
|
29
|
+
import sys
|
|
30
|
+
import time
|
|
31
|
+
|
|
32
|
+
from .secrets import redact
|
|
33
|
+
|
|
34
|
+
for _stream in (sys.stdout, sys.stderr):
|
|
35
|
+
try:
|
|
36
|
+
_stream.reconfigure(encoding='utf-8')
|
|
37
|
+
except Exception:
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
_CODES = {
|
|
41
|
+
'dim': '\x1b[90m', 'bold': '\x1b[1m', 'green': '\x1b[32m',
|
|
42
|
+
'cyan': '\x1b[36m', 'yellow': '\x1b[33m', 'red': '\x1b[31m',
|
|
43
|
+
}
|
|
44
|
+
_RESET = '\x1b[0m'
|
|
45
|
+
BAR_WIDTH = 24
|
|
46
|
+
UPLOAD_WIDTH = 14
|
|
47
|
+
INDENT = ' '
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _supports(text):
|
|
51
|
+
try:
|
|
52
|
+
text.encode(sys.stdout.encoding or 'utf-8')
|
|
53
|
+
return True
|
|
54
|
+
except (UnicodeEncodeError, LookupError):
|
|
55
|
+
return False
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def human(seconds):
|
|
59
|
+
seconds = int(round(seconds))
|
|
60
|
+
if seconds < 60:
|
|
61
|
+
return f'{seconds}s'
|
|
62
|
+
return f'{seconds // 60}m {seconds % 60:02d}s'
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _visible_len(text):
|
|
66
|
+
out, i = 0, 0
|
|
67
|
+
while i < len(text):
|
|
68
|
+
if text[i] == '\x1b':
|
|
69
|
+
i = text.find('m', i) + 1
|
|
70
|
+
continue
|
|
71
|
+
out += 1
|
|
72
|
+
i += 1
|
|
73
|
+
return out
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class Output:
|
|
77
|
+
def __init__(self, mode=None, color=None):
|
|
78
|
+
forced = os.environ.get('MKIT_OUTPUT')
|
|
79
|
+
tty = sys.stdout.isatty()
|
|
80
|
+
self.json = (mode or forced or ('human' if tty else 'json')) == 'json'
|
|
81
|
+
self.tty = tty and not self.json
|
|
82
|
+
if color is None:
|
|
83
|
+
color = not os.environ.get('NO_COLOR')
|
|
84
|
+
self.color = self.tty and color
|
|
85
|
+
unicode_ok = _supports('█░✓')
|
|
86
|
+
self.fill, self.empty = ('█', '░') if unicode_ok else ('#', '-')
|
|
87
|
+
self.warnings = []
|
|
88
|
+
self.skipped = []
|
|
89
|
+
self._reset_run()
|
|
90
|
+
|
|
91
|
+
# ------------------------------------------------------------ plumbing
|
|
92
|
+
|
|
93
|
+
def _reset_run(self):
|
|
94
|
+
self.total = 0
|
|
95
|
+
self.done_count = 0
|
|
96
|
+
self.title = None
|
|
97
|
+
self.run_started = None
|
|
98
|
+
self.step_started = None
|
|
99
|
+
self.buffer = []
|
|
100
|
+
self.sticky = False
|
|
101
|
+
self.upload_pct = None
|
|
102
|
+
self.count = None
|
|
103
|
+
self.count_mark = -1
|
|
104
|
+
self.last_draw = 0.0
|
|
105
|
+
|
|
106
|
+
def paint(self, style, text):
|
|
107
|
+
if not self.color or not style:
|
|
108
|
+
return text
|
|
109
|
+
return f'{_CODES[style]}{text}{_RESET}'
|
|
110
|
+
|
|
111
|
+
def event(self, name, **fields):
|
|
112
|
+
"""One JSON line on stderr (JSON mode only)."""
|
|
113
|
+
if not self.json:
|
|
114
|
+
return
|
|
115
|
+
payload = {'event': name, **fields}
|
|
116
|
+
sys.stderr.write(redact(json.dumps(payload, ensure_ascii=False, default=str)) + '\n')
|
|
117
|
+
sys.stderr.flush()
|
|
118
|
+
|
|
119
|
+
def _print(self, line=''):
|
|
120
|
+
self._clear_sticky()
|
|
121
|
+
print(redact(line))
|
|
122
|
+
self._draw_sticky()
|
|
123
|
+
|
|
124
|
+
def _clear_sticky(self):
|
|
125
|
+
if self.sticky:
|
|
126
|
+
sys.stdout.write('\r\x1b[2K')
|
|
127
|
+
self.sticky = False
|
|
128
|
+
|
|
129
|
+
def _bar(self, pct, width):
|
|
130
|
+
filled = int(round(pct / 100 * width))
|
|
131
|
+
return self.fill * filled + self.empty * (width - filled)
|
|
132
|
+
|
|
133
|
+
def _width(self):
|
|
134
|
+
# One short of the real width: writing the last column makes some
|
|
135
|
+
# terminals wrap, and a wrapped sticky line cannot be erased.
|
|
136
|
+
try:
|
|
137
|
+
return max(28, shutil.get_terminal_size(fallback=(80, 24)).columns - 1)
|
|
138
|
+
except Exception:
|
|
139
|
+
return 79
|
|
140
|
+
|
|
141
|
+
def _draw_sticky(self):
|
|
142
|
+
if not self.tty or not self.total:
|
|
143
|
+
return
|
|
144
|
+
width = self._width()
|
|
145
|
+
pct = int(round(self.done_count / self.total * 100))
|
|
146
|
+
uploading = self.upload_pct is not None
|
|
147
|
+
run_w = BAR_WIDTH if width >= 90 else (14 if width >= 60 else 8)
|
|
148
|
+
up_w = UPLOAD_WIDTH if width >= 100 else (8 if width >= 70 else 6)
|
|
149
|
+
segments = [
|
|
150
|
+
('dim', ' run '),
|
|
151
|
+
('cyan', self._bar(pct, run_w)),
|
|
152
|
+
('dim', f' {pct:>3}% {min(self.done_count + 1, self.total)} of {self.total}'),
|
|
153
|
+
]
|
|
154
|
+
if self.title and not (uploading and width < 96):
|
|
155
|
+
segments.append((None, ' ' + self.title))
|
|
156
|
+
if uploading:
|
|
157
|
+
counted = f' {self.count[0]}/{self.count[1]}' if self.count else ''
|
|
158
|
+
segments += [('dim', ' upload '), ('cyan', self._bar(self.upload_pct, up_w)),
|
|
159
|
+
('dim', f' {self.upload_pct:>3}%{counted}')]
|
|
160
|
+
elif self.step_started:
|
|
161
|
+
segments.append(('dim', ' ' + human(time.time() - self.step_started)))
|
|
162
|
+
out, used = [], 0
|
|
163
|
+
for style, text in segments:
|
|
164
|
+
if used >= width:
|
|
165
|
+
break
|
|
166
|
+
if used + len(text) > width:
|
|
167
|
+
text = text[:width - used]
|
|
168
|
+
out.append(self.paint(style, text))
|
|
169
|
+
used += len(text)
|
|
170
|
+
sys.stdout.write('\r\x1b[2K' + ''.join(out))
|
|
171
|
+
sys.stdout.flush()
|
|
172
|
+
self.sticky = True
|
|
173
|
+
|
|
174
|
+
def _buffer(self, style, text, kind):
|
|
175
|
+
text = redact(str(text))
|
|
176
|
+
if self.json:
|
|
177
|
+
self.event(kind, text=text, step=self.title)
|
|
178
|
+
return
|
|
179
|
+
if self.title is None:
|
|
180
|
+
print(INDENT + self.paint(style, text))
|
|
181
|
+
return
|
|
182
|
+
self.buffer.append((style, text))
|
|
183
|
+
self._draw_sticky()
|
|
184
|
+
|
|
185
|
+
# ------------------------------------------------------------ run API
|
|
186
|
+
|
|
187
|
+
def start(self, title, total, subtitle=None):
|
|
188
|
+
self._reset_run()
|
|
189
|
+
self.total = total
|
|
190
|
+
self.run_started = time.time()
|
|
191
|
+
if self.json:
|
|
192
|
+
self.event('start', title=title, subtitle=subtitle, total=total)
|
|
193
|
+
return
|
|
194
|
+
print()
|
|
195
|
+
head = self.paint('bold', title)
|
|
196
|
+
if subtitle:
|
|
197
|
+
head += self.paint('dim', ' ' + subtitle)
|
|
198
|
+
print(redact(head))
|
|
199
|
+
print()
|
|
200
|
+
|
|
201
|
+
def step(self, title):
|
|
202
|
+
self.title = title
|
|
203
|
+
self.step_started = time.time()
|
|
204
|
+
self.buffer = []
|
|
205
|
+
self.upload_pct = None
|
|
206
|
+
self.count = None
|
|
207
|
+
self.count_mark = -1
|
|
208
|
+
self.event('step', title=title, index=self.done_count + 1, total=self.total)
|
|
209
|
+
self._draw_sticky()
|
|
210
|
+
|
|
211
|
+
def detail(self, message):
|
|
212
|
+
self._buffer(None, message, 'detail')
|
|
213
|
+
|
|
214
|
+
def ok(self, message='done'):
|
|
215
|
+
self._buffer('green', message, 'detail')
|
|
216
|
+
|
|
217
|
+
def warn(self, message):
|
|
218
|
+
self.warnings.append(redact(str(message)))
|
|
219
|
+
self._buffer('yellow', message, 'warn')
|
|
220
|
+
|
|
221
|
+
def skip(self, step, reason):
|
|
222
|
+
"""A pipeline step that did not run. Collected into the result."""
|
|
223
|
+
self.skipped.append({'step': step, 'reason': redact(reason)})
|
|
224
|
+
if self.json:
|
|
225
|
+
self.event('skipped', step=step, reason=redact(reason))
|
|
226
|
+
else:
|
|
227
|
+
self._buffer('yellow', f'skipped: {reason}', 'skipped')
|
|
228
|
+
|
|
229
|
+
def done(self, note=None):
|
|
230
|
+
if self.title is None:
|
|
231
|
+
return
|
|
232
|
+
elapsed = time.time() - self.step_started if self.step_started else 0
|
|
233
|
+
index = self.done_count + 1
|
|
234
|
+
if self.json:
|
|
235
|
+
self.event('done', title=self.title, index=index, seconds=round(elapsed, 1), note=note)
|
|
236
|
+
else:
|
|
237
|
+
heading = (self.paint('dim', f' [{index:02d}/{self.total:02d}] ')
|
|
238
|
+
+ self.paint('green', 'OK') + ' ' + self.paint('bold', self.title))
|
|
239
|
+
if elapsed >= 5:
|
|
240
|
+
heading += self.paint('dim', ' ' + human(elapsed))
|
|
241
|
+
if note:
|
|
242
|
+
heading += self.paint('dim', ' ' + note)
|
|
243
|
+
self._clear_sticky()
|
|
244
|
+
print(redact(heading))
|
|
245
|
+
for style, text in self.buffer:
|
|
246
|
+
print(INDENT + self.paint(style, text))
|
|
247
|
+
self.done_count = index
|
|
248
|
+
self.title = None
|
|
249
|
+
self.step_started = None
|
|
250
|
+
self.buffer = []
|
|
251
|
+
self.upload_pct = None
|
|
252
|
+
self.count = None
|
|
253
|
+
self._draw_sticky()
|
|
254
|
+
|
|
255
|
+
def upload(self, percent):
|
|
256
|
+
pct = max(0, min(100, int(percent)))
|
|
257
|
+
if self.json:
|
|
258
|
+
if pct != self.upload_pct:
|
|
259
|
+
self.event('upload', percent=pct)
|
|
260
|
+
self.upload_pct = pct
|
|
261
|
+
return
|
|
262
|
+
self.upload_pct = pct
|
|
263
|
+
self._draw_sticky()
|
|
264
|
+
|
|
265
|
+
def progress(self, done, total, item=None):
|
|
266
|
+
"""Work counted in items (files uploaded, calls made). At a terminal it
|
|
267
|
+
is the upload bar with "done/total"; piped, a line at every tenth; in
|
|
268
|
+
JSON a progress event per item."""
|
|
269
|
+
if self.json:
|
|
270
|
+
self.event('progress', done=done, total=total, item=item, step=self.title)
|
|
271
|
+
return
|
|
272
|
+
pct = int(done * 100 / total) if total else 100
|
|
273
|
+
self.count = (done, total)
|
|
274
|
+
self.upload_pct = pct
|
|
275
|
+
if self.tty:
|
|
276
|
+
self.tick() if done < total else self._draw_sticky()
|
|
277
|
+
return
|
|
278
|
+
mark = pct // 10
|
|
279
|
+
if mark != self.count_mark or done == total:
|
|
280
|
+
self.count_mark = mark
|
|
281
|
+
print(redact(f'{INDENT}{self.title or "progress"}: {done}/{total}'), flush=True)
|
|
282
|
+
|
|
283
|
+
def tick(self, throttle=0.15):
|
|
284
|
+
now = time.time()
|
|
285
|
+
if now - self.last_draw < throttle:
|
|
286
|
+
return
|
|
287
|
+
self.last_draw = now
|
|
288
|
+
self._draw_sticky()
|
|
289
|
+
|
|
290
|
+
def finish(self, message):
|
|
291
|
+
total = time.time() - self.run_started if self.run_started else 0
|
|
292
|
+
if self.json:
|
|
293
|
+
self.event('finish', message=message, seconds=round(total, 1))
|
|
294
|
+
return
|
|
295
|
+
self._clear_sticky()
|
|
296
|
+
if self.tty and self.total:
|
|
297
|
+
print(self.paint('dim', ' run ') + self.paint('green', self._bar(100, BAR_WIDTH))
|
|
298
|
+
+ self.paint('dim', f' 100% {self.total} of {self.total}'))
|
|
299
|
+
print()
|
|
300
|
+
colour = 'yellow' if self.skipped else 'green'
|
|
301
|
+
suffix = f' ({len(self.skipped)} skipped)' if self.skipped else ''
|
|
302
|
+
print(self.paint(colour, redact('DONE ' + message + suffix
|
|
303
|
+
+ (f' in {human(total)}' if total >= 5 else ''))))
|
|
304
|
+
|
|
305
|
+
# ------------------------------------------------------------ plain text
|
|
306
|
+
|
|
307
|
+
def line(self, text='', style=None):
|
|
308
|
+
"""A plain informational line. JSON mode sends it to stderr as a log event."""
|
|
309
|
+
if self.json:
|
|
310
|
+
if text:
|
|
311
|
+
self.event('log', text=redact(str(text)))
|
|
312
|
+
return
|
|
313
|
+
self._print(self.paint(style, str(text)))
|
|
314
|
+
|
|
315
|
+
def heading(self, text):
|
|
316
|
+
self.line()
|
|
317
|
+
self.line(text, 'bold')
|
|
318
|
+
|
|
319
|
+
def table(self, rows, headers):
|
|
320
|
+
"""Aligned columns for humans. JSON callers read the result instead."""
|
|
321
|
+
if self.json or not rows:
|
|
322
|
+
return
|
|
323
|
+
cells = [[str(c) for c in headers]] + [[str(c) for c in r] for r in rows]
|
|
324
|
+
widths = [max(len(r[i]) for r in cells) for i in range(len(headers))]
|
|
325
|
+
for n, row in enumerate(cells):
|
|
326
|
+
text = ' ' + ' '.join(c.ljust(w) for c, w in zip(row, widths)).rstrip()
|
|
327
|
+
self._print(self.paint('dim' if n == 0 else None, text))
|
|
328
|
+
|
|
329
|
+
# ------------------------------------------------------------ endings
|
|
330
|
+
|
|
331
|
+
def result(self, command, data, exit_code=0):
|
|
332
|
+
if not self.json:
|
|
333
|
+
return
|
|
334
|
+
envelope = {'ok': True, 'command': command, 'exit': exit_code, 'result': data,
|
|
335
|
+
'warnings': self.warnings, 'skipped': self.skipped}
|
|
336
|
+
print(redact(json.dumps(envelope, ensure_ascii=False, indent=2, default=str)))
|
|
337
|
+
|
|
338
|
+
def failure(self, command, error, exit_code):
|
|
339
|
+
"""Report an MkitError. Human: headline, detail, tool tail. JSON: envelope."""
|
|
340
|
+
if self.json:
|
|
341
|
+
envelope = {'ok': False, 'command': command, 'exit': exit_code,
|
|
342
|
+
'error': error.as_dict(), 'warnings': self.warnings,
|
|
343
|
+
'skipped': self.skipped}
|
|
344
|
+
print(redact(json.dumps(envelope, ensure_ascii=False, indent=2, default=str)))
|
|
345
|
+
return
|
|
346
|
+
self._clear_sticky()
|
|
347
|
+
if self.buffer:
|
|
348
|
+
# The open step never finished, so its held lines were never shown;
|
|
349
|
+
# among them is often the one that matters ("bump commit undone").
|
|
350
|
+
title = self.title or 'In progress'
|
|
351
|
+
print(self.paint('dim', f' [--/{self.total:02d}] ') + self.paint('red', '!!')
|
|
352
|
+
+ ' ' + self.paint('bold', title))
|
|
353
|
+
for style, text in self.buffer:
|
|
354
|
+
print(INDENT + self.paint(style, text))
|
|
355
|
+
self.buffer = []
|
|
356
|
+
headline = redact(error.message)
|
|
357
|
+
print()
|
|
358
|
+
print(self.paint('red', headline))
|
|
359
|
+
print(self.paint('dim', '-' * min(_visible_len(headline), self._width())))
|
|
360
|
+
if error.hint:
|
|
361
|
+
print(' ' + redact(error.hint))
|
|
362
|
+
if error.lines:
|
|
363
|
+
print()
|
|
364
|
+
print(self.paint('dim', ' Last output:'))
|
|
365
|
+
for line in error.lines:
|
|
366
|
+
print(' ' + redact(line))
|
|
367
|
+
print()
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
out = Output()
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def configure(mode=None, color=None):
|
|
374
|
+
"""Replace the shared instance once flags are parsed."""
|
|
375
|
+
global out
|
|
376
|
+
out = Output(mode=mode, color=color)
|
|
377
|
+
return out
|
mkit/proc.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Running noisy external tools.
|
|
2
|
+
|
|
3
|
+
Gradle and the Firebase CLI print hundreds of lines and bury their own cause
|
|
4
|
+
when they fail. Output is captured rather than inherited, only lines that
|
|
5
|
+
matter surface as step detail, every other line is a heartbeat for the run
|
|
6
|
+
bar, and a failure carries the tail with the boilerplate stripped.
|
|
7
|
+
"""
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import subprocess
|
|
11
|
+
|
|
12
|
+
from . import output
|
|
13
|
+
from .errors import MkitError
|
|
14
|
+
|
|
15
|
+
ANSI = re.compile(r'\x1b\[[0-9;]*[A-Za-z]')
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ToolFailed(MkitError):
|
|
19
|
+
code = 'TOOL_FAILED'
|
|
20
|
+
|
|
21
|
+
def __init__(self, command, returncode, tail):
|
|
22
|
+
super().__init__(f'{command} exited {returncode}', lines=tail,
|
|
23
|
+
hint='The last lines of its output are below.')
|
|
24
|
+
self.command = command
|
|
25
|
+
self.returncode = returncode
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# Compile errors are worth seeing live even on a build that goes on to pass:
|
|
29
|
+
# Dart "lib/x.dart:3:5: Error:", Kotlin "e: file:///...", javac "error:".
|
|
30
|
+
# Kotlin's marker only counts at the start of a line: "Note: " contains it too.
|
|
31
|
+
_BUILD_SIGNALS = ('error:', 'Error:')
|
|
32
|
+
|
|
33
|
+
_BUILD_NOISE = (
|
|
34
|
+
'Deprecated Gradle features were used', "You can use '--warning-mode",
|
|
35
|
+
'For more on this, please refer to', 'Run with --stacktrace', 'Run with --info',
|
|
36
|
+
'Run with --scan', 'Get more help at', 'actionable task', '* Try:', '* Get more help',
|
|
37
|
+
'Note: Recompile with', 'Note: Some input files use', 'Daemon will be stopped', 'w: ',
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
_FIREBASE_SIGNALS = ('rules', 'indexes', 'deploy complete', 'error', 'warning')
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def build_interesting(line):
|
|
44
|
+
return bool(line) and (line.startswith('e: ') or any(m in line for m in _BUILD_SIGNALS))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def firebase_interesting(line):
|
|
48
|
+
return any(w in line.lower() for w in _FIREBASE_SIGNALS)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def never(line):
|
|
52
|
+
return False
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def useful_tail(lines, limit=25):
|
|
56
|
+
kept = []
|
|
57
|
+
for line in lines:
|
|
58
|
+
if any(m in line for m in _BUILD_NOISE):
|
|
59
|
+
continue
|
|
60
|
+
if not line.strip() and (not kept or not kept[-1].strip()):
|
|
61
|
+
continue
|
|
62
|
+
kept.append(line)
|
|
63
|
+
while kept and not kept[-1].strip():
|
|
64
|
+
kept.pop()
|
|
65
|
+
return kept[-limit:]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def stream(argv, command, interesting=build_interesting, cwd=None, env=None, quiet=False,
|
|
69
|
+
done_marker=None):
|
|
70
|
+
"""Run a tool with its output captured; raise ToolFailed with the tail on a
|
|
71
|
+
non-zero exit. Returns the last 60 lines.
|
|
72
|
+
|
|
73
|
+
[done_marker] is a line a tool prints once its work is actually finished. A
|
|
74
|
+
tool that says so and *then* exits non-zero failed at something it does
|
|
75
|
+
after the job -- telemetry, a cleanup, a version check -- and treating that
|
|
76
|
+
as a failed deploy is worse than wrong: the caller rolls back a change the
|
|
77
|
+
tool already made. When the marker was seen the exit code is reported as a
|
|
78
|
+
warning and the run counts as a success.
|
|
79
|
+
"""
|
|
80
|
+
out = output.out
|
|
81
|
+
try:
|
|
82
|
+
proc = subprocess.Popen([str(a) for a in argv], cwd=cwd or os.getcwd(), env=env,
|
|
83
|
+
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
|
84
|
+
encoding='utf-8', errors='replace', bufsize=1)
|
|
85
|
+
except FileNotFoundError as e:
|
|
86
|
+
raise MkitError(f'could not start {command}: {e}', code='TOOL_MISSING') from e
|
|
87
|
+
tail = []
|
|
88
|
+
# Tracked as the lines go by rather than read back off the tail, which only keeps the
|
|
89
|
+
# last 60 and would lose the marker behind a talkative epilogue.
|
|
90
|
+
finished = False
|
|
91
|
+
for raw in proc.stdout:
|
|
92
|
+
clean = ANSI.sub('', raw.rstrip()).strip()
|
|
93
|
+
tail.append(clean)
|
|
94
|
+
if len(tail) > 60:
|
|
95
|
+
tail.pop(0)
|
|
96
|
+
if done_marker and done_marker in clean:
|
|
97
|
+
finished = True
|
|
98
|
+
if interesting(clean) and not quiet:
|
|
99
|
+
out.detail(clean)
|
|
100
|
+
else:
|
|
101
|
+
out.tick()
|
|
102
|
+
code = proc.wait()
|
|
103
|
+
if code != 0:
|
|
104
|
+
if finished:
|
|
105
|
+
out.warn(f'{command} finished its work, then exited {code} on its own; continuing')
|
|
106
|
+
return tail
|
|
107
|
+
raise ToolFailed(command, code, useful_tail(tail))
|
|
108
|
+
return tail
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def run(argv, cwd=None, env=None, check=True, timeout=None):
|
|
112
|
+
"""Run quietly and return stdout (stderr folded in)."""
|
|
113
|
+
try:
|
|
114
|
+
result = subprocess.run([str(a) for a in argv], cwd=cwd, env=env, text=True,
|
|
115
|
+
encoding='utf-8', errors='replace', capture_output=True,
|
|
116
|
+
timeout=timeout)
|
|
117
|
+
except FileNotFoundError as e:
|
|
118
|
+
raise MkitError(f'could not start {argv[0]}: {e}', code='TOOL_MISSING') from e
|
|
119
|
+
text = (result.stdout or '') + (result.stderr or '')
|
|
120
|
+
if check and result.returncode != 0:
|
|
121
|
+
raise ToolFailed(os.path.basename(str(argv[0])) + ' ' + ' '.join(str(a) for a in argv[1:3]),
|
|
122
|
+
result.returncode, useful_tail(text.splitlines()))
|
|
123
|
+
return text
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def detach(argv, cwd=None, env=None):
|
|
127
|
+
"""Start a windowed tool (Android Studio) and return its pid.
|
|
128
|
+
|
|
129
|
+
Detached on purpose: mkit exits while the IDE stays open, and nothing of
|
|
130
|
+
the IDE's own chatter lands in mkit's output or JSON envelope.
|
|
131
|
+
"""
|
|
132
|
+
kwargs = ({'creationflags': subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP}
|
|
133
|
+
if os.name == 'nt' else {'start_new_session': True})
|
|
134
|
+
try:
|
|
135
|
+
started = subprocess.Popen([str(a) for a in argv], cwd=cwd, env=env,
|
|
136
|
+
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
|
137
|
+
stderr=subprocess.DEVNULL, **kwargs)
|
|
138
|
+
except OSError as e:
|
|
139
|
+
raise MkitError(f'could not start {argv[0]}: {e}', code='TOOL_MISSING') from e
|
|
140
|
+
return started.pid
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def interactive(argv, cwd=None, env=None):
|
|
144
|
+
"""Run with the terminal attached (flutter run, tests). Returns the exit code.
|
|
145
|
+
|
|
146
|
+
Ctrl+C reaches the tool and mkit alike. mkit ignores it while the tool runs,
|
|
147
|
+
so the tool decides how to stop (flutter run detaches cleanly) and mkit then
|
|
148
|
+
ends with its exit code, instead of a second "interrupted" error on top."""
|
|
149
|
+
import signal
|
|
150
|
+
previous = signal.signal(signal.SIGINT, signal.SIG_IGN)
|
|
151
|
+
try:
|
|
152
|
+
return subprocess.run([str(a) for a in argv], cwd=cwd, env=env).returncode
|
|
153
|
+
finally:
|
|
154
|
+
signal.signal(signal.SIGINT, previous)
|
mkit/registry.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""The command registry: one declaration per command, read by every front end.
|
|
2
|
+
|
|
3
|
+
A command declares its name, a one-line summary, its risk class, the features
|
|
4
|
+
it needs, and its arguments. The CLI builds argparse from this, `mkit commands
|
|
5
|
+
--json` and `mkit schema` print it for agents, and a later MCP server will turn
|
|
6
|
+
the same entries into tools. Nothing about a command is described twice.
|
|
7
|
+
|
|
8
|
+
Risk classes, lowest first:
|
|
9
|
+
|
|
10
|
+
read looks, never changes anything anywhere
|
|
11
|
+
plan reads remote state and computes a diff; changes nothing
|
|
12
|
+
write changes local files or git, or a remote thing that is not user
|
|
13
|
+
facing (an internal app-sharing link, symbols)
|
|
14
|
+
publish changes what users, testers or Play review see; always confirmed
|
|
15
|
+
"""
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
|
|
18
|
+
RISKS = ('read', 'plan', 'write', 'publish')
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class Arg:
|
|
23
|
+
name: str
|
|
24
|
+
help: str
|
|
25
|
+
kind: str = 'option' # positional | option | flag
|
|
26
|
+
short: str | None = None
|
|
27
|
+
long: bool = True # False: the short flag is the only spelling
|
|
28
|
+
type: type = str
|
|
29
|
+
choices: tuple | None = None
|
|
30
|
+
default: object = None
|
|
31
|
+
nargs: str | None = None
|
|
32
|
+
repeat: bool = False # flag that may be given more than once
|
|
33
|
+
|
|
34
|
+
def schema(self):
|
|
35
|
+
json_type = {str: 'string', int: 'integer', float: 'number'}.get(self.type, 'string')
|
|
36
|
+
if self.kind == 'flag':
|
|
37
|
+
json_type = 'boolean'
|
|
38
|
+
node = {'type': json_type, 'description': self.help}
|
|
39
|
+
if self.nargs in ('*', '+', '...'):
|
|
40
|
+
node = {'type': 'array', 'items': {'type': json_type}, 'description': self.help}
|
|
41
|
+
if self.choices:
|
|
42
|
+
node['enum'] = list(self.choices)
|
|
43
|
+
if self.default is not None:
|
|
44
|
+
node['default'] = self.default
|
|
45
|
+
return node
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class Command:
|
|
50
|
+
name: str # "doctor", or "listing plan" for a group member
|
|
51
|
+
summary: str
|
|
52
|
+
risk: str
|
|
53
|
+
handler: object
|
|
54
|
+
args: tuple = ()
|
|
55
|
+
needs: tuple = () # features that must be on, e.g. ("play",)
|
|
56
|
+
needs_project: bool = True
|
|
57
|
+
examples: tuple = ()
|
|
58
|
+
description: str = ''
|
|
59
|
+
|
|
60
|
+
def describe(self):
|
|
61
|
+
required = [a.name for a in self.args if a.kind == 'positional' and a.nargs not in ('?', '*', '...')]
|
|
62
|
+
return {
|
|
63
|
+
'name': self.name,
|
|
64
|
+
'summary': self.summary,
|
|
65
|
+
'risk': self.risk,
|
|
66
|
+
'needs': list(self.needs),
|
|
67
|
+
'needs_project': self.needs_project,
|
|
68
|
+
'confirm': self.risk == 'publish',
|
|
69
|
+
'input': {
|
|
70
|
+
'type': 'object',
|
|
71
|
+
'properties': {a.name: a.schema() for a in self.args},
|
|
72
|
+
'required': required,
|
|
73
|
+
},
|
|
74
|
+
'examples': list(self.examples),
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
REGISTRY: dict[str, Command] = {}
|
|
79
|
+
GROUPS: dict[str, str] = {}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def group(name, summary):
|
|
83
|
+
GROUPS[name] = summary
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def command(name, summary, risk, args=(), needs=(), needs_project=True, examples=(),
|
|
87
|
+
description=''):
|
|
88
|
+
if risk not in RISKS:
|
|
89
|
+
raise ValueError(f'{name}: risk must be one of {RISKS}')
|
|
90
|
+
|
|
91
|
+
def wrap(fn):
|
|
92
|
+
if name in REGISTRY:
|
|
93
|
+
raise ValueError(f'command {name!r} registered twice')
|
|
94
|
+
REGISTRY[name] = Command(name=name, summary=summary, risk=risk, handler=fn,
|
|
95
|
+
args=tuple(args), needs=tuple(needs),
|
|
96
|
+
needs_project=needs_project, examples=tuple(examples),
|
|
97
|
+
description=description or (fn.__doc__ or '').strip())
|
|
98
|
+
return fn
|
|
99
|
+
return wrap
|
mkit/safety.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""The confirmation gate in front of every publish-class command.
|
|
2
|
+
|
|
3
|
+
At a terminal, mkit shows what is about to change and asks the person to type
|
|
4
|
+
the app name. Anywhere else (an agent's shell, a script) there is nobody to
|
|
5
|
+
ask, so it refuses with exit 6 unless the caller passes both --yes and
|
|
6
|
+
--confirm <package>. Naming the package is deliberate: it proves the caller
|
|
7
|
+
knows which app it is about to change, so a --yes carried over from another
|
|
8
|
+
project's command cannot publish to the wrong app.
|
|
9
|
+
|
|
10
|
+
--dry-run skips the gate, because a dry run publishes nothing.
|
|
11
|
+
|
|
12
|
+
`safety.no_prompt` (in ~/.mkit/config.toml or mkit.toml) lists commands a
|
|
13
|
+
person at a terminal runs without typing the name: what is about to change is
|
|
14
|
+
still shown, then it goes ahead. It only drops the prompt; off a terminal those
|
|
15
|
+
commands still need --yes --confirm, so an agent never gets through on it.
|
|
16
|
+
"""
|
|
17
|
+
import sys
|
|
18
|
+
|
|
19
|
+
from .errors import NeedsConfirm
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def no_prompt(project):
|
|
23
|
+
"""Commands that skip the typed confirmation at a terminal."""
|
|
24
|
+
value = project.get('safety.no_prompt', []) if project else []
|
|
25
|
+
if isinstance(value, str): # MKIT_SAFETY_NO_PROMPT="deploy,promote"
|
|
26
|
+
value = value.split(',')
|
|
27
|
+
return {str(name).strip() for name in value if str(name).strip()}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def confirm(ctx, action, changes=()):
|
|
31
|
+
"""Return normally when the publish may go ahead; raise NeedsConfirm otherwise."""
|
|
32
|
+
if ctx.dry_run:
|
|
33
|
+
return
|
|
34
|
+
package = ctx.project.package if ctx.project else None
|
|
35
|
+
expected = package or (ctx.project.name if ctx.project else '')
|
|
36
|
+
|
|
37
|
+
if ctx.yes and ctx.confirm:
|
|
38
|
+
if ctx.confirm not in (package, ctx.project.name if ctx.project else None):
|
|
39
|
+
raise NeedsConfirm(f'--confirm {ctx.confirm} does not match this project ({expected})',
|
|
40
|
+
hint=f'Re-run with --confirm {expected} if this is the app you mean.')
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
interactive = not ctx.out.json and sys.stdin.isatty() and sys.stdout.isatty()
|
|
44
|
+
if not interactive:
|
|
45
|
+
raise NeedsConfirm(f'{action} changes {expected} and needs confirmation',
|
|
46
|
+
hint=f'A person must approve this. Re-run with --yes --confirm {expected}, '
|
|
47
|
+
f'or run it at a terminal. Use --dry-run to see what it would do.')
|
|
48
|
+
|
|
49
|
+
out = ctx.out
|
|
50
|
+
out.line()
|
|
51
|
+
out.line(action, 'bold')
|
|
52
|
+
for change in changes:
|
|
53
|
+
out.line(f' {change}')
|
|
54
|
+
out.line()
|
|
55
|
+
if getattr(ctx, 'command', '') in no_prompt(ctx.project):
|
|
56
|
+
out.line(' Going ahead (safety.no_prompt).')
|
|
57
|
+
return
|
|
58
|
+
typed = input(f' Type {ctx.project.name} to go ahead: ').strip()
|
|
59
|
+
if typed.lower() != ctx.project.name.lower():
|
|
60
|
+
raise NeedsConfirm('not confirmed; nothing was changed')
|