@modelstatus/cli 0.1.34 → 0.1.36
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/api.js +6 -0
- package/src/ci.js +2 -2
- package/src/index.js +219 -12
- package/src/integrations.js +121 -0
- package/src/sources/aws-lambda.js +95 -0
- package/src/sources/configscan.js +8 -2
- package/src/sources/filesystem.js +0 -0
- package/src/sources/github-actions.js +156 -0
- package/src/sources/index.js +70 -13
- package/src/sources/scan-process.js +238 -0
- package/src/sources/scan-runner.js +127 -0
- package/src/sources/scan-worker.js +148 -0
- package/src/sources/supabase-edge.js +183 -0
- package/src/sources/supabase.js +5 -0
- package/src/sources/vercel.js +74 -0
- package/src/tui/app.js +45 -2
- package/src/tui/game/DkGame.js +21 -0
- package/src/tui/game/dk-core.js +688 -0
- package/src/tui/game/dk-render.js +160 -0
- package/src/tui/game/input.js +169 -0
- package/src/tui/game/loop.js +337 -0
- package/src/tui/game/term.js +330 -0
- package/src/tui/views/add.js +1 -1
- package/src/tui/views/integrations.js +224 -0
- package/src/tui/views/inventory.js +31 -2
- package/src/tui/views/scan.js +116 -6
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
/* Direct-ANSI terminal control + double-buffered cell renderer for the game.
|
|
2
|
+
* NO Ink, NO React. This is the renderer half of the DECISION: a diff-based
|
|
3
|
+
* cell buffer that emits ONLY changed cells (cursor-move on run-break + sticky
|
|
4
|
+
* SGR fg) so a near-static frame writes ~0 bytes and the screen is NEVER cleared
|
|
5
|
+
* during play — that is what removes the old Ink clearTerminal flicker.
|
|
6
|
+
*
|
|
7
|
+
* The pieces here are deliberately split so they're unit-testable WITHOUT a TTY:
|
|
8
|
+
* - Backbuffer: pure cell store + diff() string builder (front/back typed
|
|
9
|
+
* arrays). diff-minimality is asserted directly in tests.
|
|
10
|
+
* - Term: the IO shell (alt-screen, raw mode, hide cursor, SIGWINCH) with a
|
|
11
|
+
* SINGLE idempotent restore() wired to every exit path — the #1 safety risk.
|
|
12
|
+
*
|
|
13
|
+
* Color: GAME_COLORS are hex strings ("#rrggbb"); we pack to a 24-bit int once
|
|
14
|
+
* and emit truecolor SGR (\x1b[38;2;r;g;bm), sticky so a run of one color emits
|
|
15
|
+
* one SGR. When color is off (NO_COLOR / MM_ASCII / TERM=dumb) we skip ALL SGR
|
|
16
|
+
* and just place glyphs — matching the rest of the TUI's degrade discipline. */
|
|
17
|
+
|
|
18
|
+
// ---- env flags (read once, mirror ui.js / dk-render.js) --------------------
|
|
19
|
+
const ASCII = process.env.MM_ASCII === "1" || process.env.TERM === "dumb";
|
|
20
|
+
export const COLOR_ON = !ASCII && process.env.NO_COLOR == null;
|
|
21
|
+
|
|
22
|
+
// ---- ANSI primitives -------------------------------------------------------
|
|
23
|
+
export const ESC = "\x1b";
|
|
24
|
+
const CSI = ESC + "[";
|
|
25
|
+
export const ALT_ENTER = CSI + "?1049h"; // enter alternate screen buffer
|
|
26
|
+
export const ALT_LEAVE = CSI + "?1049l"; // leave it (restores prior screen)
|
|
27
|
+
export const CURSOR_HIDE = CSI + "?25l";
|
|
28
|
+
export const CURSOR_SHOW = CSI + "?25h";
|
|
29
|
+
export const SGR_RESET = CSI + "0m";
|
|
30
|
+
const CLEAR_SCREEN = CSI + "2J" + CSI + "H";
|
|
31
|
+
|
|
32
|
+
/** 1-based cursor positioning (terminals are 1-indexed). */
|
|
33
|
+
export function cursorTo(x, y) {
|
|
34
|
+
return CSI + (y + 1) + ";" + (x + 1) + "H";
|
|
35
|
+
}
|
|
36
|
+
/** Truecolor foreground SGR for a packed 24-bit RGB int. */
|
|
37
|
+
export function fgSeq(rgb) {
|
|
38
|
+
return CSI + "38;2;" + ((rgb >> 16) & 255) + ";" + ((rgb >> 8) & 255) + ";" + (rgb & 255) + "m";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** "#rrggbb" | "#rgb" | "rrggbb" -> packed 24-bit int. Unparseable -> -1
|
|
42
|
+
* (sentinel = "no color", so the diff path skips SGR for that cell). */
|
|
43
|
+
export function packHex(hex) {
|
|
44
|
+
if (typeof hex !== "string") return -1;
|
|
45
|
+
let h = hex[0] === "#" ? hex.slice(1) : hex;
|
|
46
|
+
if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
|
|
47
|
+
if (h.length !== 6) return -1;
|
|
48
|
+
const n = parseInt(h, 16);
|
|
49
|
+
return Number.isNaN(n) ? -1 : n & 0xffffff;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const SPACE = 32; // char code for a blank cell
|
|
53
|
+
const NO_FG = -1; // packed-fg sentinel for "default / no SGR"
|
|
54
|
+
const DIRTY = -2; // front-buffer sentinel forcing every cell to differ once
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Double-buffered cell grid. Cells are two flat typed arrays:
|
|
58
|
+
* ch : Uint16Array of char codes (BMP glyphs — DK's glyph set is all BMP)
|
|
59
|
+
* fg : Int32Array of packed 24-bit RGB (or NO_FG = -1 = default)
|
|
60
|
+
* `front` is what's on screen; you write the next frame into `back` (via set /
|
|
61
|
+
* clear / setCell or fillFromCells), then diff() returns the minimal escape
|
|
62
|
+
* string and (when applied) swaps the buffers.
|
|
63
|
+
*
|
|
64
|
+
* Pure + TTY-free: construct, write cells, call diff() — assert the string.
|
|
65
|
+
*/
|
|
66
|
+
export class Backbuffer {
|
|
67
|
+
constructor(width, height, { color = COLOR_ON } = {}) {
|
|
68
|
+
this.color = color;
|
|
69
|
+
this.resize(width, height);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
resize(width, height) {
|
|
73
|
+
this.width = Math.max(0, width | 0);
|
|
74
|
+
this.height = Math.max(0, height | 0);
|
|
75
|
+
this.n = this.width * this.height;
|
|
76
|
+
this.frontCh = new Uint16Array(this.n);
|
|
77
|
+
this.frontFg = new Int32Array(this.n);
|
|
78
|
+
this.backCh = new Uint16Array(this.n);
|
|
79
|
+
this.backFg = new Int32Array(this.n);
|
|
80
|
+
this.frontCh.fill(SPACE);
|
|
81
|
+
this.backCh.fill(SPACE);
|
|
82
|
+
this.frontFg.fill(NO_FG);
|
|
83
|
+
this.backFg.fill(NO_FG);
|
|
84
|
+
// Force the very next diff() to be a full paint (front all-dirty).
|
|
85
|
+
this.markAllDirty();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Poison the front buffer so EVERY cell differs from back -> full repaint on
|
|
89
|
+
* the next diff(). Used at game start and after SIGWINCH (size changed). */
|
|
90
|
+
markAllDirty() {
|
|
91
|
+
this.frontCh.fill(0xffff);
|
|
92
|
+
this.frontFg.fill(DIRTY);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Reset the BACK buffer to blank before composing a frame. */
|
|
96
|
+
clearBack(fg = NO_FG) {
|
|
97
|
+
this.backCh.fill(SPACE);
|
|
98
|
+
this.backFg.fill(fg);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Write one cell into the back buffer. Out-of-bounds is a safe no-op so the
|
|
102
|
+
* caller never has to bounds-check (e.g. a HUD string longer than the row). */
|
|
103
|
+
setCell(x, y, chCode, fg = NO_FG) {
|
|
104
|
+
if (x < 0 || y < 0 || x >= this.width || y >= this.height) return;
|
|
105
|
+
const i = y * this.width + x;
|
|
106
|
+
this.backCh[i] = chCode & 0xffff;
|
|
107
|
+
this.backFg[i] = this.color ? fg : NO_FG;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Write a string starting at (x,y), one column per code unit, clipped to the
|
|
111
|
+
* row. fg applies to the whole run. Returns the x after the last glyph. */
|
|
112
|
+
setText(x, y, text, fg = NO_FG) {
|
|
113
|
+
for (let k = 0; k < text.length; k++) {
|
|
114
|
+
this.setCell(x + k, y, text.charCodeAt(k), fg);
|
|
115
|
+
}
|
|
116
|
+
return x + text.length;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Fill a rectangle of the back buffer from a flat cell descriptor produced by
|
|
121
|
+
* dk-render.fillCells: arrays `ch` (char codes) and `fg` (packed RGB) of
|
|
122
|
+
* length w*h, placed with the top-left at (ox, oy). Lets the renderer hand the
|
|
123
|
+
* board straight into the buffer with no per-cell call overhead.
|
|
124
|
+
*/
|
|
125
|
+
blit(cells, ox, oy, w, h) {
|
|
126
|
+
const { ch, fg } = cells;
|
|
127
|
+
for (let cy = 0; cy < h; cy++) {
|
|
128
|
+
const dy = oy + cy;
|
|
129
|
+
if (dy < 0 || dy >= this.height) continue;
|
|
130
|
+
const srcRow = cy * w;
|
|
131
|
+
const dstRow = dy * this.width;
|
|
132
|
+
for (let cx = 0; cx < w; cx++) {
|
|
133
|
+
const dx = ox + cx;
|
|
134
|
+
if (dx < 0 || dx >= this.width) continue;
|
|
135
|
+
const di = dstRow + dx;
|
|
136
|
+
this.backCh[di] = ch[srcRow + cx] & 0xffff;
|
|
137
|
+
this.backFg[di] = this.color ? fg[srcRow + cx] : NO_FG;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Build the MINIMAL escape string that turns the on-screen (front) buffer into
|
|
144
|
+
* the composed (back) buffer:
|
|
145
|
+
* - skip cells that are unchanged (no bytes)
|
|
146
|
+
* - emit a cursor-move ONLY when the cursor isn't already where it needs to
|
|
147
|
+
* be (i.e. only at the start of a changed run / after a skipped cell)
|
|
148
|
+
* - emit an fg SGR ONLY when the color changes (sticky across the frame)
|
|
149
|
+
* - reset SGR once at the very end (and only if any color was emitted)
|
|
150
|
+
* Does NOT mutate buffers; call swap() after writing the result to apply.
|
|
151
|
+
*/
|
|
152
|
+
diff() {
|
|
153
|
+
let out = "";
|
|
154
|
+
let cx = -1, cy = -1; // tracked cursor position (-1 = unknown)
|
|
155
|
+
let curFg = -1; // currently-active SGR fg (-1 = none set)
|
|
156
|
+
let emittedColor = false;
|
|
157
|
+
const { width, n, backCh, backFg, frontCh, frontFg, color } = this;
|
|
158
|
+
for (let i = 0; i < n; i++) {
|
|
159
|
+
if (backCh[i] === frontCh[i] && backFg[i] === frontFg[i]) {
|
|
160
|
+
continue; // unchanged — emit nothing; the next change will reposition
|
|
161
|
+
}
|
|
162
|
+
const y = (i / width) | 0;
|
|
163
|
+
const x = i - y * width;
|
|
164
|
+
if (x !== cx || y !== cy) {
|
|
165
|
+
out += cursorTo(x, y);
|
|
166
|
+
cx = x; cy = y;
|
|
167
|
+
}
|
|
168
|
+
if (color) {
|
|
169
|
+
const fg = backFg[i];
|
|
170
|
+
if (fg !== curFg) {
|
|
171
|
+
if (fg === NO_FG) {
|
|
172
|
+
out += SGR_RESET;
|
|
173
|
+
curFg = NO_FG;
|
|
174
|
+
emittedColor = false; // reset clears the sticky color
|
|
175
|
+
} else {
|
|
176
|
+
out += fgSeq(fg);
|
|
177
|
+
curFg = fg;
|
|
178
|
+
emittedColor = true;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
out += String.fromCharCode(backCh[i]);
|
|
183
|
+
cx += 1; // we advanced one column by printing the glyph
|
|
184
|
+
}
|
|
185
|
+
if (emittedColor) out += SGR_RESET;
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Apply: copy back -> front so the next frame diffs against what's now shown.
|
|
190
|
+
* (Call after writing diff() output to the terminal.) */
|
|
191
|
+
swap() {
|
|
192
|
+
this.frontCh.set(this.backCh);
|
|
193
|
+
this.frontFg.set(this.backFg);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Convenience for headless tests: returns the diff string AND applies it. */
|
|
197
|
+
render() {
|
|
198
|
+
const s = this.diff();
|
|
199
|
+
this.swap();
|
|
200
|
+
return s;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Term: the IO shell that owns alt-screen, raw mode, the hidden cursor, resize,
|
|
206
|
+
* and — most importantly — a SINGLE idempotent restore() registered on every
|
|
207
|
+
* exit path so a crash mid-game can NEVER leave the user in raw mode + alt
|
|
208
|
+
* screen + hidden cursor (the "broken terminal").
|
|
209
|
+
*
|
|
210
|
+
* Injectable streams/hooks (`out`, `inp`, `proc`) make it fully testable: tests
|
|
211
|
+
* pass fakes and assert the exact teardown sequence without touching a TTY.
|
|
212
|
+
*/
|
|
213
|
+
export class Term {
|
|
214
|
+
constructor({
|
|
215
|
+
out = process.stdout,
|
|
216
|
+
inp = process.stdin,
|
|
217
|
+
proc = process,
|
|
218
|
+
color = COLOR_ON,
|
|
219
|
+
} = {}) {
|
|
220
|
+
this.out = out;
|
|
221
|
+
this.inp = inp;
|
|
222
|
+
this.proc = proc;
|
|
223
|
+
this.color = color;
|
|
224
|
+
this.started = false;
|
|
225
|
+
this.restored = false;
|
|
226
|
+
this._wasRaw = false;
|
|
227
|
+
this._signalHandlers = null;
|
|
228
|
+
this._onResize = null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
write(s) {
|
|
232
|
+
if (s) this.out.write(s);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Enter the game's terminal mode: alt screen, hide cursor, raw stdin, and
|
|
236
|
+
* wire bulletproof teardown. Idempotent. */
|
|
237
|
+
start() {
|
|
238
|
+
if (this.started) return;
|
|
239
|
+
this.started = true;
|
|
240
|
+
this.restored = false;
|
|
241
|
+
|
|
242
|
+
// Enter alt screen + hide cursor ONCE (never per frame — that's the anti-
|
|
243
|
+
// flicker invariant). Clear the alt buffer so a stale prior frame can't show
|
|
244
|
+
// through before the first diff paints.
|
|
245
|
+
this.write(ALT_ENTER + CURSOR_HIDE + CLEAR_SCREEN);
|
|
246
|
+
|
|
247
|
+
// Raw mode so we get bytes immediately (no line buffering / no echo). Save
|
|
248
|
+
// the prior state so restore() returns the terminal exactly as it was.
|
|
249
|
+
const inp = this.inp;
|
|
250
|
+
if (inp && typeof inp.setRawMode === "function" && inp.isTTY) {
|
|
251
|
+
this._wasRaw = !!inp.isRaw;
|
|
252
|
+
inp.setRawMode(true);
|
|
253
|
+
}
|
|
254
|
+
if (inp && typeof inp.resume === "function") inp.resume();
|
|
255
|
+
|
|
256
|
+
// Bulletproof teardown: a single restore() on EVERY way out.
|
|
257
|
+
const restore = () => this.restore();
|
|
258
|
+
const handlers = {
|
|
259
|
+
exit: restore,
|
|
260
|
+
SIGINT: () => { this.restore(); this.proc.exit(130); },
|
|
261
|
+
SIGTERM: () => { this.restore(); this.proc.exit(143); },
|
|
262
|
+
SIGHUP: () => { this.restore(); this.proc.exit(129); },
|
|
263
|
+
uncaughtException: (err) => {
|
|
264
|
+
this.restore();
|
|
265
|
+
// Surface the error on the now-restored terminal, then exit non-zero.
|
|
266
|
+
try { (this.proc.stderr || process.stderr).write(String(err && err.stack || err) + "\n"); } catch {}
|
|
267
|
+
this.proc.exit(1);
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
this._signalHandlers = handlers;
|
|
271
|
+
for (const [evt, fn] of Object.entries(handlers)) this.proc.on(evt, fn);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Register a resize callback. On SIGWINCH we recompute nothing here (the loop
|
|
275
|
+
* owns board math) — we just notify; the loop reallocs + forces a full repaint
|
|
276
|
+
* via Backbuffer.markAllDirty(). Debounced is the loop's concern; we pass the
|
|
277
|
+
* raw signal. */
|
|
278
|
+
onResize(cb) {
|
|
279
|
+
this._onResize = cb;
|
|
280
|
+
const handler = () => { if (this._onResize) this._onResize(this.size()); };
|
|
281
|
+
this._resizeHandler = handler;
|
|
282
|
+
this.proc.on("SIGWINCH", handler);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
size() {
|
|
286
|
+
return {
|
|
287
|
+
width: (this.out && this.out.columns) || 80,
|
|
288
|
+
height: (this.out && this.out.rows) || 24,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* IDEMPOTENT restore: leave alt screen, show cursor, reset SGR, drop raw mode
|
|
294
|
+
* back to its prior state, and unhook every signal handler. Safe to call
|
|
295
|
+
* twice (double-restore is a no-op) — which it WILL be (try/finally in the loop
|
|
296
|
+
* plus the exit handler both call it).
|
|
297
|
+
*/
|
|
298
|
+
restore() {
|
|
299
|
+
if (this.restored) return;
|
|
300
|
+
this.restored = true;
|
|
301
|
+
|
|
302
|
+
// Order matters: reset SGR + show cursor + leave alt LAST so the chrome
|
|
303
|
+
// lands back on the normal buffer with a visible cooked cursor.
|
|
304
|
+
try { this.write(SGR_RESET + CURSOR_SHOW + ALT_LEAVE); } catch {}
|
|
305
|
+
|
|
306
|
+
const inp = this.inp;
|
|
307
|
+
try {
|
|
308
|
+
if (inp && typeof inp.setRawMode === "function" && inp.isTTY) {
|
|
309
|
+
inp.setRawMode(this._wasRaw);
|
|
310
|
+
}
|
|
311
|
+
} catch {}
|
|
312
|
+
// Don't pause stdin here — the caller (TUI remount) may want to keep reading.
|
|
313
|
+
|
|
314
|
+
// Unhook our signal/exit handlers so a subsequent normal exit doesn't double-
|
|
315
|
+
// fire and so the process can exit cleanly when nothing else is listening.
|
|
316
|
+
if (this._signalHandlers) {
|
|
317
|
+
for (const [evt, fn] of Object.entries(this._signalHandlers)) {
|
|
318
|
+
try { this.proc.removeListener(evt, fn); } catch {}
|
|
319
|
+
}
|
|
320
|
+
this._signalHandlers = null;
|
|
321
|
+
}
|
|
322
|
+
if (this._resizeHandler) {
|
|
323
|
+
try { this.proc.removeListener("SIGWINCH", this._resizeHandler); } catch {}
|
|
324
|
+
this._resizeHandler = null;
|
|
325
|
+
}
|
|
326
|
+
this.started = false;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export const _internals = { SPACE, NO_FG, DIRTY, CLEAR_SCREEN };
|
package/src/tui/views/add.js
CHANGED
|
@@ -2,7 +2,7 @@ import React from "react";
|
|
|
2
2
|
import { Box, Text, useInput } from "ink";
|
|
3
3
|
import { h, C, GLYPH, useAsync, clampCursor } from "../ui.js";
|
|
4
4
|
|
|
5
|
-
const ENV_ORDER = ["prod", "staging", "dev", "
|
|
5
|
+
const ENV_ORDER = ["prod", "staging", "dev", "unknown"];
|
|
6
6
|
|
|
7
7
|
export const meta = {
|
|
8
8
|
keys: [
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Box, Text, useInput } from "ink";
|
|
3
|
+
import {
|
|
4
|
+
h, C, GLYPH, ListRow, EmptyCard, envTag as envTagSeg,
|
|
5
|
+
cell, cellE, SPINNER, useTick, useAsync, clampCursor,
|
|
6
|
+
} from "../ui.js";
|
|
7
|
+
import { getSource } from "../../sources/index.js";
|
|
8
|
+
import {
|
|
9
|
+
INTEGRATION_IDS, INTEGRATION_META, readIntegrations,
|
|
10
|
+
setEnabled, setEnvTag, getEnvTag,
|
|
11
|
+
} from "../../integrations.js";
|
|
12
|
+
|
|
13
|
+
const ENV_TAGS = ["prod", "staging", "dev", "unknown"];
|
|
14
|
+
|
|
15
|
+
// kind → accent pill color (mirrors the channel KIND_COLOR taste in alerts.js).
|
|
16
|
+
const KIND_COLOR = {
|
|
17
|
+
"aws-lambda": "#ff9900", // AWS orange
|
|
18
|
+
vercel: C.FG_STRONG,
|
|
19
|
+
"supabase-edge": "#3ecf8e", // Supabase green
|
|
20
|
+
"github-actions": "#a78bfa",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** A compact provider pill text (kept ≤ the 14-col column). */
|
|
24
|
+
const PILL = {
|
|
25
|
+
"aws-lambda": "aws",
|
|
26
|
+
vercel: "vercel",
|
|
27
|
+
"supabase-edge": "supabase",
|
|
28
|
+
"github-actions": "github",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Integrations view: a single fixed list of the 4 LIVE integration sources, with
|
|
33
|
+
* a per-source enabled toggle (the authoritative local switch that drives what
|
|
34
|
+
* `mm scan` runs by default) + a declared env label. Authorization is NOT stored
|
|
35
|
+
* here — it's the user's already-authenticated local CLI (aws/gh/vercel/supabase);
|
|
36
|
+
* `t` runs a one-off read-only identity probe and toasts the result.
|
|
37
|
+
*
|
|
38
|
+
* needsAuth:false in app.js — the toggle is local-first and scanning is free; the
|
|
39
|
+
* web row (best-effort PATCH) is only for cross-device visibility + plan display.
|
|
40
|
+
* No idle timers / setState-on-tick (the spinner ticks ONLY while a probe runs).
|
|
41
|
+
*/
|
|
42
|
+
export function IntegrationsView({ client, me, ui, active, width = 78, height = 14 }) {
|
|
43
|
+
const ROWS = Math.max(3, height - 3); // hint line + a little overhead
|
|
44
|
+
const [cursor, setCursor] = React.useState(0);
|
|
45
|
+
// Local toggle state, re-read from integrations.json. `gen` bumps to force a
|
|
46
|
+
// re-read after a write (the file is the source of truth, not React state).
|
|
47
|
+
const [gen, setGen] = React.useState(0);
|
|
48
|
+
const local = React.useMemo(() => readIntegrations(), [gen]);
|
|
49
|
+
// Probing state: which id is mid-probe (drives the spinner) + last results.
|
|
50
|
+
const [probing, setProbing] = React.useState(null);
|
|
51
|
+
const [probed, setProbed] = React.useState({}); // id -> { connected, account?, reason? }
|
|
52
|
+
const tick = useTick(80, probing != null);
|
|
53
|
+
|
|
54
|
+
// Availability is the cheap PATH check (no spawn) per source.
|
|
55
|
+
const avail = useAsync(
|
|
56
|
+
async () => {
|
|
57
|
+
const out = {};
|
|
58
|
+
for (const id of INTEGRATION_IDS) {
|
|
59
|
+
const src = getSource(id);
|
|
60
|
+
out[id] = src ? await src.available({}) : false;
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
},
|
|
64
|
+
[gen],
|
|
65
|
+
);
|
|
66
|
+
const availMap = avail.data || {};
|
|
67
|
+
|
|
68
|
+
const rows = INTEGRATION_IDS.map((id) => ({
|
|
69
|
+
id,
|
|
70
|
+
meta: INTEGRATION_META[id],
|
|
71
|
+
enabled: !!local[id]?.enabled,
|
|
72
|
+
env: getEnvTag(id) || "unknown",
|
|
73
|
+
hasCmd: !!availMap[id],
|
|
74
|
+
probe: probed[id] || null,
|
|
75
|
+
}));
|
|
76
|
+
const cur = rows[clampCursor(cursor, rows.length)] || null;
|
|
77
|
+
const enabledCount = rows.filter((r) => r.enabled).length;
|
|
78
|
+
|
|
79
|
+
React.useEffect(
|
|
80
|
+
() => ui?.reportStatus?.({ context: `${enabledCount}/${rows.length} on` }),
|
|
81
|
+
[enabledCount, rows.length, ui],
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
function refresh() {
|
|
85
|
+
setProbed({});
|
|
86
|
+
setGen((g) => g + 1);
|
|
87
|
+
avail.reload();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function toggle(r) {
|
|
91
|
+
const next = !r.enabled;
|
|
92
|
+
setEnabled(r.id, next); // local write is authoritative + instant
|
|
93
|
+
setGen((g) => g + 1);
|
|
94
|
+
ui.showToast(`${r.id} ${next ? "enabled" : "disabled"}`);
|
|
95
|
+
// Best-effort cloud mirror (cross-device + plan-gating). The local toggle
|
|
96
|
+
// already applied; a 402/anything else just toasts and leaves it local.
|
|
97
|
+
if (me) {
|
|
98
|
+
client
|
|
99
|
+
.setIntegration({ action: next ? "enable" : "disable", kind: r.id, env: r.env })
|
|
100
|
+
.catch((e) => {
|
|
101
|
+
if (e.status === 402) ui.showToast("Live integrations sync is a Pro feature (toggle still applies locally)", "yellow");
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function setEnv(r) {
|
|
107
|
+
ui.askPrompt(`Env for ${r.id} (${ENV_TAGS.join("/")})`, {
|
|
108
|
+
initial: r.env,
|
|
109
|
+
onSubmit: (val) => {
|
|
110
|
+
const tag = (val || "").trim().toLowerCase();
|
|
111
|
+
if (!ENV_TAGS.includes(tag)) return ui.showToast("env must be prod/staging/dev/unknown", "red");
|
|
112
|
+
try {
|
|
113
|
+
setEnvTag(r.id, tag); // "unknown" clears the override (guessEnvFrom resumes)
|
|
114
|
+
setGen((g) => g + 1);
|
|
115
|
+
ui.showToast(tag === "unknown" ? `${r.id} env override cleared` : `${r.id} env → ${tag}`);
|
|
116
|
+
if (me) client.setIntegration({ action: r.enabled ? "enable" : "disable", kind: r.id, env: tag }).catch(() => {});
|
|
117
|
+
} catch (e) {
|
|
118
|
+
ui.showToast(e.message, "red");
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function testAuth(r) {
|
|
125
|
+
if (!r.hasCmd) return ui.showToast(`${r.meta.requiresCmd} not installed`, "red");
|
|
126
|
+
const src = getSource(r.id);
|
|
127
|
+
if (!src || typeof src.authState !== "function") return ui.showToast("no auth probe for this source", "yellow");
|
|
128
|
+
setProbing(r.id);
|
|
129
|
+
try {
|
|
130
|
+
const st = await src.authState({ region: local[r.id]?.ref });
|
|
131
|
+
setProbed((p) => ({ ...p, [r.id]: st }));
|
|
132
|
+
if (st.connected) ui.showToast(`${r.id} authorized${st.account ? ` · ${st.account}` : ""}`, "#16a34a");
|
|
133
|
+
else ui.showToast(`${r.id}: ${st.reason || "not authorized"}`, "red");
|
|
134
|
+
} catch (e) {
|
|
135
|
+
setProbed((p) => ({ ...p, [r.id]: { connected: false, reason: e.message } }));
|
|
136
|
+
ui.showToast(e.message, "red");
|
|
137
|
+
} finally {
|
|
138
|
+
setProbing(null);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
useInput(
|
|
143
|
+
(input, key) => {
|
|
144
|
+
if (!active) return;
|
|
145
|
+
if (key.downArrow || input === "j") return setCursor((c) => clampCursor(c + 1, rows.length));
|
|
146
|
+
if (key.upArrow || input === "k") return setCursor((c) => clampCursor(c - 1, rows.length));
|
|
147
|
+
if (input === "g") return refresh();
|
|
148
|
+
if (!cur) return;
|
|
149
|
+
if (input === " " || key.return) return toggle(cur);
|
|
150
|
+
if (input === "e") return setEnv(cur);
|
|
151
|
+
if (input === "t") return testAuth(cur);
|
|
152
|
+
},
|
|
153
|
+
{ isActive: active },
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
// Column budget: rail(1) + toggle(2) + label + gap(1) + kind(10) + status(rest) + env(8).
|
|
157
|
+
const ENV_W = 8;
|
|
158
|
+
const KIND_W = 10;
|
|
159
|
+
const LABEL_W = 30;
|
|
160
|
+
const restW = Math.max(8, width - 1 - 2 - LABEL_W - 1 - KIND_W - ENV_W);
|
|
161
|
+
|
|
162
|
+
// Status string per row: not-installed › off/available › on › on·authorized / auth failed.
|
|
163
|
+
function statusFor(r) {
|
|
164
|
+
if (!r.hasCmd) return { text: `${r.meta.requiresCmd} not installed`, color: C.FG_FAINT };
|
|
165
|
+
if (r.probe && !r.probe.connected) return { text: "auth failed", color: "#dc2626" };
|
|
166
|
+
if (r.probe && r.probe.connected) {
|
|
167
|
+
const who = r.probe.account ? ` · ${r.probe.account}` : "";
|
|
168
|
+
return { text: `authorized${who}`, color: "#16a34a" };
|
|
169
|
+
}
|
|
170
|
+
if (r.enabled) return { text: "on", color: C.ACCENT };
|
|
171
|
+
return { text: "available · off", color: C.FG_DIM };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const curIdx = clampCursor(cursor, rows.length);
|
|
175
|
+
let body;
|
|
176
|
+
if (avail.loading) {
|
|
177
|
+
body = h(Text, {}, h(Text, { color: C.ACCENT }, ` ${SPINNER[tick % SPINNER.length]} `), h(Text, { color: C.FG_DIM }, "checking installed CLIs…"));
|
|
178
|
+
} else if (!rows.length) {
|
|
179
|
+
body = h(EmptyCard, {
|
|
180
|
+
icon: GLYPH.spark,
|
|
181
|
+
title: "Connect your live deployments",
|
|
182
|
+
lines: ["Scan AWS Lambda, Vercel, Supabase Edge and GitHub Actions for the models you actually ship.", "Toggle one on with space — it then scans by default. Authorization is your own CLI; we never store a token."],
|
|
183
|
+
width,
|
|
184
|
+
});
|
|
185
|
+
} else {
|
|
186
|
+
body = h(
|
|
187
|
+
Box,
|
|
188
|
+
{ flexDirection: "column" },
|
|
189
|
+
...rows.slice(0, ROWS).map((r, i) => {
|
|
190
|
+
const isCur = i === curIdx;
|
|
191
|
+
const st = statusFor(r);
|
|
192
|
+
const spin = probing === r.id ? `${SPINNER[tick % SPINNER.length]} ` : "";
|
|
193
|
+
const et = envTagSeg(r.env);
|
|
194
|
+
const cells = [
|
|
195
|
+
{ text: `${r.enabled ? GLYPH.check : GLYPH.dot} `, color: r.enabled ? C.ACCENT : C.FG_FAINT },
|
|
196
|
+
{ text: cellE(r.meta.label, LABEL_W), color: isCur ? C.FG_STRONG : r.enabled ? C.FG : C.FG_FAINT, bold: isCur },
|
|
197
|
+
{ text: " ", color: C.FG },
|
|
198
|
+
{ text: cell(PILL[r.id] || "", KIND_W), color: KIND_COLOR[r.id] || C.FG_DIM, bold: true },
|
|
199
|
+
{ text: cellE(spin + st.text, restW), color: st.color },
|
|
200
|
+
{ text: cell(et.text, ENV_W), color: et.color },
|
|
201
|
+
];
|
|
202
|
+
return h(ListRow, { key: r.id, active: isCur, cells, width });
|
|
203
|
+
}),
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const hint = " Toggle which live deployments scan by default · space on/off · t authorize · e env. Secrets stay on your machine.";
|
|
208
|
+
return h(
|
|
209
|
+
Box,
|
|
210
|
+
{ flexDirection: "column" },
|
|
211
|
+
h(Text, { color: C.FG_FAINT }, cellE(hint, width)),
|
|
212
|
+
body,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export const meta = {
|
|
217
|
+
keys: [
|
|
218
|
+
{ k: "↑↓", label: "nav" },
|
|
219
|
+
{ k: "space", label: "toggle" },
|
|
220
|
+
{ k: "e", label: "env" },
|
|
221
|
+
{ k: "t", label: "test" },
|
|
222
|
+
{ k: "g", label: "refresh" },
|
|
223
|
+
],
|
|
224
|
+
};
|
|
@@ -11,12 +11,13 @@ import {
|
|
|
11
11
|
} from "../ui.js";
|
|
12
12
|
import { readSnippet, findSourceFile } from "../snippet.js";
|
|
13
13
|
|
|
14
|
-
const ENV_ORDER = ["prod", "staging", "dev", "
|
|
14
|
+
const ENV_ORDER = ["prod", "staging", "dev", "unknown"];
|
|
15
15
|
|
|
16
16
|
export const meta = {
|
|
17
17
|
keys: [
|
|
18
18
|
{ k: "↑↓", label: "nav" },
|
|
19
19
|
{ k: "e", label: "env" },
|
|
20
|
+
{ k: "t", label: "tag untagged" },
|
|
20
21
|
{ k: "c", label: "critical" },
|
|
21
22
|
{ k: "d", label: "delete" },
|
|
22
23
|
{ k: "C", label: "clear all" },
|
|
@@ -26,6 +27,10 @@ export const meta = {
|
|
|
26
27
|
],
|
|
27
28
|
};
|
|
28
29
|
|
|
30
|
+
// A row whose env is genuinely unknown — disk had no signal and nobody tagged it.
|
|
31
|
+
// Legacy null/empty + the old "other" bucket count as untagged too.
|
|
32
|
+
const isUntagged = (u) => !u.environment || u.environment === "unknown" || u.environment === "other";
|
|
33
|
+
|
|
29
34
|
export function InventoryView({ client, ui, dir = ".", active, width = 78, height = 14 }) {
|
|
30
35
|
const q = useAsync(async () => {
|
|
31
36
|
const [u, p] = await Promise.all([client.listUsages(), client.listProjects()]);
|
|
@@ -82,6 +87,27 @@ export function InventoryView({ client, ui, dir = ".", active, width = 78, heigh
|
|
|
82
87
|
if (input === "g") return q.reload();
|
|
83
88
|
if (input === "r") return ui.switchTo("scan");
|
|
84
89
|
if (input === "n") return ui.switchTo("add");
|
|
90
|
+
if (input === "t") {
|
|
91
|
+
const untagged = usages.filter(isUntagged);
|
|
92
|
+
if (!untagged.length) return ui.showToast("nothing untagged");
|
|
93
|
+
return ui.askPrompt(`Tag ${untagged.length} untagged → type prod / staging / dev`, {
|
|
94
|
+
onSubmit: async (v) => {
|
|
95
|
+
const env = String(v || "").trim().toLowerCase();
|
|
96
|
+
if (!["prod", "staging", "dev"].includes(env)) return ui.showToast("tag cancelled");
|
|
97
|
+
let n = 0;
|
|
98
|
+
for (const u of untagged) {
|
|
99
|
+
try {
|
|
100
|
+
await client.patchUsage(u.id, { environment: env });
|
|
101
|
+
n += 1;
|
|
102
|
+
} catch {
|
|
103
|
+
/* skip the row that failed; report the rest */
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
ui.showToast(`${GLYPH.check} tagged ${n} → ${env}`);
|
|
107
|
+
q.reload();
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
}
|
|
85
111
|
if (input === "C" && usages.length) {
|
|
86
112
|
return ui.askPrompt(`Type "clear" to delete ALL ${usages.length} usages`, {
|
|
87
113
|
onSubmit: (v) => {
|
|
@@ -98,7 +124,10 @@ export function InventoryView({ client, ui, dir = ".", active, width = 78, heigh
|
|
|
98
124
|
});
|
|
99
125
|
}
|
|
100
126
|
if (!cur) return;
|
|
101
|
-
if (input === "e")
|
|
127
|
+
if (input === "e") {
|
|
128
|
+
const i = ENV_ORDER.indexOf(cur.environment);
|
|
129
|
+
return patch(cur, { environment: ENV_ORDER[(i + 1) % ENV_ORDER.length] }, "env updated");
|
|
130
|
+
}
|
|
102
131
|
if (input === "c") return patch(cur, { is_critical: !cur.is_critical }, "critical toggled");
|
|
103
132
|
if (input === "d")
|
|
104
133
|
return client
|