javer-cli 1.0.0

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/lib/api.js ADDED
@@ -0,0 +1,79 @@
1
+ // Thin fetch wrapper. Node 18+ has global fetch, so this package has zero
2
+ // dependencies — nothing to audit, nothing to keep patched, and `npm i -g`
3
+ // stays instant.
4
+ const config = require('./config');
5
+ const { version } = require('../package.json');
6
+
7
+ class ApiError extends Error {
8
+ constructor(message, status, body) {
9
+ super(message);
10
+ this.status = status;
11
+ this.body = body;
12
+ }
13
+ }
14
+
15
+ const request = async (method, route, body) => {
16
+ const key = config.apiKey();
17
+ if (!key) throw new ApiError('Not logged in. Run `javer login` first.', 401);
18
+
19
+ const res = await fetch(`${config.apiUrl()}/api${route}`, {
20
+ method,
21
+ headers: {
22
+ Authorization: `Bearer ${key}`,
23
+ 'User-Agent': `javer-cli/${version}`,
24
+ ...(body ? { 'Content-Type': 'application/json' } : {})
25
+ },
26
+ ...(body ? { body: JSON.stringify(body) } : {})
27
+ });
28
+
29
+ const text = await res.text();
30
+ let data;
31
+ try { data = text ? JSON.parse(text) : {}; } catch { data = { raw: text }; }
32
+
33
+ // fetch() only rejects on network failure — an HTTP 401/500 resolves
34
+ // normally. Without this check every error would look like a success with
35
+ // odd-shaped data.
36
+ if (!res.ok) {
37
+ throw new ApiError(data.message || `Request failed (HTTP ${res.status})`, res.status, data);
38
+ }
39
+ return data;
40
+ };
41
+
42
+ // A file upload, for deploying the folder you are standing in. The deploy
43
+ // endpoints take multipart with a field called `appfile`, so this cannot go
44
+ // through request() — that one always sends JSON.
45
+ //
46
+ // FormData and Blob are built into Node 18 and later, which package.json
47
+ // already requires, so there is still nothing to install. Letting fetch build
48
+ // the body also means the multipart boundary is its problem, not ours.
49
+ const upload = async (route, filename, buffer, fields = {}) => {
50
+ const key = config.apiKey();
51
+ if (!key) throw new ApiError('Not logged in. Run `javer login` first.', 401);
52
+
53
+ const form = new FormData();
54
+ for (const [k, v] of Object.entries(fields)) form.append(k, String(v));
55
+ form.append('appfile', new Blob([buffer]), filename);
56
+
57
+ const res = await fetch(`${config.apiUrl()}/api${route}`, {
58
+ method: 'POST',
59
+ // Deliberately no Content-Type: fetch sets it, with the boundary. Setting
60
+ // it by hand here is the classic way to get an unparseable request.
61
+ headers: { Authorization: `Bearer ${key}`, 'User-Agent': `javer-cli/${version}` },
62
+ body: form
63
+ });
64
+
65
+ const text = await res.text();
66
+ let data;
67
+ try { data = text ? JSON.parse(text) : {}; } catch { data = { raw: text }; }
68
+ if (!res.ok) throw new ApiError(data.message || `Upload failed (HTTP ${res.status})`, res.status, data);
69
+ return data;
70
+ };
71
+
72
+ module.exports = {
73
+ ApiError,
74
+ get: (route) => request('GET', route),
75
+ post: (route, body) => request('POST', route, body),
76
+ put: (route, body) => request('PUT', route, body),
77
+ del: (route) => request('DELETE', route),
78
+ upload
79
+ };
package/lib/bird.js ADDED
@@ -0,0 +1,129 @@
1
+ /* JAVER-SIGNATURE-START
2
+ JAV
3
+ E RJ
4
+ A o VE
5
+ RJA VERJ
6
+ A V ERJA
7
+ V ER JA
8
+ V ER JA
9
+ VE RJA V
10
+ ER JAVE
11
+ RJ AVE
12
+ RJAVERJAV
13
+ E RJ
14
+ AVERJ AV
15
+ javer.pro — built in-house, not outsourced. signed: budgie
16
+ JAVER-SIGNATURE-END */
17
+
18
+ // The Javer bird, for the terminal.
19
+ //
20
+ // This is not a drawing of a bird. It is THE bird, read straight out of
21
+ // public/javerbirb.jpg. Hand-drawing it from memory produces a centipede, or a
22
+ // shoe; both were tried.
23
+ //
24
+ // The grid is 15x15 and its cells are NOT square: 78px across, 67.5px down.
25
+ // That non-square cell is what made the first three attempts wrong. Guessing a
26
+ // single cell size from the horizontal runs gave 15x13, which sampled thirteen
27
+ // rows out of an image that has fifteen — every row after the head landed
28
+ // slightly off, two rows vanished entirely, and the eye came out 77px wide but
29
+ // 67px tall, which was the clue sitting in plain sight the whole time.
30
+ //
31
+ // It was settled by run-length encoding each row's pattern across the whole
32
+ // image: rows inside one logical cell are identical, so the bands between
33
+ // changes ARE the cell rows. No guessing, no rounding.
34
+ //
35
+ // G = body green, L = the lighter mint highlight on the crest, W = the white
36
+ // eye, . = nothing. The highlight is a single cell and it is the only shading
37
+ // in the whole logo, so rendering it as ordinary green is immediately visible
38
+ // as a missing pixel — which is exactly how it was spotted.
39
+ const GRID = [
40
+ '...GGG.........',
41
+ '..L..GG........',
42
+ '.G.W.GG........',
43
+ 'GGG..GGG.......',
44
+ 'GG.GG.GGG......',
45
+ 'G...G..GGG.....',
46
+ '....G..G.GG....',
47
+ '....G..G.GG....',
48
+ '....GG.GG.G....',
49
+ '....GG..GGGG...',
50
+ '.....GG..GGG...',
51
+ '.....GGGGGGGG..',
52
+ '......G....GG..',
53
+ '.....GGGG...GG.',
54
+ '.............GG'
55
+ ];
56
+
57
+ // Sampled from the logo itself rather than guessed.
58
+ const GREEN = [11, 165, 37]; // body, measured off the logo
59
+ const LIGHT = [37, 167, 75]; // the crest highlight: more red and blue, so it reads brighter
60
+ const WHITE = [255, 255, 255]; // the eye
61
+ const RESET = '\x1b[0m';
62
+ const fg = (c) => `\x1b[38;2;${c[0]};${c[1]};${c[2]}m`;
63
+ const bg = (c) => `\x1b[48;2;${c[0]};${c[1]};${c[2]}m`;
64
+ const colourOf = (ch) => (ch === 'G' ? GREEN : ch === 'L' ? LIGHT : ch === 'W' ? WHITE : null);
65
+
66
+ // Half-height blocks: two pixel rows per text line, so thirteen rows become
67
+ // seven lines. This is the trick that makes terminal mascots look smooth —
68
+ // one character cell carries a top pixel and a bottom pixel, drawn as a
69
+ // foreground and a background colour.
70
+ //
71
+ // The catch is real: if a terminal adds line spacing, the halves separate and
72
+ // the shape tears. `javer bird` prints this next to the blocky version so you
73
+ // can see which one your own terminal renders properly.
74
+ const half = () => {
75
+ const out = [];
76
+ for (let y = 0; y < GRID.length; y += 2) {
77
+ const top = GRID[y];
78
+ const bot = GRID[y + 1] || '.'.repeat(top.length);
79
+ let line = '';
80
+ for (let x = 0; x < top.length; x++) {
81
+ const t = colourOf(top[x]);
82
+ const b = colourOf(bot[x]);
83
+ if (t && b) line += fg(t) + bg(b) + '▀' + RESET;
84
+ else if (t) line += fg(t) + '▀' + RESET;
85
+ else if (b) line += fg(b) + '▄' + RESET;
86
+ else line += ' ';
87
+ }
88
+ out.push(line);
89
+ }
90
+ return out;
91
+ };
92
+
93
+ // One pixel per two characters. Taller, but every cell is a whole character —
94
+ // nothing can tear, and the proportions are true squares because a terminal
95
+ // cell is about half as wide as it is tall.
96
+ const full = () => GRID.map((row) => {
97
+ let line = '';
98
+ for (const ch of row) {
99
+ const c = colourOf(ch);
100
+ line += c ? fg(c) + '██' + RESET : ' ';
101
+ }
102
+ return line;
103
+ });
104
+
105
+ // The JAVER wordmark in block letters, for the empty band the stats leave
106
+ // between themselves and the bird. Five rows, drawn on the same grid idea as
107
+ // the logo so the header reads as one piece rather than three ornaments.
108
+ const WORDMARK = [
109
+ ' # # # # ### ## ',
110
+ ' # # # # # # # #',
111
+ ' # ### # # ## ## ',
112
+ '# # # # # # # # #',
113
+ ' ## # # # ### # #'
114
+ ];
115
+
116
+ // TWO characters per pixel. A terminal cell is about half as wide as it is
117
+ // tall, so a letter five rows high needs roughly six columns to look correctly
118
+ // proportioned — at one character per pixel these came out three wide and ten
119
+ // tall, which reads as squashed from the sides.
120
+ const wordmark = () => WORDMARK.map((row) => {
121
+ let line = '';
122
+ for (const ch of row) line += ch === '#' ? fg(GREEN) + '██' + RESET : ' ';
123
+ return line;
124
+ });
125
+
126
+ // Width in visible columns, for laying the bird out beside other things.
127
+ const widthOf = (lines) => Math.max(...lines.map((l) => l.replace(/\x1b\[[0-9;]*m/g, '').length));
128
+
129
+ module.exports = { GRID, GREEN, LIGHT, half, full, wordmark, widthOf };
package/lib/config.js ADDED
@@ -0,0 +1,43 @@
1
+ // Credential storage for the CLI. The API key is a long-lived, non-expiring
2
+ // credential with the same power as a browser session, so it lives in a
3
+ // 0600 file inside a 0700 directory — never in shell history, never in an
4
+ // env var that leaks into `ps` output or a CI log.
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+ const path = require('path');
8
+
9
+ const DIR = path.join(os.homedir(), '.javer');
10
+ const FILE = path.join(DIR, 'config.json');
11
+ const DEFAULT_URL = 'https://panel.javer.pro';
12
+
13
+ const read = () => {
14
+ try {
15
+ return JSON.parse(fs.readFileSync(FILE, 'utf-8'));
16
+ } catch {
17
+ return {};
18
+ }
19
+ };
20
+
21
+ const write = (cfg) => {
22
+ fs.mkdirSync(DIR, { recursive: true, mode: 0o700 });
23
+ // Write then chmod: writeFileSync's mode argument only applies when the
24
+ // file is created, so an existing file with looser permissions would
25
+ // silently keep them.
26
+ fs.writeFileSync(FILE, JSON.stringify(cfg, null, 2));
27
+ fs.chmodSync(FILE, 0o600);
28
+ };
29
+
30
+ const clear = () => {
31
+ try { fs.unlinkSync(FILE); return true; } catch { return false; }
32
+ };
33
+
34
+ // JAVER_API_URL exists so the CLI can be pointed at a local dev server
35
+ // without touching the saved production config — the same override the
36
+ // server's own test harnesses use.
37
+ const apiUrl = () => process.env.JAVER_API_URL || read().apiUrl || DEFAULT_URL;
38
+
39
+ // JAVER_API_KEY takes precedence over the saved file so CI can supply a key
40
+ // as a secret without running `javer login` first.
41
+ const apiKey = () => process.env.JAVER_API_KEY || read().key || null;
42
+
43
+ module.exports = { read, write, clear, apiUrl, apiKey, FILE, DEFAULT_URL };
package/lib/ui.js ADDED
@@ -0,0 +1,366 @@
1
+ /* JAVER-SIGNATURE-START
2
+ JAV
3
+ E RJ
4
+ A o VE
5
+ RJA VERJ
6
+ A V ERJA
7
+ V ER JA
8
+ V ER JA
9
+ VE RJA V
10
+ ER JAVE
11
+ RJ AVE
12
+ RJAVERJAV
13
+ E RJ
14
+ AVERJ AV
15
+ javer.pro — built in-house, not outsourced. signed: sky
16
+ JAVER-SIGNATURE-END */
17
+
18
+ // `javer ui` — a live view of everything you are running, in the terminal.
19
+ //
20
+ // Apps AND VMs in one place, which the panel itself does not do: there, they
21
+ // are two separate pages, and the resource budget they share is only visible on
22
+ // a third. Here the budget is the header and both kinds sit under it, because
23
+ // what people actually want to know is "what have I got, and how much is left".
24
+ //
25
+ // No dependencies. That is a deliberate constraint for a globally-installed
26
+ // tool, so this is plain ANSI: an alternate screen buffer, cursor moves and
27
+ // box-drawing characters. Everything one frame needs is built as a single
28
+ // string and written in one go — writing piece by piece is what makes a
29
+ // terminal UI flicker.
30
+ const api = require('./api');
31
+ const bird = require('./bird');
32
+ const config = require('./config');
33
+
34
+ const ESC = '\x1b[';
35
+ const alt = (on) => (on ? `${ESC}?1049h` : `${ESC}?1049l`);
36
+ const cursor = (on) => (on ? `${ESC}?25h` : `${ESC}?25l`);
37
+ const home = `${ESC}H`;
38
+ const clear = `${ESC}2J`;
39
+
40
+ const C = {
41
+ reset: `${ESC}0m`, dim: `${ESC}2m`, bold: `${ESC}1m`, rev: `${ESC}7m`,
42
+ green: `${ESC}32m`, red: `${ESC}31m`, yellow: `${ESC}33m`,
43
+ blue: `${ESC}34m`, cyan: `${ESC}36m`, grey: `${ESC}90m`
44
+ };
45
+
46
+ // Width without the escape sequences, so padding maths stays right once the
47
+ // string has colour in it.
48
+ const visible = (s) => s.replace(/\x1b\[[0-9;]*m/g, '');
49
+ const padTo = (s, n) => {
50
+ const len = visible(s).length;
51
+ return len >= n ? s : s + ' '.repeat(n - len);
52
+ };
53
+ const trunc = (s, n) => {
54
+ const v = visible(s);
55
+ return v.length <= n ? s : v.slice(0, Math.max(0, n - 1)) + '…';
56
+ };
57
+
58
+ // A proportion drawn as a bar. Colour carries the same information as the
59
+ // length so it survives being skim-read: green is fine, yellow is getting
60
+ // full, red needs attention.
61
+ const bar = (used, total, width = 14) => {
62
+ if (!total || total <= 0) return C.grey + '─'.repeat(width) + C.reset;
63
+ const ratio = Math.max(0, Math.min(1, used / total));
64
+ const filled = Math.round(ratio * width);
65
+ const colour = ratio > 0.9 ? C.red : ratio > 0.7 ? C.yellow : C.green;
66
+ return colour + '█'.repeat(filled) + C.grey + '░'.repeat(width - filled) + C.reset;
67
+ };
68
+
69
+ const statusDot = (s) => {
70
+ const state = String(s || '').toLowerCase();
71
+ if (state === 'running') return C.green + '●' + C.reset;
72
+ if (state === 'stopped' || state === 'shut off' || state === 'exited') return C.grey + '○' + C.reset;
73
+ if (state === 'error' || state === 'crashed') return C.red + '✖' + C.reset;
74
+ return C.yellow + '◐' + C.reset;
75
+ };
76
+
77
+ const mb = (bytes) => (bytes == null ? null : Math.round(bytes / 1048576));
78
+
79
+ // The real bird, read out of the logo rather than drawn from memory — see
80
+ // lib/bird.js. Half-height blocks put its thirteen pixel rows into seven text
81
+ // lines, which is what makes it look like the logo instead of like a staircase.
82
+ const BIRD = bird.half();
83
+ const BIRD_W = bird.widthOf(BIRD);
84
+ // The stats leave a wide dead band between themselves and the bird. The
85
+ // wordmark fills it, so the header reads as one composition rather than a
86
+ // column of numbers with an ornament stuck on the far side.
87
+ const MARK = bird.wordmark();
88
+ const MARK_W = bird.widthOf(MARK);
89
+
90
+ // One row of the unified list. Apps and VMs differ enough that keeping them as
91
+ // one shape here is what lets the whole table share selection and actions.
92
+ const buildRows = (apps, vms, stats) => {
93
+ const rows = [];
94
+ for (const a of apps) {
95
+ const st = stats[`app:${a.id}`];
96
+ rows.push({
97
+ kind: 'app', id: a.id, name: a.name, status: a.status,
98
+ ramUsed: mb(st?.memUsage), ramMax: a.ram_mb, cpu: st?.cpuPercent,
99
+ where: a.docker_host || 'node2',
100
+ url: a.hostname ? `https://${a.hostname}` : null
101
+ });
102
+ }
103
+ for (const v of vms) {
104
+ const st = stats[`vm:${v.id}`];
105
+ rows.push({
106
+ kind: 'vm', id: v.id, name: v.name, status: v.state || v.status,
107
+ ramUsed: mb(st?.memUsage), ramMax: v.ram_mb, cpu: st?.cpuPercent,
108
+ where: v.ip || '—',
109
+ url: null
110
+ });
111
+ }
112
+ return rows;
113
+ };
114
+
115
+ const render = (state, width, height) => {
116
+ const W = Math.max(64, Math.min(width || 100, 140));
117
+ const line = (l, m, r, fill = '─') => l + fill.repeat(W - 2) + r;
118
+ const out = [];
119
+
120
+ // ── header: the shared budget on the left, the bird on the right.
121
+ // Neither panel page shows this budget — apps and VMs are separate screens
122
+ // there and the thing they compete for is on a third.
123
+ const u = state.usage;
124
+ out.push(C.cyan + line('╭', '', '╮') + C.reset);
125
+
126
+ const inner = W - 4; // usable width between the borders
127
+ // Decorations give way to data as the terminal narrows: the wordmark goes
128
+ // first, then the bird. The numbers are never what gets dropped.
129
+ const STATS_MIN = 46; // the stats need this much before anything decorative earns room
130
+ const showBird = inner > BIRD_W + 34;
131
+ const showMark = showBird && inner > BIRD_W + MARK_W + STATS_MIN;
132
+ // The stats column is only as wide as the stats. Padding it out to fill the
133
+ // row just relocates the empty space instead of using it — which is what the
134
+ // first attempt at this did.
135
+ const STATS_W = 44;
136
+ const leftW = showMark ? STATS_W : inner - (showBird ? BIRD_W + 1 : 0);
137
+ // Whatever sits between the stats and the bird is the wordmark's to centre in.
138
+ const midW = showMark ? inner - STATS_W - BIRD_W - 1 : 0;
139
+ const markPad = showMark ? Math.max(0, Math.floor((midW - MARK_W) / 2)) : 0;
140
+
141
+ const running = state.rows.filter((r) => String(r.status).toLowerCase() === 'running').length;
142
+ const apps = state.rows.filter((r) => r.kind === 'app').length;
143
+ const machines = state.rows.filter((r) => r.kind === 'vm').length;
144
+
145
+ const left = [];
146
+ left.push(`${C.bold}JAVER${C.reset}${C.grey} · ${config.apiUrl().replace(/^https?:\/\//, '')}${C.reset}`);
147
+ left.push('');
148
+ if (u) {
149
+ left.push(`${C.grey}RAM ${C.reset}${bar(u.used.usedRamMB, u.budget.ramMB, 16)} ${u.used.usedRamMB}/${u.budget.ramMB} MB`);
150
+ left.push(`${C.grey}CPU ${C.reset}${bar(u.used.usedCpu, u.budget.cpu, 16)} ${u.used.usedCpu}/${u.budget.cpu}`);
151
+ left.push(`${C.grey}SLOTS ${C.reset}${bar(u.used.usedItems, u.budget.maxItems, 16)} ${u.used.usedItems}/${u.budget.maxItems}`);
152
+ }
153
+ left.push(`${C.grey}${apps} app${apps === 1 ? '' : 's'}, ${machines} VM${machines === 1 ? '' : 's'}, ${running} running${C.reset}`);
154
+ left.push(state.error
155
+ ? C.red + trunc(state.error, leftW - 2) + C.reset
156
+ : C.grey + `updated ${state.updatedAt}${state.busy ? ' · working…' : ''}` + C.reset);
157
+
158
+ // Both columns are drawn to the same height so the box stays square whether
159
+ // or not the usage call came back.
160
+ const rows = Math.max(left.length, showBird ? BIRD.length : 0);
161
+ // The wordmark is shorter than the header, so centre it vertically rather
162
+ // than letting it hang off the top.
163
+ const markTop = Math.max(0, Math.floor((rows - MARK.length) / 2));
164
+ for (let i = 0; i < rows; i++) {
165
+ const l = padTo(trunc(left[i] || '', leftW), leftW);
166
+ const m = showMark
167
+ ? padTo(' '.repeat(markPad) + (MARK[i - markTop] || ''), midW)
168
+ : '';
169
+ const b = showBird ? ' ' + padTo(BIRD[i] || '', BIRD_W) : '';
170
+ out.push(C.cyan + '│' + C.reset + ' ' + l + m + b + ' ' + C.cyan + '│' + C.reset);
171
+ }
172
+ out.push(C.cyan + line('├', '', '┤') + C.reset);
173
+
174
+ // ── column headings
175
+ const head =
176
+ `${padTo('', 3)}${padTo('NAME', 22)}${padTo('KIND', 6)}${padTo('MEMORY', 20)}` +
177
+ `${padTo('CPU', 7)}${padTo('WHERE', 14)}ADDRESS`;
178
+ out.push(C.cyan + '│' + C.reset + ' ' + C.grey + padTo(trunc(head, W - 4), W - 4) + C.reset + ' ' + C.cyan + '│' + C.reset);
179
+
180
+ // ── rows
181
+ if (!state.rows.length) {
182
+ const empty = state.loading ? 'Loading…' : 'Nothing running yet. Deploy something with `javer deploy`.';
183
+ out.push(C.cyan + '│' + C.reset + ' ' + padTo(C.grey + empty + C.reset, W - 4) + ' ' + C.cyan + '│' + C.reset);
184
+ }
185
+ const maxRows = Math.max(3, (height || 30) - out.length - 6);
186
+ state.rows.slice(0, maxRows).forEach((r, i) => {
187
+ const sel = i === state.cursor;
188
+ const ramTxt = r.ramUsed == null ? `${C.grey}—${C.reset}` : `${r.ramUsed}/${r.ramMax}MB`;
189
+ const body =
190
+ `${statusDot(r.status)} ` +
191
+ `${padTo(trunc(r.name, 21), 22)}` +
192
+ `${C.grey}${padTo(r.kind, 6)}${C.reset}` +
193
+ `${bar(r.ramUsed, r.ramMax, 10)} ${padTo(ramTxt, 12)}` +
194
+ `${padTo(r.cpu == null ? C.grey + '—' + C.reset : `${r.cpu}%`, 7)}` +
195
+ `${C.grey}${padTo(trunc(r.where, 13), 14)}${C.reset}` +
196
+ `${r.url ? C.blue + trunc(r.url, 30) + C.reset : C.grey + '—' + C.reset}`;
197
+ if (sel) {
198
+ // Strip the colour: every C.reset inside the row would otherwise end the
199
+ // inverse video early, leaving the highlight covering only part of it.
200
+ const plain = padTo(trunc(visible(body), W - 4), W - 4);
201
+ out.push(C.cyan + '│' + C.reset + C.rev + ' ' + plain + ' ' + C.reset + C.cyan + '│' + C.reset);
202
+ } else {
203
+ out.push(C.cyan + '│' + C.reset + ' ' + padTo(trunc(body, W - 4), W - 4) + ' ' + C.cyan + '│' + C.reset);
204
+ }
205
+ });
206
+
207
+ // ── footer: the keys, always visible, because a UI with hidden verbs is a
208
+ // UI you have to read documentation for.
209
+ out.push(C.cyan + line('├', '', '┤') + C.reset);
210
+ const keys = state.confirm
211
+ ? `${C.yellow}${state.confirm}${C.reset} ${C.grey}y = yes, any other key = cancel${C.reset}`
212
+ : `${C.bold}↑↓${C.reset} select ${C.bold}s${C.reset} start/stop ${C.bold}r${C.reset} restart ` +
213
+ `${C.bold}l${C.reset} logs ${C.bold}u${C.reset} refresh ${C.bold}q${C.reset} quit`;
214
+ out.push(C.cyan + '│' + C.reset + ' ' + padTo(trunc(keys, W - 4), W - 4) + ' ' + C.cyan + '│' + C.reset);
215
+ out.push(C.cyan + line('╰', '', '╯') + C.reset);
216
+
217
+ if (state.message) out.push(' ' + state.message);
218
+ return out.join('\n');
219
+ };
220
+
221
+ const run = async () => {
222
+ if (!process.stdout.isTTY) {
223
+ console.error('error: `javer ui` needs an interactive terminal. Try `javer ls` instead.');
224
+ process.exit(1);
225
+ }
226
+
227
+ const state = {
228
+ rows: [], usage: null, cursor: 0, loading: true, busy: false,
229
+ error: null, message: '', confirm: null, updatedAt: '—'
230
+ };
231
+ let timer = null;
232
+ let stopped = false;
233
+
234
+ // Restoring the terminal matters more than anything else here: leave the
235
+ // alternate buffer on or the cursor hidden and the user's shell is broken
236
+ // afterwards. Every exit path goes through this exactly once.
237
+ const restore = () => {
238
+ if (stopped) return;
239
+ stopped = true;
240
+ if (timer) clearInterval(timer);
241
+ try { if (process.stdin.isTTY) process.stdin.setRawMode(false); } catch { /* already gone */ }
242
+ process.stdin.pause();
243
+ process.stdout.write(cursor(true) + alt(false));
244
+ };
245
+ const quit = (code = 0) => { restore(); process.exit(code); };
246
+ process.on('exit', restore);
247
+ process.on('SIGINT', () => quit(0));
248
+ process.on('SIGTERM', () => quit(0));
249
+ process.on('uncaughtException', (err) => { restore(); console.error(`error: ${err.message}`); process.exit(1); });
250
+
251
+ const draw = () => {
252
+ if (stopped) return;
253
+ process.stdout.write(home + clear + render(state, process.stdout.columns, process.stdout.rows) + '\n');
254
+ };
255
+
256
+ const refresh = async () => {
257
+ try {
258
+ // Apps and VMs in parallel. A VM listing failure must not blank the apps
259
+ // — a partial view beats an error screen when you are watching a fleet.
260
+ const [appsRes, vmsRes] = await Promise.all([
261
+ api.get('/apps/').catch((e) => ({ apps: [], _err: e.message })),
262
+ api.get('/vms/').catch((e) => ({ vms: [], _err: e.message }))
263
+ ]);
264
+ const apps = appsRes.apps || [];
265
+ const vms = vmsRes.vms || vmsRes.machines || [];
266
+
267
+ // Stats are per-resource and only meaningful while running. Failures are
268
+ // swallowed on purpose: a stat that will not load should show a dash, not
269
+ // take the screen down.
270
+ const stats = {};
271
+ await Promise.all([
272
+ ...apps.filter((a) => a.status === 'running').map((a) =>
273
+ api.get(`/apps/${a.id}/stats`).then((s) => { stats[`app:${a.id}`] = s; }).catch(() => {})),
274
+ ...vms.filter((v) => (v.state || v.status) === 'running').map((v) =>
275
+ api.get(`/vms/${v.id}/stats`).then((s) => { stats[`vm:${v.id}`] = s; }).catch(() => {}))
276
+ ]);
277
+
278
+ const usage = await api.get('/account/usage').catch(() => null);
279
+ state.rows = buildRows(apps, vms, stats);
280
+ state.usage = usage;
281
+ state.error = appsRes._err || vmsRes._err || null;
282
+ state.loading = false;
283
+ state.updatedAt = new Date().toTimeString().slice(0, 8);
284
+ if (state.cursor >= state.rows.length) state.cursor = Math.max(0, state.rows.length - 1);
285
+ } catch (err) {
286
+ state.error = err.message;
287
+ state.loading = false;
288
+ }
289
+ draw();
290
+ };
291
+
292
+ const act = async (verb, row) => {
293
+ state.busy = true; state.message = ''; draw();
294
+ try {
295
+ const base = row.kind === 'vm' ? `/vms/${row.id}` : `/apps/${row.id}`;
296
+ await api.post(`${base}/${verb}`, {});
297
+ state.message = `${C.green}${verb} sent to ${row.name}${C.reset}`;
298
+ } catch (err) {
299
+ state.message = `${C.red}${row.name}: ${err.message}${C.reset}`;
300
+ }
301
+ state.busy = false;
302
+ await refresh();
303
+ };
304
+
305
+ process.stdout.write(alt(true) + cursor(false));
306
+ draw();
307
+ await refresh();
308
+ timer = setInterval(refresh, 4000);
309
+
310
+ process.stdin.setRawMode(true);
311
+ process.stdin.resume();
312
+ process.stdin.setEncoding('utf8');
313
+ process.stdin.on('data', async (key) => {
314
+ const row = state.rows[state.cursor];
315
+
316
+ // A pending confirmation swallows the next keypress, so an accidental
317
+ // second 's' cannot both ask and answer.
318
+ if (state.confirm) {
319
+ const { verb, target } = state.confirm_action;
320
+ state.confirm = null; state.confirm_action = null;
321
+ if (key.toLowerCase() === 'y') await act(verb, target); else { state.message = ''; draw(); }
322
+ return;
323
+ }
324
+
325
+ if (key === 'q' || key === '') return quit(0); // q or Ctrl-C
326
+ if (key === '' || key === 'k') { state.cursor = Math.max(0, state.cursor - 1); return draw(); }
327
+ if (key === '' || key === 'j') { state.cursor = Math.min(state.rows.length - 1, state.cursor + 1); return draw(); }
328
+ if (key === 'u') return refresh();
329
+
330
+ if (!row) return;
331
+ if (key === 's') {
332
+ const running = String(row.status).toLowerCase() === 'running';
333
+ const verb = running ? 'stop' : 'start';
334
+ // Stopping takes something offline. Ask first — a UI where one keystroke
335
+ // takes a live site down is a UI that eventually takes a live site down.
336
+ if (running) {
337
+ state.confirm = `Stop ${row.name}? It will go offline.`;
338
+ state.confirm_action = { verb, target: row };
339
+ return draw();
340
+ }
341
+ return act(verb, row);
342
+ }
343
+ if (key === 'r') {
344
+ state.confirm = `Restart ${row.name}? Brief downtime.`;
345
+ state.confirm_action = { verb: 'restart', target: row };
346
+ return draw();
347
+ }
348
+ if (key === 'l') {
349
+ // Leave the alternate buffer entirely rather than trying to paint logs
350
+ // inside the frame: log lines are arbitrarily wide and wrap, and the
351
+ // scrollback belongs to the real terminal where it can be searched.
352
+ restore();
353
+ console.log(`\n─── last logs for ${row.name} ───\n`);
354
+ try {
355
+ const path = row.kind === 'vm' ? `/vms/${row.id}` : `/apps/${row.id}`;
356
+ const { logs } = await api.get(`${path}/logs`);
357
+ console.log(logs || '(nothing yet)');
358
+ } catch (err) {
359
+ console.error(`error: ${err.message}`);
360
+ }
361
+ process.exit(0);
362
+ }
363
+ });
364
+ };
365
+
366
+ module.exports = { run, render, bar, buildRows };