termux-dev 1.8.2 → 1.9.1

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.
Files changed (51) hide show
  1. package/README.md +16 -6
  2. package/assets/banner.svg +1 -1
  3. package/assets/preview.svg +1 -1
  4. package/dist/cli/background-ui.js +8 -8
  5. package/dist/cli/files.js +2 -12
  6. package/dist/cli/finder.js +10 -2
  7. package/dist/cli/headless.js +3 -2
  8. package/dist/cli/index.js +131 -75
  9. package/dist/cli/keys.js +12 -3
  10. package/dist/cli/liveblock.js +47 -9
  11. package/dist/cli/mcp-panel.js +2 -0
  12. package/dist/cli/picker.js +2 -0
  13. package/dist/cli/prompt.js +54 -7
  14. package/dist/cli/provider-manager.js +197 -0
  15. package/dist/cli/providers.js +0 -5
  16. package/dist/cli/settings.js +38 -17
  17. package/dist/cli/typeahead.js +10 -17
  18. package/dist/cli/ui.js +33 -13
  19. package/dist/cli/update-screen.js +383 -0
  20. package/dist/cli/updater.js +119 -145
  21. package/dist/cli/usage-panel.js +17 -4
  22. package/dist/cli/width.js +18 -0
  23. package/dist/core/background.js +14 -0
  24. package/dist/core/context-stats.js +1 -0
  25. package/dist/core/cost.js +25 -11
  26. package/dist/core/custom-providers.js +161 -0
  27. package/dist/core/images.js +46 -0
  28. package/dist/core/local-config.js +3 -1
  29. package/dist/core/locales/de.js +69 -7
  30. package/dist/core/locales/en.js +69 -7
  31. package/dist/core/locales/es.js +69 -7
  32. package/dist/core/locales/fr.js +69 -7
  33. package/dist/core/locales/ja.js +69 -7
  34. package/dist/core/locales/pt.js +69 -7
  35. package/dist/core/locales/ru.js +69 -7
  36. package/dist/core/locales/uk.js +69 -7
  37. package/dist/core/locales/zh.js +69 -7
  38. package/dist/core/loop.js +32 -1
  39. package/dist/core/prices.js +8 -4
  40. package/dist/core/session-media.js +115 -0
  41. package/dist/core/session.js +17 -2
  42. package/dist/core/title.js +19 -2
  43. package/dist/core/types.js +1 -1
  44. package/dist/core/usage.js +41 -4
  45. package/dist/permissions/guard.js +3 -0
  46. package/dist/providers/anthropic.js +67 -13
  47. package/dist/providers/index.js +16 -3
  48. package/dist/providers/openai.js +99 -41
  49. package/dist/providers/shared.js +83 -0
  50. package/dist/tools/fs.js +32 -2
  51. package/package.json +1 -1
package/dist/cli/ui.js CHANGED
@@ -36,9 +36,12 @@ export const palette = {
36
36
  accent: (s) => tokens().accent(s),
37
37
  bold: (s) => (colorOn() ? `\x1b[1m${s}\x1b[22m` : s),
38
38
  };
39
- export function resolveMode(planMode, yolo) {
39
+ /** `extra` is YOLO without the safety net (nothing is asked); it only counts while YOLO is on. */
40
+ export function resolveMode(planMode, yolo, extra = false) {
40
41
  if (planMode)
41
42
  return 'plan';
43
+ if (yolo && extra)
44
+ return 'extra';
42
45
  return yolo ? 'yolo' : 'agent';
43
46
  }
44
47
  export function modeStyle(mode) {
@@ -47,6 +50,8 @@ export function modeStyle(mode) {
47
50
  return { label: 'plan', glyph: '◆', color: palette.plan };
48
51
  case 'yolo':
49
52
  return { label: 'yolo', glyph: '▲', color: palette.yolo };
53
+ case 'extra':
54
+ return { label: 'extra', glyph: '▲', color: palette.err };
50
55
  default:
51
56
  return { label: 'agent', glyph: '●', color: palette.accent };
52
57
  }
@@ -136,7 +141,7 @@ function brand(version) {
136
141
  }
137
142
  export function renderHeader(info, cols) {
138
143
  const W = contentWidth(cols);
139
- const modePill = info.yolo ? palette.yolo(palette.bold('▲ auto-approve')) : '';
144
+ const modePill = info.extra ? palette.err(palette.bold('▲ extra: nothing asked')) : info.yolo ? palette.yolo(palette.bold('▲ auto-approve')) : '';
140
145
  const branch = info.branch
141
146
  ? `${palette.faint('git:')}${palette.muted(info.branch)}${info.dirty ? palette.warn('*') : ''}`
142
147
  : '';
@@ -146,7 +151,7 @@ export function renderHeader(info, cols) {
146
151
  const limit = Math.max(8, cols - 1);
147
152
  const room = Math.max(6, cols - 3);
148
153
  const titleOptions = info.yolo
149
- ? [`${brand(info.version)} ${modePill}`, `${brand(info.version)} ${palette.yolo('▲ yolo')}`, `${brand(info.version)} ${palette.yolo('▲')}`, brand(info.version)]
154
+ ? [`${brand(info.version)} ${modePill}`, `${brand(info.version)} ${info.extra ? palette.err('▲ extra') : palette.yolo('▲ yolo')}`, `${brand(info.version)} ${info.extra ? palette.err('▲') : palette.yolo('▲')}`, brand(info.version)]
150
155
  : [brand(info.version)];
151
156
  lines.push(titleOptions.find((t) => displayWidth(t) <= limit) ?? truncateWidth(stripAnsi(brand(info.version)), limit));
152
157
  lines.push(` ${palette.text(truncateWidth(info.model, room))}`);
@@ -258,6 +263,8 @@ export function inputCells(cols) {
258
263
  const W = frameWidth(cols);
259
264
  return Math.max(4, isCompact(cols) ? W - 4 : W - 6);
260
265
  }
266
+ /** Control characters but the line break (`\n` is a real break in the input, it is laid out, not shown). */
267
+ const CONTROL_EXCEPT_NEWLINE = /[\x00-\x09\x0b-\x1f\x7f]/g;
261
268
  /** Rows the input may take before it scrolls: a quarter of the terminal, at most 8. */
262
269
  export function maxInputRows(termRows = process.stdout.rows || 24) {
263
270
  return Math.max(1, Math.min(8, Math.floor(termRows / 4)));
@@ -291,7 +298,14 @@ export function wrapInput(input, cells) {
291
298
  const start = i;
292
299
  let used = 0;
293
300
  let afterSpace = -1;
301
+ let hardBreak = false;
294
302
  while (i < cps.length) {
303
+ // A line break ends the row; it stays at the end of that row, so the cursor behind it is on the next one.
304
+ if (cps[i] === '\n') {
305
+ i++;
306
+ hardBreak = true;
307
+ break;
308
+ }
295
309
  let next = atoms.get(i) ?? i + 1;
296
310
  let w = widths.slice(i, next).reduce((a, b) => a + b, 0);
297
311
  if (next > i + 1 && w > cells) {
@@ -305,12 +319,17 @@ export function wrapInput(input, cells) {
305
319
  if (cps[i - 1] === ' ')
306
320
  afterSpace = i;
307
321
  }
308
- if (i < cps.length && cps[i] !== ' ' && afterSpace > start)
309
- i = afterSpace; // break between words
310
- if (i === start)
311
- i = start + 1; // a cell wider than the row
322
+ if (!hardBreak) {
323
+ if (i < cps.length && cps[i] !== ' ' && afterSpace > start)
324
+ i = afterSpace; // break between words
325
+ if (i === start)
326
+ i = start + 1; // a cell wider than the row
327
+ }
312
328
  rows.push({ start, end: i });
313
329
  }
330
+ // Text that ends with a line break: the cursor waits on a new, empty row.
331
+ if (cps[cps.length - 1] === '\n')
332
+ rows.push({ start: cps.length, end: cps.length });
314
333
  return rows;
315
334
  }
316
335
  function layoutInput(input, cursorPos, cells) {
@@ -335,7 +354,7 @@ function layoutInput(input, cursorPos, cells) {
335
354
  * (the arrow keys then walk the prompt history as before). Returns a UTF-16 index.
336
355
  */
337
356
  export function moveCursorRow(input, cursorPos, cols, dir) {
338
- const clean = input.replace(/[\x00-\x1f\x7f]/g, ' ');
357
+ const clean = input.replace(CONTROL_EXCEPT_NEWLINE, ' ');
339
358
  const { cps, rows, widths, cursorRow, cursorCell } = layoutInput(clean, cursorPos, inputCells(cols));
340
359
  const target = rows[cursorRow + dir];
341
360
  if (!target)
@@ -358,7 +377,7 @@ function inputRows(f, cells) {
358
377
  const { cps, rows, cursorRow, cursorCell } = layoutInput(f.input, f.cursorPos, cells);
359
378
  const limit = f.maxInputRows ?? maxInputRows();
360
379
  const firstRow = rows.length <= limit ? 0 : Math.max(0, Math.min(cursorRow - limit + 1, rows.length - limit));
361
- const shown = rows.slice(firstRow, firstRow + limit).map((r) => formatBadges(cps.slice(r.start, r.end).join('')));
380
+ const shown = rows.slice(firstRow, firstRow + limit).map((r) => formatBadges(cps.slice(r.start, r.end).join('').replace(/\n$/, '')));
362
381
  return { rows: shown, firstRow, cursorRow: cursorRow - firstRow, cursorCol: cursorCell };
363
382
  }
364
383
  function keyHints(cols, mode) {
@@ -398,8 +417,9 @@ export function fitModelLabel(label, max) {
398
417
  }
399
418
  export function buildPromptFrame(input) {
400
419
  // Control characters would move the cursor and tear the frame apart. They are shown as
401
- // spaces; one character stays one character, so cursor positions keep matching.
402
- const f = { ...input, input: input.input.replace(/[\x00-\x1f\x7f]/g, ' ') };
420
+ // spaces; one character stays one character, so cursor positions keep matching. A line break
421
+ // is kept: it starts a new row of the input.
422
+ const f = { ...input, input: input.input.replace(CONTROL_EXCEPT_NEWLINE, ' ') };
403
423
  if (isCompact(f.cols))
404
424
  return buildCompactFrame(f);
405
425
  const W = frameWidth(f.cols);
@@ -997,7 +1017,7 @@ export function easeToward(current, target, dtMs, tauMs = 400) {
997
1017
  return current + distance * (1 - Math.exp(-dtMs / tauMs));
998
1018
  }
999
1019
  /**
1000
- * The working line: ` ⠹ Read src/x.ts (12s · ↓ 364 tokens · ctrl+c to stop)`.
1020
+ * The working line: ` ⠹ Read src/x.ts (12s · ↓ 364 tokens · esc to stop)`.
1001
1021
  * On a narrow screen the details are dropped step by step, the label is cut last.
1002
1022
  */
1003
1023
  export function renderStatusLine({ label: rawLabel, elapsedMs, meta = '', frame, columns }) {
@@ -1026,7 +1046,7 @@ export function renderStatusLine({ label: rawLabel, elapsedMs, meta = '', frame,
1026
1046
  return ` ${glyph} ${palette.text(text)}${' '.repeat(GAP)}${suffix}`;
1027
1047
  }
1028
1048
  /**
1029
- * Animated "working" line: ` ⠹ Read src/x.ts (12s · 1.2k tok · ctrl+c to stop)`.
1049
+ * Animated "working" line: ` ⠹ Read src/x.ts (12s · 1.2k tok · esc to stop)`.
1030
1050
  * Same start / message / stop API as the clack spinner it replaces.
1031
1051
  */
1032
1052
  let activeSpinner = null;
@@ -0,0 +1,383 @@
1
+ import { frame } from './finder.js';
2
+ import { LiveBlock } from './liveblock.js';
3
+ import { runPicker } from './picker.js';
4
+ import { palette, renderNotice } from './ui.js';
5
+ import { displayWidth, truncateWidth } from './width.js';
6
+ import { getLanguage, t } from '../core/i18n.js';
7
+ import { UpdateError, buildUpdateSteps, checkForUpdates, detectInstallMethod, getCurrentVersion, restartDevx, runUpdateSteps, } from './updater.js';
8
+ /**
9
+ * The update screens, in the style of the rest of devx:
10
+ *
11
+ * checking one line, the text with a bright band sweeping across it
12
+ * card what is new (from the notes of the release), how it will be installed
13
+ * choice Update now / Later / Skip this version
14
+ * progress the steps with a spinner and the time of each, and a bar that fills as they go
15
+ * result installed (the bar drains while devx restarts) or what failed and how to do it by hand
16
+ *
17
+ * Every screen is a pure function of its state and the animation tick, so it can be tested without
18
+ * a terminal; `presentUpdate` only adds the timer, the keyboard and the install.
19
+ */
20
+ export const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
21
+ const TICK_MS = 80;
22
+ // ───────────────────────── animation pieces ─────────────────────────
23
+ /** The text with a bright band moving across it; `tick` is the frame of the animation. */
24
+ export function shimmer(text, tick) {
25
+ const chars = Array.from(text);
26
+ const position = ((tick * 1.2) % (chars.length + 10)) - 5;
27
+ return chars
28
+ .map((ch, i) => {
29
+ if (ch === ' ')
30
+ return ch;
31
+ const d = Math.abs(i - position);
32
+ if (d < 1.2)
33
+ return palette.bold(palette.text(ch));
34
+ if (d < 3.5)
35
+ return palette.accent(ch);
36
+ return palette.muted(ch);
37
+ })
38
+ .join('');
39
+ }
40
+ /** A bar of exactly `width` cells. While busy a bright spot runs along the filled part. */
41
+ export function renderBar(width, progress, tick, tone = 'busy') {
42
+ const w = Math.max(4, width);
43
+ const filled = Math.max(0, Math.min(w, Math.round(progress * w)));
44
+ const color = tone === 'done' ? palette.ok : tone === 'failed' ? palette.err : palette.accent;
45
+ const spot = tone === 'busy' && filled > 0 ? ((tick * 1.5) % (filled + 6)) - 3 : -99;
46
+ let out = '';
47
+ for (let i = 0; i < w; i++) {
48
+ if (i < filled)
49
+ out += Math.abs(i - spot) < 1.5 ? palette.bold(palette.text('━')) : color('━');
50
+ else if (i === filled && tone === 'busy')
51
+ out += color('╸');
52
+ else
53
+ out += palette.rule('─');
54
+ }
55
+ return out;
56
+ }
57
+ /** How long each step weighs in the bar: the install is nearly all of the wait. */
58
+ const WEIGHT = { registry: 0.1, install: 0.75, verify: 0.15, pull: 0.15, build: 0.75, link: 0.1 };
59
+ /**
60
+ * How full the bar is. The real progress of an install is not known, so the step that is running
61
+ * fills asymptotically: it keeps moving, and it never reaches the end before the step is done.
62
+ */
63
+ export function estimateProgress(ids, doneCount, runningMs, expectedMs = 15000) {
64
+ const total = ids.reduce((sum, id) => sum + WEIGHT[id], 0);
65
+ if (doneCount >= ids.length)
66
+ return 1;
67
+ const done = ids.slice(0, doneCount).reduce((sum, id) => sum + WEIGHT[id], 0);
68
+ const running = WEIGHT[ids[doneCount]] * (1 - Math.exp(-runningMs / expectedMs)) * 0.92;
69
+ return Math.min(0.99, (done + running) / total);
70
+ }
71
+ const seconds = (ms) => `${(ms / 1000).toFixed(1)}s`;
72
+ // ───────────────────────── what is new ─────────────────────────
73
+ const SECTION_NAMES = {
74
+ en: /english/i, ru: /русск/i, uk: /україн/i, de: /deutsch/i, es: /español/i, fr: /français/i, pt: /português/i, zh: /中文/, ja: /日本語/,
75
+ };
76
+ function cleanMarkdown(text) {
77
+ return text
78
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
79
+ .replace(/\*\*([^*]+)\*\*/g, '$1')
80
+ .replace(/[`*_]/g, '')
81
+ .replace(/\s+/g, ' ')
82
+ .trim();
83
+ }
84
+ /**
85
+ * The highlights of a release: the top-level bullets under its first `###` heading, in the language
86
+ * of the interface when the notes have a section for it (`## Русский`), else the first section.
87
+ */
88
+ export function extractHighlights(body, language, max = 4) {
89
+ if (!body)
90
+ return [];
91
+ const lines = body.replace(/\r/g, '').split('\n');
92
+ const sections = [];
93
+ for (const line of lines) {
94
+ const heading = /^##\s+(.+?)\s*$/.exec(line);
95
+ if (heading)
96
+ sections.push({ name: heading[1], lines: [] });
97
+ else
98
+ sections[sections.length - 1]?.lines.push(line);
99
+ }
100
+ const pool = sections.length > 0 ? sections : [{ name: '', lines }];
101
+ const own = SECTION_NAMES[language];
102
+ const chosen = (own && pool.find((s) => own.test(s.name))) || pool.find((s) => SECTION_NAMES.en.test(s.name)) || pool[0];
103
+ const out = [];
104
+ let seenHeading = false;
105
+ for (const line of chosen.lines) {
106
+ if (/^###\s/.test(line)) {
107
+ // The bullets of the first block only ("Highlights"): the next heading ends them.
108
+ if (seenHeading && out.length > 0)
109
+ break;
110
+ seenHeading = true;
111
+ continue;
112
+ }
113
+ const bullet = /^-\s+(.+)$/.exec(line);
114
+ if (bullet) {
115
+ const text = cleanMarkdown(bullet[1]);
116
+ if (text)
117
+ out.push(text);
118
+ if (out.length >= max)
119
+ break;
120
+ }
121
+ }
122
+ return out;
123
+ }
124
+ /** `Your own providers. A name, an address…` -> the short head and the rest. */
125
+ function splitHighlight(text) {
126
+ const at = text.search(/[.:] /);
127
+ if (at > 0 && at <= 50)
128
+ return { head: text.slice(0, at + 1), rest: text.slice(at + 2) };
129
+ return { head: text, rest: '' };
130
+ }
131
+ const versions = (current, latest) => `v${current} → v${latest}`;
132
+ /** The line while the check runs. */
133
+ export function renderChecking(cols, tick, current) {
134
+ const limit = Math.max(10, cols - 1);
135
+ const spin = palette.accent(SPINNER_FRAMES[tick % SPINNER_FRAMES.length]);
136
+ // Two cells of indent, the spinner and a space come first; the text is cut to what is left.
137
+ const text = truncateWidth(t('update.checking'), Math.max(4, limit - 4));
138
+ const label = shimmer(text, tick);
139
+ const withTag = ` ${spin} ${label} ${palette.faint(`v${current}`)}`;
140
+ return displayWidth(withTag) <= limit ? withTag : ` ${spin} ${label}`;
141
+ }
142
+ /** The card that says a new version is out and what is in it. */
143
+ export function renderUpdateCard(info, cols) {
144
+ const how = info.method === 'npm' ? t('update.viaNpm') : t('update.viaGit');
145
+ return frame(t('update.title'), versions(info.current, info.latest), cols, (iw, box) => {
146
+ const lines = [];
147
+ lines.push(box(' ' + palette.bold(palette.accent(truncateWidth(t('update.available', { version: info.latest }), iw - 2)))));
148
+ const date = info.publishedAt ? info.publishedAt.slice(0, 10) : '';
149
+ const meta = [t('update.have', { version: info.current }), date ? t('update.released', { date }) : ''].filter(Boolean).join(' · ');
150
+ lines.push(box(' ' + palette.muted(truncateWidth(meta, iw - 2))));
151
+ if (info.highlights.length > 0) {
152
+ lines.push(box(''));
153
+ lines.push(box(' ' + palette.faint(truncateWidth(t('update.whatsNew').toUpperCase(), iw - 2))));
154
+ for (const text of info.highlights) {
155
+ const { head, rest } = splitHighlight(text);
156
+ const room = iw - 4;
157
+ const headText = truncateWidth(head, room);
158
+ const restRoom = room - displayWidth(headText) - 1;
159
+ const tailText = rest && restRoom >= 8 ? ' ' + palette.muted(truncateWidth(rest, restRoom)) : '';
160
+ lines.push(box(' ' + palette.accent('●') + ' ' + palette.bold(palette.text(headText)) + tailText));
161
+ }
162
+ }
163
+ lines.push(box(''));
164
+ return lines;
165
+ }, palette.faint(truncateWidth(how, Math.max(10, cols - 3))));
166
+ }
167
+ /** The steps with a spinner on the one that runs, and the bar. */
168
+ export function renderProgressCard(state, cols, tick) {
169
+ const done = state.tone === 'done';
170
+ return frame(done ? t('update.titleDone') : t('update.titleBusy'), versions(state.current, state.latest), cols, (iw, box) => {
171
+ const lines = [];
172
+ for (const step of state.steps) {
173
+ const icon = step.status === 'ok' ? palette.ok('✓')
174
+ : step.status === 'run' ? palette.accent(SPINNER_FRAMES[tick % SPINNER_FRAMES.length])
175
+ : step.status === 'fail' ? palette.err('✗')
176
+ : palette.faint('○');
177
+ const time = step.ms !== undefined ? palette.faint(seconds(step.ms)) : '';
178
+ const room = Math.max(6, iw - 4 - displayWidth(time) - 1);
179
+ const text = truncateWidth(step.label, room);
180
+ const label = step.status === 'run' ? palette.bold(palette.text(text))
181
+ : step.status === 'wait' ? palette.faint(text)
182
+ : step.status === 'fail' ? palette.err(text)
183
+ : palette.muted(text);
184
+ const left = ' ' + icon + ' ' + label;
185
+ lines.push(box(left + ' '.repeat(Math.max(1, iw - displayWidth(left) - displayWidth(time) - 1)) + time));
186
+ }
187
+ lines.push(box(''));
188
+ lines.push(box(' ' + renderBar(iw - 2, state.progress, tick, state.tone)));
189
+ if (state.restarting)
190
+ lines.push(box(' ' + palette.muted(truncateWidth(t('update.restarting'), iw - 2))));
191
+ lines.push(box(''));
192
+ return lines;
193
+ }, palette.faint(truncateWidth(done ? t('update.installed', { version: state.latest }) : t('update.keepOpen'), Math.max(10, cols - 3))));
194
+ }
195
+ /** The command that does the update by hand. */
196
+ export const manualCommand = (method) => (method === 'npm' ? 'npm install -g termux-dev@latest' : 'git pull && npm install && npm run build');
197
+ /** What failed, and how to do it by hand. devx goes on with the version it has. */
198
+ export function renderFailureCard(info, cols) {
199
+ return frame(t('update.titleFailed'), versions(info.current, info.latest), cols, (iw, box) => {
200
+ const lines = [];
201
+ lines.push(box(' ' + palette.err('✗') + ' ' + palette.err(truncateWidth(t('update.failedStep', { step: info.step }), iw - 4))));
202
+ const detail = info.notPublished
203
+ ? [t('update.notPublished', { version: info.latest })]
204
+ : info.message.split('\n').map((l) => l.trim()).filter(Boolean).slice(-4);
205
+ for (const line of detail)
206
+ lines.push(box(' ' + palette.muted(truncateWidth(line, iw - 4))));
207
+ lines.push(box(''));
208
+ lines.push(box(' ' + palette.text(truncateWidth(t('update.keepsRunning', { version: info.current }), iw - 2))));
209
+ lines.push(box(''));
210
+ lines.push(box(' ' + palette.faint(truncateWidth(t('update.byHand'), iw - 2))));
211
+ lines.push(box(' ' + palette.bold(palette.text(truncateWidth(manualCommand(info.method), iw - 4)))));
212
+ lines.push(box(''));
213
+ return lines;
214
+ }, '');
215
+ }
216
+ const columnsOf = (out) => out.columns || process.stdout.columns || 80;
217
+ const write = (out, lines) => out.write(lines.join('\n') + '\n');
218
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
219
+ /**
220
+ * Shows the card for a new version, asks what to do and, when told to, installs it on a live
221
+ * screen and restarts devx. `offerSkip` adds "Skip this version" (the start-up check, not /update);
222
+ * `save` is told which version was skipped.
223
+ */
224
+ export async function presentUpdate(info, opts, deps = {}) {
225
+ const out = deps.output ?? process.stdout;
226
+ const now = deps.now ?? Date.now;
227
+ const install = deps.install ?? (await detectInstallMethod());
228
+ const cols = columnsOf(out);
229
+ write(out, [
230
+ '',
231
+ ...renderUpdateCard({ current: info.currentVersion, latest: info.latestVersion, publishedAt: info.publishedAt, highlights: extractHighlights(info.releaseNotes, getLanguage()), method: install.method }, cols),
232
+ '',
233
+ ]);
234
+ // Nobody to ask (a pipe): the card is all there is.
235
+ if (!out.isTTY)
236
+ return 'later';
237
+ const picker = deps.picker ?? runPicker;
238
+ const labels = [t('update.now'), t('update.later'), t('update.skip')];
239
+ const choice = await picker({
240
+ title: t('update.title'),
241
+ subtitle: versions(info.currentVersion, info.latestVersion),
242
+ items: [
243
+ { value: 'now', label: t('update.now'), detail: t('update.nowDetail', { version: info.latestVersion }) },
244
+ { value: 'later', label: t('update.later'), detail: t('update.laterDetail') },
245
+ ...(opts.offerSkip ? [{ value: 'skip', label: t('update.skip'), detail: t('update.skipDetail', { version: info.latestVersion }) }] : []),
246
+ ],
247
+ current: 'now',
248
+ labelWidth: Math.max(...labels.slice(0, opts.offerSkip ? 3 : 2).map((l) => displayWidth(l))) + 2,
249
+ });
250
+ if (choice === 'skip') {
251
+ await opts.save?.(info.latestVersion);
252
+ out.write(renderNotice('info', t('update.skipped', { version: info.latestVersion }), cols) + '\n\n');
253
+ return 'skipped';
254
+ }
255
+ if (choice !== 'now')
256
+ return 'later';
257
+ // ── the install, on a screen that is redrawn in place
258
+ const steps = buildUpdateSteps(install.method, install.root, info.latestVersion, deps.run);
259
+ const ids = steps.map((step) => step.id);
260
+ const state = {
261
+ current: info.currentVersion,
262
+ latest: info.latestVersion,
263
+ steps: ids.map((id, i) => ({ label: t(`update.step.${id}`, { version: info.latestVersion }), status: i === 0 ? 'run' : 'wait' })),
264
+ progress: 0,
265
+ tone: 'busy',
266
+ };
267
+ const block = new LiveBlock(out);
268
+ let tick = 0;
269
+ let runningSince = now();
270
+ let restartStart = 0;
271
+ const restartMs = deps.restartMs ?? 1500;
272
+ const draw = () => {
273
+ if (state.restarting)
274
+ state.progress = Math.max(0, 1 - (now() - restartStart) / restartMs);
275
+ else {
276
+ const doneCount = state.steps.filter((s) => s.status === 'ok').length;
277
+ state.progress = state.tone === 'failed' ? state.progress : estimateProgress(ids, doneCount, now() - runningSince);
278
+ }
279
+ block.draw(renderProgressCard(state, columnsOf(out), tick++));
280
+ };
281
+ draw();
282
+ const timer = setInterval(draw, deps.tickMs ?? TICK_MS);
283
+ timer.unref?.();
284
+ const stopWatching = block.watchResize(draw);
285
+ const outcome = await runUpdateSteps(steps, {
286
+ onStart: (i) => {
287
+ state.steps[i].status = 'run';
288
+ runningSince = now();
289
+ },
290
+ onDone: (i, ms) => {
291
+ state.steps[i].status = 'ok';
292
+ state.steps[i].ms = ms;
293
+ if (state.steps[i + 1])
294
+ state.steps[i + 1].status = 'run';
295
+ },
296
+ }, now);
297
+ if (!outcome.ok) {
298
+ clearInterval(timer);
299
+ stopWatching();
300
+ state.steps[outcome.index].status = 'fail';
301
+ state.tone = 'failed';
302
+ block.erase();
303
+ write(out, renderFailureCard({ current: info.currentVersion, latest: info.latestVersion, step: state.steps[outcome.index].label, message: outcome.error.message, notPublished: outcome.error.kind === 'not-published', method: install.method }, columnsOf(out)));
304
+ out.write('\x1b[?25h\n');
305
+ return 'failed';
306
+ }
307
+ // Installed: the bar turns green and drains while devx restarts.
308
+ state.tone = 'done';
309
+ state.restarting = true;
310
+ restartStart = now();
311
+ await sleep(restartMs);
312
+ clearInterval(timer);
313
+ stopWatching();
314
+ state.progress = 0;
315
+ block.draw(renderProgressCard(state, columnsOf(out), tick));
316
+ block.release();
317
+ (deps.restart ?? restartDevx)();
318
+ return 'updated';
319
+ }
320
+ // ───────────────────────── the check ─────────────────────────
321
+ /** Runs `work` while the "checking" line plays, and takes the line away when it is done. */
322
+ async function withCheckingLine(out, current, work, tickMs = TICK_MS) {
323
+ if (!out.isTTY)
324
+ return work();
325
+ const block = new LiveBlock(out);
326
+ let tick = 0;
327
+ const draw = () => block.draw([renderChecking(columnsOf(out), tick++, current)]);
328
+ draw();
329
+ const timer = setInterval(draw, tickMs);
330
+ timer.unref?.();
331
+ try {
332
+ return await work();
333
+ }
334
+ finally {
335
+ clearInterval(timer);
336
+ block.erase();
337
+ }
338
+ }
339
+ /** At start-up: look for a new version; say so only when there is one (and it was not skipped). */
340
+ export async function runStartupUpdateCheck(config, deps = {}) {
341
+ if (config.checkUpdates === false)
342
+ return;
343
+ const out = deps.output ?? process.stdout;
344
+ if (!out.isTTY)
345
+ return;
346
+ const current = await getCurrentVersion();
347
+ const result = await withCheckingLine(out, current, () => (deps.check ?? checkForUpdates)(6000));
348
+ if (result.updateAvailable) {
349
+ if (config.skippedUpdate === result.latestVersion)
350
+ return;
351
+ await presentUpdate(result, {
352
+ offerSkip: true,
353
+ save: async (version) => {
354
+ config.skippedUpdate = version;
355
+ await deps.save?.(config);
356
+ },
357
+ }, deps);
358
+ return;
359
+ }
360
+ if (result.error)
361
+ return; // offline at start-up is not worth a word
362
+ // Up to date: a short line that goes away by itself.
363
+ out.write(` ${palette.ok('✓')} ${palette.muted(t('update.upToDate', { version: result.currentVersion }))}\n`);
364
+ await sleep(deps.tickMs !== undefined ? 0 : 450);
365
+ out.write('\x1b[1A\x1b[2K');
366
+ }
367
+ /** `/update`: the same, but it always answers (also "up to date" and "could not check"). */
368
+ export async function runManualUpdate(deps = {}) {
369
+ const out = deps.output ?? process.stdout;
370
+ const current = await getCurrentVersion();
371
+ const result = await withCheckingLine(out, current, () => (deps.check ?? checkForUpdates)(10000));
372
+ const cols = columnsOf(out);
373
+ if (result.updateAvailable) {
374
+ await presentUpdate(result, { offerSkip: false }, deps);
375
+ }
376
+ else if (result.error) {
377
+ out.write(renderNotice('warn', t('update.offline', { reason: result.error }), cols) + '\n');
378
+ }
379
+ else {
380
+ out.write(renderNotice('ok', t('update.upToDate', { version: result.currentVersion }), cols) + '\n');
381
+ }
382
+ }
383
+ export { UpdateError };