gp-cron 0.1.0 → 0.1.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 (4) hide show
  1. package/README.md +19 -10
  2. package/package.json +25 -6
  3. package/src/cli.js +40 -93
  4. package/src/tui.js +254 -0
package/README.md CHANGED
@@ -25,9 +25,10 @@ gp add
25
25
  gp
26
26
  ```
27
27
 
28
- `gp add` открывает мастер настройки. На первом шаге текущая папка предложена
29
- по умолчанию — нажмите Enter. Затем укажите название, интервал, команды после
30
- обновления, таймаут и подтвердите доверие. Путь можно передать явно:
28
+ `gp add` открывает форму настройки с текущей папкой. Стрелками или Tab выберите
29
+ поле, нажмите Enter для изменения. Задайте интервал, команды после обновления
30
+ и таймаут, отметьте разрешение на обновление клавишей Space и выберите «Сохранить».
31
+ Путь можно передать явно:
31
32
  `gp add ~/Projects/my-app`.
32
33
 
33
34
  Папка должна быть корнем Git-репозитория; текущая ветка должна иметь upstream.
@@ -37,8 +38,8 @@ gp
37
38
  git branch --set-upstream-to=origin/main main
38
39
  ```
39
40
 
40
- Добавленный проект сразу начинает обновляться. `--no-start` регистрирует его
41
- остановленным. Для серверов и скриптов есть явная настройка без вопросов:
41
+ Переключатель «Запустить после сохранения» включает обновления сразу.
42
+ `--no-start` отключает его по умолчанию. Для серверов и скриптов есть настройка без вопросов:
42
43
 
43
44
  ```sh
44
45
  gp add /srv/my-app --name my-app --interval 5m --trust \
@@ -48,7 +49,7 @@ gp add /srv/my-app --name my-app --interval 5m --trust \
48
49
 
49
50
  Каждая команда выполняется отдельно, по порядку, в папке проекта через системный
50
51
  shell. Можно передавать `npm ci && npm run build` одной командой. Пустой список
51
- команд означает только обновление Git. При редактировании список вводится заново.
52
+ команд означает только обновление Git. При редактировании существующие команды сохраняются.
52
53
  Таймаут применяется к каждой команде; Git-операции имеют отдельный таймаут 120 секунд.
53
54
  Интервалы и таймауты: от `10s` до `168h`.
54
55
 
@@ -59,18 +60,26 @@ shell. Можно передавать `npm ci && npm run build` одной ко
59
60
 
60
61
  | Клавиша | Действие |
61
62
  | --- | --- |
62
- | ↑ / ↓ или k / j | Выбрать проект |
63
+ | ↑ / ↓ или Tab | Выбрать проект |
64
+ | Enter | Открыть меню действий проекта |
63
65
  | a | Добавить проект |
64
66
  | Space | Запустить / остановить расписание |
65
67
  | p | Проверить и обновить сейчас |
66
68
  | e | Изменить настройки |
67
- | l | Посмотреть последние логи |
69
+ | l | Открыть логи с прокруткой и автоматическим обновлением |
68
70
  | r | Повторить команды после ошибки |
69
71
  | d | Удалить из реестра |
70
- | q / Ctrl+C | Закрыть панель; фоновые обновления продолжаются |
72
+ | q / Esc / Ctrl+C | Закрыть панель; фоновые обновления продолжаются |
73
+
74
+ В формах Tab / Shift+Tab и стрелки переключают поля, Enter открывает редактор,
75
+ Space меняет переключатель, Esc возвращает назад без сохранения формы.
76
+ В редакторе текста Ctrl+U очищает поле, ←→ / Home / End перемещают курсор.
77
+ В списке команд Enter добавляет или изменяет команду, Delete удаляет,
78
+ Ctrl+↑↓ меняет порядок. В просмотре логов ↑↓ / PageUp / PageDown прокручивают
79
+ вывод, End включает слежение за новыми строками.
71
80
 
72
81
  Без интерактивного терминала `gp` выводит таблицу статусов. `NO_COLOR=1` отключает
73
- цвета в обычном CLI. Панель использует ANSI-терминал.
82
+ цвета. Панель использует ANSI-терминал, восстанавливает экран и курсор при выходе.
74
83
 
75
84
  ## Команды
76
85
 
package/package.json CHANGED
@@ -1,12 +1,31 @@
1
1
  {
2
2
  "name": "gp-cron",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "A safe, local Git project updater with a terminal dashboard",
5
5
  "type": "module",
6
- "bin": { "gp": "bin/gp.js" },
7
- "engines": { "node": ">=20" },
8
- "files": ["bin", "src", "README.md", "LICENSE"],
9
- "scripts": { "test": "node --test", "check": "node --check bin/gp.js && node --check src/cli.js && node --check src/worker.js", "start": "node bin/gp.js" },
10
- "keywords": ["git", "updater", "cli", "tui", "automation"],
6
+ "bin": {
7
+ "gp": "bin/gp.js"
8
+ },
9
+ "engines": {
10
+ "node": ">=20"
11
+ },
12
+ "files": [
13
+ "bin",
14
+ "src",
15
+ "README.md",
16
+ "LICENSE"
17
+ ],
18
+ "scripts": {
19
+ "test": "node --test",
20
+ "check": "node --check bin/gp.js && node --check src/cli.js && node --check src/worker.js && node --check src/tui.js",
21
+ "start": "node bin/gp.js"
22
+ },
23
+ "keywords": [
24
+ "git",
25
+ "updater",
26
+ "cli",
27
+ "tui",
28
+ "automation"
29
+ ],
11
30
  "license": "MIT"
12
31
  }
package/src/cli.js CHANGED
@@ -3,8 +3,8 @@ import path from 'node:path';
3
3
  import crypto from 'node:crypto';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { spawn } from 'node:child_process';
6
- import readline from 'node:readline';
7
- import { createInterface } from 'node:readline/promises';
6
+ import os from 'node:os';
7
+ import { withTerminal } from './tui.js';
8
8
  import { config, home, alive, interval, lock, logFile, select, setState, state, updateConfig } from './store.js';
9
9
  import { daemon, inspect, pull } from './worker.js';
10
10
 
@@ -41,28 +41,20 @@ async function ensureDaemon() {
41
41
  for (let i = 0; i < 50; i++) { if (spawnError) throw spawnError; if (daemonPid()) return; await sleep(100); }
42
42
  throw new Error(`Daemon failed to start. Inspect ${path.join(home, 'daemon.log')}`);
43
43
  }
44
- async function promptSession(fn) {
45
- if (!process.stdin.isTTY) throw new Error('Interactive setup needs a terminal. Use --trust and explicit options; see gp help.');
46
- const rl = createInterface({ input: process.stdin, output: process.stdout });
47
- try { return await fn(async (label, fallback = '') => (await rl.question(`${label}${fallback ? ` [${fallback}]` : ''}: `)).trim() || fallback); }
48
- finally { rl.close(); }
49
- }
50
- async function wizard(folder, existing) {
51
- return promptSession(async ask => {
52
- const repo = await inspect(folder || await ask('Repository folder', process.cwd()));
53
- console.log(`\n${safe(repo.path)}\nBranch: ${safe(repo.branch)} → ${safe(repo.upstream)}\n`);
54
- const name = await ask('Project name', existing?.name || path.basename(repo.path));
55
- const frequency = interval(await ask('Update interval (10s, 5m, 1h)', existing ? `${existing.interval / 1000}s` : '5m'));
56
- console.log('Commands run in this folder using your shell, with your user permissions.');
57
- if (existing?.commands.length) console.log(`Current commands: ${safe(existing.commands.join(' → '))}`);
58
- const commands = [];
59
- console.log('Enter each post-update command in execution order. Empty input finishes.');
60
- while (true) { const command = await ask(`Command ${commands.length + 1}`); if (!command) break; commands.push(command); }
61
- const timeout = interval(await ask('Timeout for each command', existing ? `${existing.commandTimeout / 1000}s` : '10m'));
62
- console.log('Trust allows Git network access and updates in this folder. Built-in Git hooks are disabled.');
63
- if ((await ask('Trust this folder and enable these commands? Type yes', 'no')).toLowerCase() !== 'yes') throw new Error('Setup cancelled.');
64
- return { ...repo, name, interval: frequency, commands, commandTimeout: timeout, trusted: true };
44
+ function say(opts, message) { if (opts?.ui) opts.ui.notice = message; else console.log(message); }
45
+ async function wizard(folder, existing, opts = {}) {
46
+ const show = ui => ui.projectForm({
47
+ folder: folder || process.cwd(), existing, start: !opts['no-start'],
48
+ validate: async value => {
49
+ const frequency = interval(value.interval), timeout = interval(value.timeout);
50
+ const folderPath = value.folder === '~' ? os.homedir() : value.folder.startsWith('~/') ? path.join(os.homedir(), value.folder.slice(2)) : value.folder;
51
+ const repo = await inspect(path.resolve(folderPath));
52
+ const name = value.name || path.basename(repo.path);
53
+ if (name === 'all' || config().projects.some(p => p.id !== existing?.id && (p.path === repo.path || p.name === name || p.id === name))) throw new Error('Папка или название уже зарегистрированы.');
54
+ return { ...repo, name, interval: frequency, commands: value.commands, commandTimeout: timeout, trusted: true, enabled: existing?.enabled ?? value.enabled };
55
+ }
65
56
  });
57
+ return opts.ui ? show(opts.ui) : withTerminal(show);
66
58
  }
67
59
  async function add(folder, opts) {
68
60
  let values;
@@ -70,14 +62,15 @@ async function add(folder, opts) {
70
62
  const repo = await inspect(path.resolve(folder || '.'));
71
63
  if (opts.command.length && !opts['allow-commands']) throw new Error('Use --allow-commands to explicitly authorize post-update commands.');
72
64
  values = { ...repo, name: opts.name || path.basename(repo.path), interval: interval(opts.interval || '5m'), commands: opts.command, commandTimeout: interval(opts.timeout || '10m'), trusted: true };
73
- } else values = await wizard(folder);
74
- const project = { ...values, id: crypto.randomBytes(4).toString('hex'), enabled: !opts['no-start'] };
65
+ } else values = await wizard(folder, undefined, opts);
66
+ if (!values) { say(opts, 'Добавление отменено.'); return; }
67
+ const project = { ...values, id: crypto.randomBytes(4).toString('hex'), enabled: values.enabled ?? !opts['no-start'] };
75
68
  updateConfig(c => {
76
69
  if (c.projects.some(p => p.path === project.path || p.name === project.name || p.id === project.name) || project.name === 'all') throw new Error('Project folder or name is already registered, or name is reserved.');
77
70
  c.projects.push(project);
78
71
  });
79
72
  if (project.enabled) await ensureDaemon();
80
- console.log(`Added ${safe(project.name)} (${project.id})${project.enabled ? ' · running' : ' · stopped'}`);
73
+ say(opts, `Added ${safe(project.name)} (${project.id})${project.enabled ? ' · running' : ' · stopped'}`);
81
74
  }
82
75
  function status(p) {
83
76
  const s = state(p.id);
@@ -97,15 +90,16 @@ function mutateProject(p, fn) {
97
90
  if (!release) throw new Error('Project is currently updating. Try again when it finishes.');
98
91
  try { return fn(); } finally { release(); }
99
92
  }
100
- async function edit(p) {
101
- const values = await wizard(p.path, p);
93
+ async function edit(p, opts = {}) {
94
+ const values = await wizard(p.path, p, opts);
95
+ if (!values) { say(opts, 'Изменения отменены.'); return; }
102
96
  mutateProject(p, () => updateConfig(c => {
103
- if (values.name === 'all' || c.projects.some(item => item.id !== p.id && (item.name === values.name || item.id === values.name))) throw new Error('Project name is already used or reserved.');
97
+ if (values.name === 'all' || c.projects.some(item => item.id !== p.id && (item.name === values.name || item.id === values.name || item.path === values.path))) throw new Error('Project name or folder is already used or reserved.');
104
98
  const current = c.projects.find(item => item.id === p.id);
105
99
  if (!current) throw new Error('Project was removed.');
106
100
  Object.assign(current, values);
107
101
  }));
108
- console.log('Saved. Run gp start to resume a paused project.');
102
+ say(opts, 'Настройки сохранены. Для возобновления проекта используйте Start.');
109
103
  }
110
104
  async function logs(p, opts = {}) {
111
105
  const count = Number(opts.lines || 30);
@@ -156,93 +150,46 @@ async function action(command, query, opts = {}) {
156
150
  updateConfig(c => { for (const p of c.projects) if (targets.some(t => t.id === p.id)) p.enabled = command === 'start'; });
157
151
  } finally { for (const release of releases.reverse()) release(); }
158
152
  if (command === 'start') await ensureDaemon();
159
- console.log(`${command === 'start' ? 'Started' : 'Stopped scheduling'} ${targets.length} project(s).${command === 'stop' ? ' An active job is allowed to finish.' : ''}`);
153
+ say(opts, `${command === 'start' ? 'Started' : 'Stopped scheduling'} ${targets.length} project(s).${command === 'stop' ? ' An active job is allowed to finish.' : ''}`);
160
154
  return;
161
155
  }
162
156
  const p = select(query);
163
- if (command === 'edit') return edit(p);
157
+ if (command === 'edit') return edit(p, opts);
164
158
  if (command === 'logs') return logs(p, opts);
165
159
  if (command === 'info') { console.log(JSON.stringify({ ...p, status: status(p), runtime: state(p.id) }, null, 2)); return; }
166
160
  if (command === 'pull' || command === 'retry') {
167
161
  const result = await pull(p, { retry: command === 'retry' });
168
162
  if (result.error) throw new Error(result.error);
169
- console.log(result.busy ? 'Project is already updating.' : 'Done.'); return;
163
+ say(opts, result.busy ? 'Project is already updating.' : 'Done.'); return;
170
164
  }
171
165
  if (command === 'remove' || command === 'accept') {
172
166
  if (!opts.yes) {
173
- const confirmed = await promptSession(async ask => (await ask(command === 'remove' ? `Unregister ${p.name}? Files will stay. Type yes` : 'Clear pending commands without running them? Type yes', 'no')) === 'yes');
167
+ const show = ui => ui.confirm(command === 'remove' ? 'Удаление из реестра' : 'Снять незавершённые команды', p.name, command === 'remove' ? ['Прекратить управление этой папкой?', 'Файлы проекта и логи сохранятся.'] : ['Снять блокировку без выполнения команд?', 'Подтвердите, если решили проблему вручную.']);
168
+ const confirmed = opts.ui ? await show(opts.ui) : await withTerminal(show);
174
169
  if (!confirmed) return;
175
170
  }
176
171
  mutateProject(p, () => {
177
172
  if (command === 'remove') updateConfig(c => { c.projects = c.projects.filter(item => item.id !== p.id); });
178
173
  else setState(p.id, { pendingRevision: null, blocked: false, error: null, phase: 'idle', nextRun: Date.now() + p.interval });
179
174
  });
180
- console.log(command === 'remove' ? 'Project unregistered; files and logs retained.' : 'Pending commands cleared.'); return;
175
+ say(opts, command === 'remove' ? 'Project unregistered; files and logs retained.' : 'Pending commands cleared.'); return;
181
176
  }
182
177
  throw new Error(`Unknown command: ${command}`);
183
178
  }
184
179
 
185
180
  async function dashboard() {
186
181
  if (!process.stdin.isTTY || !process.stdout.isTTY) return list();
187
- let selected = 0, busy = false, done = false, notice = '';
188
- readline.emitKeypressEvents(process.stdin);
189
- const draw = () => {
190
- if (busy || done) return;
191
- const projects = config().projects;
192
- selected = Math.max(0, Math.min(selected, projects.length - 1));
193
- const width = Math.max(30, (process.stdout.columns || 90) - 2);
194
- const line = text => console.log(safe(text).slice(0, width));
195
- process.stdout.write('\x1b[2J\x1b[H');
196
- console.log(color('1;36', ' ◈ git-puller') + color('2', ` daemon ${daemonPid() ? '● online' : '○ offline'}`));
197
- line(' Git projects, quietly kept current.'); console.log();
198
- const visible = Math.max(1, (process.stdout.rows || 24) - 13);
199
- const first = Math.max(0, selected - visible + 1);
200
- for (let index = first; index < Math.min(projects.length, first + visible); index++) {
201
- const p = projects[index];
202
- const text = ` ${index === selected ? '›' : ' '} ${p.name.padEnd(22)} ${status(p).padEnd(10)} ${String(p.interval / 1000).padStart(5)}s ${p.branch}`;
203
- console.log(color(index === selected ? '1;36' : '0', safe(text).slice(0, width)));
204
- }
205
- if (!projects.length) line(' No projects. Press a to add your first repository.');
206
- const p = projects[selected];
207
- console.log();
208
- if (p) {
209
- const s = state(p.id);
210
- line(` ${p.path}`);
211
- line(` Checked: ${s.checkedAt || '—'} Updated: ${s.updatedAt || '—'}`);
212
- line(` Next: ${p.enabled && !s.blocked && s.nextRun ? new Date(s.nextRun).toLocaleTimeString() : '—'}`);
213
- if (s.error) line(` ⚠ ${s.error}`);
182
+ const labels = { stopped: 'остановлен', paused: 'пауза', offline: 'нет daemon', checking: 'проверка', commands: 'команды', watching: 'работает' };
183
+ return withTerminal(ui => ui.dashboard({
184
+ projects: () => config().projects,
185
+ status: p => labels[status(p)], state, daemon: daemonPid,
186
+ logs: p => fs.existsSync(logFile(p.id)) ? fs.readFileSync(logFile(p.id), 'utf8') : 'Логов пока нет.',
187
+ action: async (command, p, terminal) => {
188
+ const opts = { ui: terminal, command: [] };
189
+ if (command === 'add') await add(undefined, opts);
190
+ else await action(command, p.id, opts);
214
191
  }
215
- console.log();
216
- line(' ↑↓ select a add space start/stop p pull e edit l logs');
217
- line(' r retry commands d remove q quit');
218
- if (notice) line(` ${notice}`);
219
- };
220
- const cleanup = () => { clearInterval(timer); process.stdin.off('keypress', keypress); process.stdin.setRawMode(false); process.stdin.pause(); process.stdout.write('\x1b[?25h\n'); };
221
- let finish;
222
- const completed = new Promise(resolve => { finish = resolve; });
223
- const keypress = async (text, key = {}) => {
224
- if (busy || done) return;
225
- if (text === 'q' || (key.ctrl && key.name === 'c')) { done = true; cleanup(); finish(); return; }
226
- const projects = config().projects;
227
- if (key.name === 'up' || text === 'k') { selected--; draw(); return; }
228
- if (key.name === 'down' || text === 'j') { selected++; draw(); return; }
229
- const p = projects[selected];
230
- if (!['a', ' ', 'p', 'e', 'l', 'r', 'd'].includes(text) || (text !== 'a' && !p)) return;
231
- busy = true; process.stdin.setRawMode(false); process.stdout.write('\x1b[?25h\x1b[2J\x1b[H');
232
- try {
233
- if (text === 'a') await add(undefined, { command: [] });
234
- else await action(({ ' ': p.enabled ? 'stop' : 'start', p: 'pull', e: 'edit', l: 'logs', r: 'retry', d: 'remove' })[text], p.id);
235
- notice = 'Done';
236
- } catch (error) { notice = error.message; console.log(safe(error.message)); }
237
- finally {
238
- await promptSession(async ask => { await ask('Press Enter to return'); });
239
- process.stdin.setRawMode(true); process.stdin.resume(); process.stdout.write('\x1b[?25l'); busy = false; draw();
240
- }
241
- };
242
- process.stdin.setRawMode(true); process.stdin.resume(); process.stdout.write('\x1b[?25l');
243
- process.stdin.on('keypress', keypress);
244
- const timer = setInterval(draw, 1000);
245
- draw(); await completed;
192
+ }));
246
193
  }
247
194
 
248
195
  const help = `git-puller · gp
package/src/tui.js ADDED
@@ -0,0 +1,254 @@
1
+ import readline from 'node:readline';
2
+
3
+ export const cleanText = value => String(value ?? '').replace(/[\x00-\x1f\x7f-\x9f]/g, ' ');
4
+ const chars = value => Array.from(cleanText(value));
5
+ export function fit(value, width) {
6
+ const text = chars(value);
7
+ return (text.length > width ? text.slice(0, Math.max(0, width - 1)).join('') + '…' : text.join('')).padEnd(width);
8
+ }
9
+ const row = (text = '', selected = false, warning = false) => ({ text, selected, warning });
10
+ const cancelKey = key => key.name === 'escape' || (key.ctrl && key.name === 'c');
11
+
12
+ export class Terminal {
13
+ constructor(input = process.stdin, output = process.stdout) {
14
+ this.input = input; this.output = output; this.view = null; this.notice = '';
15
+ }
16
+ async open(fn) {
17
+ if (!this.input.isTTY || !this.output.isTTY) throw new Error('Настройка требует терминала. Для скриптов используйте --trust и параметры из gp help.');
18
+ this.wasRaw = Boolean(this.input.isRaw);
19
+ readline.emitKeypressEvents(this.input, { escapeCodeTimeout: 100 });
20
+ this.onKey = (text, key = {}) => this.view?.key(text, key);
21
+ this.onResize = () => this.draw();
22
+ this.onSignal = () => { this.view?.cancel(); this.close(); process.exitCode = 143; };
23
+ this.input.on('keypress', this.onKey); this.output.on('resize', this.onResize);
24
+ process.on('SIGTERM', this.onSignal);
25
+ this.input.setRawMode(true); this.input.resume();
26
+ this.output.write('\x1b[?1049h\x1b[?25l');
27
+ this.timer = setInterval(() => this.draw(), 1000);
28
+ try { return await fn(this); } finally { this.close(); }
29
+ }
30
+ close() {
31
+ if (!this.onKey) return;
32
+ clearInterval(this.timer);
33
+ this.input.off('keypress', this.onKey); this.output.off('resize', this.onResize);
34
+ process.off('SIGTERM', this.onSignal);
35
+ this.input.setRawMode(this.wasRaw); this.input.pause();
36
+ this.output.write('\x1b[0m\x1b[?25h\x1b[?1049l');
37
+ this.onKey = null;
38
+ }
39
+ draw() {
40
+ if (!this.view || !this.onKey) return;
41
+ const columns = this.output.columns || 80, height = this.output.rows || 24;
42
+ const width = Math.max(10, Math.min(96, columns - 4));
43
+ const inner = width - 4, capacity = Math.max(1, height - 9);
44
+ const { title, rows, footer, focus = 0 } = this.view.render(inner, capacity);
45
+ const start = Math.max(0, Math.min(Math.max(0, rows.length - capacity), focus - capacity + 2));
46
+ const visible = rows.slice(start, start + capacity);
47
+ const lines = [row(`┌${'─'.repeat(width - 2)}┐`), row(`│ ${fit(title, inner)} │`)];
48
+ for (let i = 0; i < capacity; i++) {
49
+ const item = visible[i] || row();
50
+ lines.push({ ...item, text: `│${item.selected ? '›' : ' '}${fit(item.text, inner)} │` });
51
+ }
52
+ lines.push(row(`├${'─'.repeat(width - 2)}┤`), row(`│ ${fit(footer, inner)} │`), row(`└${'─'.repeat(width - 2)}┘`));
53
+ const top = Math.max(2, Math.floor((height - lines.length) / 2));
54
+ const left = Math.max(0, Math.floor((columns - width) / 2));
55
+ const colored = !process.env.NO_COLOR && process.env.TERM !== 'dumb';
56
+ const paint = (code, text) => colored ? `\x1b[${code}m${text}\x1b[0m` : text;
57
+ const background = fit('', columns);
58
+ const screen = Array.from({ length: height }, () => paint('37;44', background));
59
+ screen[0] = paint('1;97;44', fit(' git-puller / gp', columns));
60
+ for (let i = 0; i < lines.length && top + i < height - 1; i++) {
61
+ const item = lines[i];
62
+ screen[top + i] = paint('37;44', ' '.repeat(left)) + paint(item.selected ? '1;97;46' : item.warning ? '31;47' : '30;47', item.text) + paint('37;44', ' '.repeat(Math.max(0, columns - left - width)));
63
+ }
64
+ screen[height - 1] = paint('37;44', fit(' Tab / ↑↓ — выбор Enter — открыть Esc — назад', columns));
65
+ const frame = '\x1b[H' + screen.join('\r\n');
66
+ if (frame !== this.lastFrame) { this.output.write(frame); this.lastFrame = frame; }
67
+ }
68
+ async show(render, handler) {
69
+ const previous = this.view;
70
+ let resolve;
71
+ const promise = new Promise(done => { resolve = done; });
72
+ let settled = false, handling = false;
73
+ const queue = [];
74
+ const finish = value => { if (!settled) { settled = true; resolve(value); } };
75
+ const drain = async () => {
76
+ if (handling || settled) return;
77
+ handling = true;
78
+ try {
79
+ while (queue.length && !settled) {
80
+ const [text, key] = queue.shift();
81
+ try { await handler(text, key, finish); }
82
+ catch (error) { this.notice = error.message; }
83
+ this.draw();
84
+ }
85
+ } finally { handling = false; }
86
+ };
87
+ this.view = {
88
+ render,
89
+ cancel: () => finish(null),
90
+ key: (text, key) => { if (!settled) { queue.push([text, key]); void drain(); } }
91
+ };
92
+ this.draw();
93
+ try { return await promise; } finally { this.view = previous; this.draw(); }
94
+ }
95
+ async inputText(title, initial = '', hint = '') {
96
+ let buffer = Array.from(initial), cursor = buffer.length;
97
+ return this.show(width => {
98
+ const start = Math.max(0, cursor - width + 6);
99
+ const displayed = buffer.slice(start, cursor).join('') + '▏' + buffer.slice(cursor).join('');
100
+ return { title, rows: [row(hint), row(), row(displayed, true), row(), row('Enter — сохранить Esc — отменить')], footer: 'Ctrl+U — очистить ←→ / Home / End — курсор', focus: 2 };
101
+ }, (text, key, finish) => {
102
+ if (cancelKey(key)) return finish(null);
103
+ if (key.name === 'return') return finish(buffer.join(''));
104
+ if (key.ctrl && key.name === 'u') { buffer = []; cursor = 0; }
105
+ else if (key.name === 'left') cursor = Math.max(0, cursor - 1);
106
+ else if (key.name === 'right') cursor = Math.min(buffer.length, cursor + 1);
107
+ else if (key.name === 'home') cursor = 0;
108
+ else if (key.name === 'end') cursor = buffer.length;
109
+ else if (key.name === 'backspace' && cursor) { buffer.splice(--cursor, 1); }
110
+ else if (key.name === 'delete') buffer.splice(cursor, 1);
111
+ else if (text && !key.ctrl && !key.meta && !['up', 'down', 'tab'].includes(key.name)) {
112
+ const insertion = Array.from(cleanText(text)); buffer.splice(cursor, 0, ...insertion); cursor += insertion.length;
113
+ }
114
+ });
115
+ }
116
+ async confirm(title, message, details = []) {
117
+ let selected = 0;
118
+ return this.show(() => ({ title, rows: [row(message), ...details.map(text => row(text)), row(), row(selected ? ' [ Нет ] < Да >' : ' < Нет > [ Да ]', true)], footer: '←→ / Tab — выбор Enter — подтвердить Esc — отменить', focus: details.length + 2 }), (text, key, finish) => {
119
+ if (cancelKey(key)) return finish(false);
120
+ if (['left', 'right', 'tab'].includes(key.name)) selected = 1 - selected;
121
+ if (key.name === 'return') finish(Boolean(selected));
122
+ });
123
+ }
124
+ async commands(initial) {
125
+ const commands = [...initial];
126
+ let selected = 0;
127
+ await this.show(() => ({
128
+ title: 'Команды после обновления',
129
+ rows: [row('Выполняются по порядку. Ошибка останавливает цепочку.'), row(), ...commands.map((command, index) => row(`${index + 1}. ${command}`, selected === index)), row('+ Добавить команду', selected === commands.length), row('✓ Готово', selected === commands.length + 1)],
130
+ footer: 'Enter — изменить Del — удалить Ctrl+↑↓ — порядок', focus: selected + 2
131
+ }), async (text, key, finish) => {
132
+ if (cancelKey(key)) return finish();
133
+ if (key.ctrl && ['up', 'down'].includes(key.name) && selected < commands.length) {
134
+ const next = selected + (key.name === 'up' ? -1 : 1);
135
+ if (next >= 0 && next < commands.length) { [commands[selected], commands[next]] = [commands[next], commands[selected]]; selected = next; }
136
+ } else if (key.name === 'up') selected = Math.max(0, selected - 1);
137
+ else if (key.name === 'down' || key.name === 'tab') selected = (selected + 1) % (commands.length + 2);
138
+ else if (key.name === 'delete' && selected < commands.length) commands.splice(selected, 1);
139
+ else if (key.name === 'return') {
140
+ if (selected === commands.length + 1) return finish();
141
+ const command = await this.inputText(selected === commands.length ? 'Новая команда' : `Команда ${selected + 1}`, commands[selected] || '', 'Shell-команда в папке проекта; например npm ci');
142
+ if (command?.trim()) commands[selected] = command.trim();
143
+ }
144
+ selected = Math.min(selected, commands.length + 1);
145
+ });
146
+ return commands;
147
+ }
148
+ async projectForm({ folder, existing, validate, start = true }) {
149
+ const value = { folder, name: existing?.name || '', interval: existing ? `${existing.interval / 1000}s` : '5m', timeout: existing ? `${existing.commandTimeout / 1000}s` : '10m', commands: [...(existing?.commands || [])], trusted: false, enabled: start };
150
+ let selected = 0, error = '';
151
+ const fields = ['folder', 'name', 'interval', 'timeout', 'commands', 'trusted', ...(!existing ? ['enabled'] : []), 'save', 'cancel'];
152
+ const labels = { folder: 'Папка', name: 'Название', interval: 'Проверять каждые', timeout: 'Лимит одной команды' };
153
+ const hints = { folder: 'Корень Git-репозитория. Ctrl+U очищает поле.', name: 'Оставьте пустым для названия по имени папки.', interval: 'Например 30s, 5m, 1h. Минимум 10 секунд.', timeout: 'Максимальное время выполнения одной команды.' };
154
+ return this.show(() => ({
155
+ title: existing ? `Настройка: ${existing.name}` : 'Добавить проект',
156
+ rows: [row('Выберите поле и нажмите Enter для изменения.'), row(), ...fields.map((field, index) => {
157
+ let text;
158
+ if (labels[field]) text = `${labels[field].padEnd(21)} ${value[field] || '(автоматически)'}`;
159
+ if (field === 'commands') text = `Команды по порядку ${value.commands.length ? value.commands.join(' → ') : '(не заданы)'}`;
160
+ if (field === 'trusted') text = `[${value.trusted ? 'x' : ' '}] Разрешить обновление и указанные команды`;
161
+ if (field === 'enabled') text = `[${value.enabled ? 'x' : ' '}] Запустить после сохранения`;
162
+ if (field === 'save') text = ' [ Сохранить ]';
163
+ if (field === 'cancel') text = ' [ Отмена ]';
164
+ return row(text, selected === index);
165
+ }), row(), row(error || 'Git hooks отключены. Команды выполняются от вашего имени.', false, Boolean(error))],
166
+ footer: 'Tab / ↑↓ — поле Enter — выбрать Space — переключить', focus: selected + 2
167
+ }), async (text, key, finish) => {
168
+ if (cancelKey(key)) return finish(null);
169
+ if (key.name === 'up' || (key.name === 'tab' && key.shift)) selected = (selected - 1 + fields.length) % fields.length;
170
+ else if (key.name === 'down' || key.name === 'tab') selected = (selected + 1) % fields.length;
171
+ else if (key.name === 'return' || text === ' ') {
172
+ const field = fields[selected];
173
+ if (['trusted', 'enabled'].includes(field)) value[field] = !value[field];
174
+ else if (key.name === 'return') {
175
+ if (field === 'cancel') return finish(null);
176
+ if (field === 'commands') value.commands = await this.commands(value.commands);
177
+ else if (field === 'save') {
178
+ if (!value.trusted) { error = 'Сначала разрешите обновление и запуск указанных команд.'; return; }
179
+ error = 'Проверка репозитория…'; this.draw();
180
+ try { finish(await validate(value)); } catch (e) { error = e.message; }
181
+ } else {
182
+ const result = await this.inputText(labels[field], value[field], hints[field]);
183
+ if (result !== null) value[field] = result.trim();
184
+ }
185
+ }
186
+ }
187
+ });
188
+ }
189
+ async viewer(title, read) {
190
+ let offset = 0, following = true;
191
+ return this.show((width, capacity) => {
192
+ const lines = read().split('\n').flatMap(line => {
193
+ const text = chars(line), pieces = [];
194
+ for (let index = 0; index < text.length; index += width) pieces.push(row(text.slice(index, index + width).join('')));
195
+ return pieces.length ? pieces : [row()];
196
+ });
197
+ if (following) offset = Math.max(0, lines.length - capacity);
198
+ offset = Math.max(0, Math.min(offset, Math.max(0, lines.length - capacity)));
199
+ return { title, rows: lines.slice(offset, offset + capacity), footer: '↑↓ / PgUp PgDn — прокрутка End — следить Esc — назад' };
200
+ }, (text, key, finish) => {
201
+ if (cancelKey(key) || text === 'q') return finish();
202
+ if (key.name === 'end') following = true;
203
+ else if (['up', 'down', 'pageup', 'pagedown', 'home'].includes(key.name)) {
204
+ following = false;
205
+ offset = key.name === 'home' ? 0 : offset + ({ up: -1, down: 1, pageup: -10, pagedown: 10 }[key.name]);
206
+ }
207
+ });
208
+ }
209
+ async dashboard({ projects, status, state, daemon, action, logs }) {
210
+ let selected = 0, processing = false;
211
+ return this.show((width, capacity) => {
212
+ const items = projects(); selected = Math.max(0, Math.min(selected, items.length - 1));
213
+ const p = items[selected], s = p ? state(p.id) : {};
214
+ const count = Math.max(1, capacity - 11), first = Math.max(0, selected - count + 1);
215
+ return {
216
+ title: `Проекты · daemon ${daemon() ? 'работает' : 'выключен'}`,
217
+ rows: [row(' ПРОЕКТ СТАТУС ИНТЕРВАЛ'), row(), ...(items.length ? items.slice(first, first + count).map((item, index) => row(`${index + first === selected ? '›' : ' '} ${fit(item.name, 24)} ${fit(status(item), 12)} ${item.interval / 1000}s`, index + first === selected)) : [row('Проектов нет. Нажмите A, чтобы добавить.')]), row(), ...(p ? [row(p.path), row(`Ветка: ${p.branch} → ${p.upstream}`), row(`Проверка: ${s.checkedAt || '—'}`), row(`Обновление: ${s.updatedAt || '—'}`), row(`Следующая проверка: ${p.enabled && !s.blocked && s.nextRun ? new Date(s.nextRun).toLocaleTimeString() : '—'}`), row(s.error || '', false, Boolean(s.error))] : []), row(), row(processing ? 'Выполнение…' : this.notice)],
218
+ footer: 'A добавить Enter меню Space старт/стоп L логи Q выход', focus: selected - first + 2
219
+ };
220
+ }, async (text, key, finish) => {
221
+ if (cancelKey(key) || text === 'q') return finish();
222
+ const items = projects(), p = items[selected];
223
+ if (key.name === 'up') selected = Math.max(0, selected - 1);
224
+ else if (key.name === 'down' || key.name === 'tab') selected = Math.min(items.length - 1, selected + 1);
225
+ else if (text === 'l' && p) await this.viewer(`Логи: ${p.name}`, () => logs(p));
226
+ else {
227
+ let command = text === 'a' ? 'add' : ({ ' ': p?.enabled ? 'stop' : 'start', e: 'edit', p: 'pull', r: 'retry', d: 'remove' })[text];
228
+ if (key.name === 'return' && p) command = await this.menu(p.name, [
229
+ [p.enabled ? 'Остановить расписание' : 'Запустить / возобновить', p.enabled ? 'stop' : 'start'],
230
+ ['Обновить сейчас', 'pull'], ['Настройки', 'edit'], ['Логи', 'logs'],
231
+ ['Повторить команды', 'retry'], ['Снять незавершённые команды', 'accept'], ['Удалить из реестра', 'remove']
232
+ ]);
233
+ if (command === 'logs') await this.viewer(`Логи: ${p.name}`, () => logs(p));
234
+ else if (command && (p || command === 'add')) {
235
+ processing = true; this.notice = ''; this.draw();
236
+ try { await action(command, p, this); }
237
+ catch (e) { this.notice = e.message; }
238
+ finally { processing = false; }
239
+ }
240
+ }
241
+ });
242
+ }
243
+ async menu(title, items) {
244
+ let selected = 0;
245
+ return this.show(() => ({ title, rows: items.map(([label], index) => row(` ${label}`, index === selected)), footer: '↑↓ / Tab — выбор Enter — открыть Esc — назад', focus: selected }), (text, key, finish) => {
246
+ if (cancelKey(key)) return finish(null);
247
+ if (key.name === 'up' || (key.name === 'tab' && key.shift)) selected = (selected - 1 + items.length) % items.length;
248
+ else if (key.name === 'down' || key.name === 'tab') selected = (selected + 1) % items.length;
249
+ else if (key.name === 'return') finish(items[selected][1]);
250
+ });
251
+ }
252
+ }
253
+
254
+ export const withTerminal = fn => new Terminal().open(fn);