gp-cron 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 git-puller contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,198 @@
1
+ # git-puller
2
+
3
+ Автоматическое обновление Git-проектов: команда `gp`, терминальная панель,
4
+ фоновый процесс и команды после обновления. Без внешних npm-зависимостей.
5
+
6
+ Node.js 20+ и Git. Основная платформа — macOS/Linux. Windows пока не поддерживается:
7
+ остановка деревьев процессов и отключение Git hooks используют POSIX-механизмы.
8
+
9
+ ## Установка
10
+
11
+ Из исходников, находясь в папке проекта:
12
+
13
+ ```sh
14
+ npm install -g .
15
+ gp --help
16
+ ```
17
+
18
+ Для запуска без глобальной установки: `node bin/gp.js`.
19
+
20
+ ## Быстрый старт
21
+
22
+ ```sh
23
+ cd ~/Projects/my-app
24
+ gp add
25
+ gp
26
+ ```
27
+
28
+ `gp add` открывает мастер настройки. На первом шаге текущая папка предложена
29
+ по умолчанию — нажмите Enter. Затем укажите название, интервал, команды после
30
+ обновления, таймаут и подтвердите доверие. Путь можно передать явно:
31
+ `gp add ~/Projects/my-app`.
32
+
33
+ Папка должна быть корнем Git-репозитория; текущая ветка должна иметь upstream.
34
+ При необходимости настройте его самостоятельно:
35
+
36
+ ```sh
37
+ git branch --set-upstream-to=origin/main main
38
+ ```
39
+
40
+ Добавленный проект сразу начинает обновляться. `--no-start` регистрирует его
41
+ остановленным. Для серверов и скриптов есть явная настройка без вопросов:
42
+
43
+ ```sh
44
+ gp add /srv/my-app --name my-app --interval 5m --trust \
45
+ --allow-commands --command 'npm ci' --command 'npm run build' \
46
+ --command 'pm2 reload my-app' --timeout 10m
47
+ ```
48
+
49
+ Каждая команда выполняется отдельно, по порядку, в папке проекта через системный
50
+ shell. Можно передавать `npm ci && npm run build` одной командой. Пустой список
51
+ команд означает только обновление Git. При редактировании список вводится заново.
52
+ Таймаут применяется к каждой команде; Git-операции имеют отдельный таймаут 120 секунд.
53
+ Интервалы и таймауты: от `10s` до `168h`.
54
+
55
+ ## Терминальная панель
56
+
57
+ `gp` открывает панель с автоматическим обновлением статусов, веткой, интервалом,
58
+ временем последней проверки и обновления, следующей проверкой и причиной паузы.
59
+
60
+ | Клавиша | Действие |
61
+ | --- | --- |
62
+ | ↑ / ↓ или k / j | Выбрать проект |
63
+ | a | Добавить проект |
64
+ | Space | Запустить / остановить расписание |
65
+ | p | Проверить и обновить сейчас |
66
+ | e | Изменить настройки |
67
+ | l | Посмотреть последние логи |
68
+ | r | Повторить команды после ошибки |
69
+ | d | Удалить из реестра |
70
+ | q / Ctrl+C | Закрыть панель; фоновые обновления продолжаются |
71
+
72
+ Без интерактивного терминала `gp` выводит таблицу статусов. `NO_COLOR=1` отключает
73
+ цвета в обычном CLI. Панель использует ANSI-терминал.
74
+
75
+ ## Команды
76
+
77
+ ```sh
78
+ gp list # Также: status, ls
79
+ gp scan ~/Projects # Найти репозитории до четырёх уровней вложенности
80
+ gp start my-app # Включить расписание / возобновить после паузы
81
+ gp stop my-app # Остановить расписание, дать текущей задаче завершиться
82
+ gp start all
83
+ gp stop all
84
+ gp pull my-app # Разовая проверка, даже для остановленного проекта
85
+ gp edit my-app
86
+ gp info my-app # JSON: настройки и состояние
87
+ gp logs my-app --lines 50
88
+ gp logs my-app --follow
89
+ gp retry my-app # Повторить весь список команд с начала
90
+ gp accept my-app # Подтвердить ручное решение; снять pending-команды
91
+ gp remove my-app # Файлы проекта и логи сохраняются
92
+ gp daemon start
93
+ gp daemon stop
94
+ ```
95
+
96
+ Проект выбирается по имени, ID, уникальному префиксу ID или пути. `remove` и
97
+ `accept` запрашивают подтверждение; в скриптах используйте `--yes`.
98
+ `scan` только обнаруживает папки; доверие и регистрация выполняются через `add`.
99
+
100
+ ## Что происходит при обновлении
101
+
102
+ 1. Проверяется доверие, реальный путь, текущая ветка и upstream.
103
+ 2. Незавершённый merge/rebase и локальные изменения, включая untracked-файлы,
104
+ приостанавливают проект. Игнорируемые Git файлы не считаются изменениями.
105
+ 3. Выполняется `git fetch --prune`; состояние проверяется повторно.
106
+ 4. Выполняется `git merge --ff-only @{upstream}` — безопасный эквивалент
107
+ fast-forward pull. Никаких автоматических merge-коммитов, reset, stash или force.
108
+ 5. Если HEAD изменился, последовательно выполняются настроенные команды.
109
+ 6. Следующая проверка назначается через заданный интервал после завершения задачи.
110
+
111
+ Расхождение истории, проблемы сети/авторизации и ошибки команд ставят проект
112
+ на паузу с записью причины в статус и лог. Автоматических повторов ошибок нет.
113
+ При локальных коммитах, когда upstream является предком HEAD, Git оставляет их
114
+ без изменений. Репозитории с submodules не обновляют содержимое submodules
115
+ автоматически: добавьте нужную команду самостоятельно.
116
+
117
+ ### Конфликты и локальные изменения
118
+
119
+ При конфликте проект приостанавливается, локальные изменения сохраняются.
120
+ Выполните commit/stash, закончите
121
+ незавершённую операцию или вручную решите расхождение веток, затем:
122
+
123
+ ```sh
124
+ gp start my-app
125
+ ```
126
+
127
+ Если вы намеренно сменили ветку/upstream, подтвердите новую настройку через
128
+ `gp edit my-app`. Сначала дождитесь завершения активного обновления.
129
+
130
+ ### Ошибка команды после обновления
131
+
132
+ Git-обновление сохраняется; автоматического отката базы данных, файлов или внешних
133
+ сервисов нет. Сохраняется pending-ревизия, новые обновления блокируются.
134
+
135
+ ```sh
136
+ gp logs my-app
137
+ gp edit my-app # При необходимости исправить команды
138
+ gp retry my-app # Повторить все команды, включая уже успешные
139
+ gp start my-app
140
+ ```
141
+
142
+ Команды стоит делать идемпотентными. Если решаете проблему вручную, используйте
143
+ `gp accept my-app`. Retry требует чистой рабочей папки и той же ревизии HEAD.
144
+ Pending-состояние записывается до запуска команд. При аварии между обновлением
145
+ HEAD и записью состояния проверьте выполнение команд вручную.
146
+
147
+ ## Доверие и доступ
148
+
149
+ `--trust` или подтверждение мастера разрешает обращаться к Git remote и менять
150
+ папку от имени вашего пользователя. `--allow-commands` дополнительно разрешает
151
+ заданные shell-команды при неинтерактивной регистрации. Это реальное выполнение
152
+ кода: команды сборки и `npm ci` могут запускать код, полученный из remote.
153
+ Добавляйте только проекты и remote, которым доверяете.
154
+
155
+ Доверие Git задаётся через `-c safe.directory=<точный путь>` для конкретного
156
+ вызова, без изменения глобального Git-конфига и без wildcard. Встроенные Git hooks
157
+ отключены. SSH и Git credentials должны быть настроены заранее; фоновый процесс
158
+ не спрашивает пароли и не открывает интерактивную авторизацию.
159
+
160
+ Не выполняйте параллельные ручные Git-операции в управляемой папке во время
161
+ обновления: внутренние блокировки координируют процессы `gp`, но не другие программы.
162
+
163
+ ## Фоновый процесс и хранение
164
+
165
+ Один daemon на каталог данных, одна задача на проект, проекты обновляются
166
+ последовательно. Долгая сборка задерживает проверки остальных проектов. Блокировки
167
+ с PID предотвращают пересечение `gp pull` и расписания; после завершения процесса
168
+ устаревшая блокировка восстанавливается при следующем обращении.
169
+
170
+ По умолчанию данные лежат в `~/.git-puller` (каталоги `0700`, новые файлы `0600`):
171
+
172
+ - `config.json` — доверенные папки, расписание и команды;
173
+ - `states/` — последние результаты и незавершённые команды;
174
+ - `logs/` — логи проектов, ротация около 2 MiB с одной предыдущей копией;
175
+ - `daemon.log` — ошибки фонового процесса; не ротируется автоматически;
176
+ - `locks/` — блокировки процессов.
177
+
178
+ `GP_HOME=/custom/path gp ...` изолирует отдельный реестр и daemon. Конфигурация
179
+ записывается атомарно. Логи могут содержать вывод ваших команд: не выводите в них
180
+ секреты. Удаление проекта сохраняет его историю на диске.
181
+
182
+ Daemon стартует автоматически при `add`/`start`, живёт после закрытия панели.
183
+ `stop all` выключает расписание, `daemon stop` завершает сам процесс и прерывает
184
+ активную Git/shell-команду. После перезагрузки компьютера запустите `gp daemon start`
185
+ или подключите эту команду к launchd/systemd; автозагрузка и самовосстановление
186
+ упавшего daemon в этой версии не устанавливаются.
187
+
188
+ ## Разработка
189
+
190
+ ```sh
191
+ npm test
192
+ npm run check
193
+ npm pack --dry-run
194
+ ```
195
+
196
+ Интеграционные тесты создают локальные bare-remotes и проверяют настоящие
197
+ fast-forward обновления, сохранность локальной работы, расхождение истории,
198
+ ошибки/повтор команд, блокировки, таймауты и фоновое расписание. Сеть не нужна.
package/bin/gp.js ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+ import { main } from '../src/cli.js';
3
+ main().catch(error => {
4
+ console.error(`gp: ${error.message}`);
5
+ process.exitCode = 1;
6
+ });
package/package.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "gp-cron",
3
+ "version": "0.1.0",
4
+ "description": "A safe, local Git project updater with a terminal dashboard",
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"],
11
+ "license": "MIT"
12
+ }
package/src/cli.js ADDED
@@ -0,0 +1,296 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { spawn } from 'node:child_process';
6
+ import readline from 'node:readline';
7
+ import { createInterface } from 'node:readline/promises';
8
+ import { config, home, alive, interval, lock, logFile, select, setState, state, updateConfig } from './store.js';
9
+ import { daemon, inspect, pull } from './worker.js';
10
+
11
+ const entry = fileURLToPath(new URL('../bin/gp.js', import.meta.url));
12
+ const color = (code, text) => process.stdout.isTTY && !process.env.NO_COLOR ? `\x1b[${code}m${text}\x1b[0m` : text;
13
+ const safe = value => String(value ?? '').replace(/[\x00-\x1f\x7f-\x9f]/g, ' ');
14
+ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
15
+ function options(args) {
16
+ const result = { _: [], command: [] };
17
+ const flags = new Set(['trust', 'allow-commands', 'no-start', 'yes', 'follow']);
18
+ const values = new Set(['name', 'interval', 'command', 'timeout', 'lines']);
19
+ for (let i = 0; i < args.length; i++) {
20
+ const arg = args[i];
21
+ if (!arg.startsWith('--')) { result._.push(arg); continue; }
22
+ const key = arg.slice(2);
23
+ if (flags.has(key)) result[key] = true;
24
+ else if (values.has(key) && args[i + 1] !== undefined) {
25
+ const value = args[++i];
26
+ if (key === 'command') result.command.push(value); else result[key] = value;
27
+ } else throw new Error(`Unknown or incomplete option: ${arg}`);
28
+ }
29
+ return result;
30
+ }
31
+ function daemonPid() {
32
+ try { const pid = Number(fs.readFileSync(path.join(home, 'locks', 'daemon.lock'), 'utf8')); return alive(pid) ? pid : null; } catch { return null; }
33
+ }
34
+ async function ensureDaemon() {
35
+ if (daemonPid()) return;
36
+ const fd = fs.openSync(path.join(home, 'daemon.log'), 'a', 0o600);
37
+ const child = spawn(process.execPath, [entry, '__daemon'], { detached: true, stdio: ['ignore', fd, fd], env: { ...process.env, GP_HOME: home } });
38
+ let spawnError;
39
+ child.on('error', error => { spawnError = error; });
40
+ child.unref(); fs.closeSync(fd);
41
+ for (let i = 0; i < 50; i++) { if (spawnError) throw spawnError; if (daemonPid()) return; await sleep(100); }
42
+ throw new Error(`Daemon failed to start. Inspect ${path.join(home, 'daemon.log')}`);
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 };
65
+ });
66
+ }
67
+ async function add(folder, opts) {
68
+ let values;
69
+ if (opts.trust) {
70
+ const repo = await inspect(path.resolve(folder || '.'));
71
+ if (opts.command.length && !opts['allow-commands']) throw new Error('Use --allow-commands to explicitly authorize post-update commands.');
72
+ 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'] };
75
+ updateConfig(c => {
76
+ 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
+ c.projects.push(project);
78
+ });
79
+ if (project.enabled) await ensureDaemon();
80
+ console.log(`Added ${safe(project.name)} (${project.id})${project.enabled ? ' · running' : ' · stopped'}`);
81
+ }
82
+ function status(p) {
83
+ const s = state(p.id);
84
+ if (!p.enabled) return 'stopped';
85
+ if (s.blocked) return 'paused';
86
+ if (!daemonPid()) return 'offline';
87
+ return ['checking', 'commands'].includes(s.phase) ? s.phase : 'watching';
88
+ }
89
+ function list() {
90
+ const projects = config().projects;
91
+ console.log(color('1;36', ' gp · git-puller') + ` daemon ${daemonPid() ? `online · ${daemonPid()}` : 'offline'}`);
92
+ if (!projects.length) { console.log('\nNo projects yet. Run gp add /path/to/project'); return; }
93
+ console.table(projects.map(p => ({ id: p.id, project: safe(p.name), status: status(p), interval: `${p.interval / 1000}s`, branch: safe(p.branch), updated: state(p.id).updatedAt || '—', folder: safe(p.path) })));
94
+ }
95
+ function mutateProject(p, fn) {
96
+ const release = lock(`project-${p.id}`);
97
+ if (!release) throw new Error('Project is currently updating. Try again when it finishes.');
98
+ try { return fn(); } finally { release(); }
99
+ }
100
+ async function edit(p) {
101
+ const values = await wizard(p.path, p);
102
+ 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.');
104
+ const current = c.projects.find(item => item.id === p.id);
105
+ if (!current) throw new Error('Project was removed.');
106
+ Object.assign(current, values);
107
+ }));
108
+ console.log('Saved. Run gp start to resume a paused project.');
109
+ }
110
+ async function logs(p, opts = {}) {
111
+ const count = Number(opts.lines || 30);
112
+ if (!Number.isInteger(count) || count < 1 || count > 10000) throw new Error('--lines must be between 1 and 10000.');
113
+ const file = logFile(p.id);
114
+ const content = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
115
+ console.log(content.trimEnd().split('\n').slice(-count).map(safe).join('\n') || 'No logs yet.');
116
+ if (!opts.follow) return;
117
+ let offset = Buffer.byteLength(content), stopped = false;
118
+ const stop = () => { stopped = true; };
119
+ process.on('SIGINT', stop); process.on('SIGTERM', stop);
120
+ try {
121
+ while (!stopped) {
122
+ await sleep(500);
123
+ if (!fs.existsSync(file)) continue;
124
+ const data = fs.readFileSync(file);
125
+ if (data.length < offset) offset = 0;
126
+ if (data.length > offset) console.log(safe(data.subarray(offset).toString()).trim());
127
+ offset = data.length;
128
+ }
129
+ } finally { process.off('SIGINT', stop); process.off('SIGTERM', stop); }
130
+ }
131
+ async function scan(root) {
132
+ const found = [];
133
+ function walk(dir, depth) {
134
+ if (fs.existsSync(path.join(dir, '.git'))) { found.push(dir); return; }
135
+ if (depth >= 4) return;
136
+ let entries;
137
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
138
+ for (const e of entries) if (e.isDirectory() && !e.name.startsWith('.') && !['node_modules', 'vendor', 'dist'].includes(e.name)) walk(path.join(dir, e.name), depth + 1);
139
+ }
140
+ walk(fs.realpathSync(root || '.'), 0);
141
+ const known = new Set(config().projects.map(p => p.path));
142
+ for (const folder of found) console.log(`${known.has(folder) ? 'registered' : 'available '} ${safe(folder)}`);
143
+ console.log(`${found.length} repositories found. Register with gp add "<folder>".`);
144
+ }
145
+ async function action(command, query, opts = {}) {
146
+ if (command === 'start' || command === 'stop') {
147
+ const targets = query === 'all' ? config().projects : [select(query)];
148
+ const releases = [];
149
+ try {
150
+ if (command === 'start') for (const p of targets) {
151
+ const release = lock(`project-${p.id}`);
152
+ if (!release) throw new Error(`${p.name} is currently updating. Try again when it finishes.`);
153
+ releases.push(release);
154
+ }
155
+ if (command === 'start') for (const p of targets) setState(p.id, { blocked: false, phase: 'idle', nextRun: 0, error: null });
156
+ updateConfig(c => { for (const p of c.projects) if (targets.some(t => t.id === p.id)) p.enabled = command === 'start'; });
157
+ } finally { for (const release of releases.reverse()) release(); }
158
+ 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.' : ''}`);
160
+ return;
161
+ }
162
+ const p = select(query);
163
+ if (command === 'edit') return edit(p);
164
+ if (command === 'logs') return logs(p, opts);
165
+ if (command === 'info') { console.log(JSON.stringify({ ...p, status: status(p), runtime: state(p.id) }, null, 2)); return; }
166
+ if (command === 'pull' || command === 'retry') {
167
+ const result = await pull(p, { retry: command === 'retry' });
168
+ if (result.error) throw new Error(result.error);
169
+ console.log(result.busy ? 'Project is already updating.' : 'Done.'); return;
170
+ }
171
+ if (command === 'remove' || command === 'accept') {
172
+ 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');
174
+ if (!confirmed) return;
175
+ }
176
+ mutateProject(p, () => {
177
+ if (command === 'remove') updateConfig(c => { c.projects = c.projects.filter(item => item.id !== p.id); });
178
+ else setState(p.id, { pendingRevision: null, blocked: false, error: null, phase: 'idle', nextRun: Date.now() + p.interval });
179
+ });
180
+ console.log(command === 'remove' ? 'Project unregistered; files and logs retained.' : 'Pending commands cleared.'); return;
181
+ }
182
+ throw new Error(`Unknown command: ${command}`);
183
+ }
184
+
185
+ async function dashboard() {
186
+ 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}`);
214
+ }
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;
246
+ }
247
+
248
+ const help = `git-puller · gp
249
+
250
+ gp Interactive dashboard (↑↓, keyboard shortcuts)
251
+ gp add [folder] Guided registration, trust, schedule and commands
252
+ gp list Project status (alias: status, ls)
253
+ gp scan [folder] Discover repositories, up to four levels deep
254
+ gp start <name|id|all> Start/resume scheduled updates
255
+ gp stop <name|id|all> Stop scheduling; active job finishes
256
+ gp pull <name|id> Check and update immediately, even when stopped
257
+ gp edit <name|id> Reconfigure interactively
258
+ gp info <name|id> Configuration and runtime state as JSON
259
+ gp logs <name|id> Logs (--follow, --lines 50)
260
+ gp retry <name|id> Rerun pending post-update commands from the beginning
261
+ gp accept <name|id> Clear pending commands after manual resolution
262
+ gp remove <name|id> Unregister, preserving repository and logs
263
+ gp daemon start|stop Manage the background process
264
+ gp help Show this help
265
+
266
+ Non-interactive setup:
267
+ gp add ./app --trust --interval 5m --name app \\
268
+ --allow-commands --command 'npm ci' --command 'npm run build'
269
+
270
+ Options: --no-start, --timeout 10m; remove/accept: --yes.
271
+ Data: ${home} (override with GP_HOME).
272
+ Only fast-forward updates; local changes/diverged history pause the project.
273
+ Fix Git manually, then gp start <name>. Commands run only after HEAD changes.
274
+ `;
275
+ export async function main(args = process.argv.slice(2)) {
276
+ config();
277
+ const [command, ...rest] = args;
278
+ if (command === '__daemon') return daemon();
279
+ if (!command || command === 'ui') return dashboard();
280
+ if (['help', '--help', '-h'].includes(command)) { console.log(help); return; }
281
+ if (['--version', '-v'].includes(command)) { console.log('0.1.0'); return; }
282
+ if (['list', 'ls', 'status'].includes(command)) return list();
283
+ const opts = options(rest);
284
+ if (command === 'add') return add(opts._[0], opts);
285
+ if (command === 'scan') return scan(opts._[0]);
286
+ if (command === 'daemon') {
287
+ if (opts._[0] === 'start') { await ensureDaemon(); console.log('Daemon online.'); return; }
288
+ if (opts._[0] !== 'stop') throw new Error('Use gp daemon start|stop.');
289
+ const pid = daemonPid();
290
+ if (!pid) { console.log('Daemon is offline.'); return; }
291
+ process.kill(pid, 'SIGTERM');
292
+ for (let i = 0; i < 50; i++) { if (!daemonPid()) { console.log('Daemon stopped.'); return; } await sleep(100); }
293
+ console.log('Shutdown requested; waiting for the active job to finish.'); return;
294
+ }
295
+ return action(command, opts._[0], opts);
296
+ }
package/src/process.js ADDED
@@ -0,0 +1,27 @@
1
+ import { spawn } from 'node:child_process';
2
+
3
+ export function run(command, args, { cwd, timeout = 120000, shell = false, onOutput } = {}) {
4
+ return new Promise((resolve, reject) => {
5
+ const child = spawn(command, args, { cwd, shell, detached: process.platform !== 'win32', stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GCM_INTERACTIVE: 'never', GIT_SSH_COMMAND: `${process.env.GIT_SSH_COMMAND || 'ssh'} -oBatchMode=yes -oConnectTimeout=15` } });
6
+ let output = '', timedOut = false;
7
+ const collect = data => { const text = data.toString(); output = (output + text).slice(-64000); onOutput?.(text); };
8
+ child.stdout.on('data', collect); child.stderr.on('data', collect);
9
+ function kill() {
10
+ try { process.kill(process.platform === 'win32' ? child.pid : -child.pid, 'SIGKILL'); } catch {}
11
+ }
12
+ const timer = setTimeout(() => { timedOut = true; kill(); }, timeout);
13
+ const abort = () => kill();
14
+ process.on('SIGTERM', abort); process.on('SIGINT', abort);
15
+ const cleanup = () => { clearTimeout(timer); process.off('SIGTERM', abort); process.off('SIGINT', abort); };
16
+ child.on('error', error => { cleanup(); reject(error); });
17
+ child.on('close', (code, signal) => {
18
+ cleanup();
19
+ if (code === 0 && !timedOut) resolve(output.trim());
20
+ else reject(new Error(timedOut ? `Command timed out after ${timeout / 1000}s: ${command}` : `${command} failed (${code ?? signal}): ${output.trim()}`));
21
+ });
22
+ });
23
+ }
24
+ export function git(folder, args, options = {}) {
25
+ // Trust only this invocation; never change the user's global safe.directory.
26
+ return run('git', ['-c', `safe.directory=${folder}`, '-c', 'core.hooksPath=/dev/null', '-C', folder, ...args], options);
27
+ }
package/src/store.js ADDED
@@ -0,0 +1,71 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import crypto from 'node:crypto';
5
+
6
+ export const home = path.resolve(process.env.GP_HOME || path.join(os.homedir(), '.git-puller'));
7
+ export function init() {
8
+ fs.mkdirSync(home, { recursive: true, mode: 0o700 });
9
+ for (const dir of ['states', 'logs', 'locks']) fs.mkdirSync(path.join(home, dir), { recursive: true, mode: 0o700 });
10
+ }
11
+ export function atomic(file, value) {
12
+ const temp = `${file}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
13
+ fs.writeFileSync(temp, JSON.stringify(value, null, 2) + '\n', { mode: 0o600 });
14
+ fs.renameSync(temp, file);
15
+ }
16
+ export function read(file, fallback) {
17
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); }
18
+ catch (e) { if (e.code === 'ENOENT') return fallback; throw new Error(`Cannot read ${file}: ${e.message}`); }
19
+ }
20
+ export function config() { init(); return read(path.join(home, 'config.json'), { version: 1, projects: [] }); }
21
+ export function alive(pid) {
22
+ if (!Number.isInteger(pid) || pid <= 0) return false;
23
+ try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; }
24
+ }
25
+ // Exclusive files carry their owner PID. An empty/unfinished lock gets a grace period.
26
+ export function lock(name) {
27
+ init();
28
+ const file = path.join(home, 'locks', `${name}.lock`);
29
+ for (let attempt = 0; attempt < 2; attempt++) {
30
+ try {
31
+ const fd = fs.openSync(file, 'wx', 0o600);
32
+ fs.writeFileSync(fd, String(process.pid)); fs.closeSync(fd);
33
+ return () => { try { if (fs.readFileSync(file, 'utf8') === String(process.pid)) fs.unlinkSync(file); } catch {} };
34
+ } catch (e) {
35
+ if (e.code !== 'EEXIST') throw e;
36
+ let owner, age;
37
+ try { owner = Number(fs.readFileSync(file, 'utf8')); age = Date.now() - fs.statSync(file).mtimeMs; }
38
+ catch (e) { if (e.code === 'ENOENT') continue; throw e; }
39
+ if (alive(owner) || (!owner && age < 10000)) return null;
40
+ try { fs.unlinkSync(file); } catch (e) { if (e.code !== 'ENOENT') throw e; }
41
+ }
42
+ }
43
+ return null;
44
+ }
45
+ export function updateConfig(fn) {
46
+ const release = lock('config');
47
+ if (!release) throw new Error('Configuration is busy; try again.');
48
+ try { const value = config(); fn(value); atomic(path.join(home, 'config.json'), value); return value; }
49
+ finally { release(); }
50
+ }
51
+ export function state(id) { return read(path.join(home, 'states', `${id}.json`), {}); }
52
+ export function setState(id, patch) { atomic(path.join(home, 'states', `${id}.json`), { ...state(id), ...patch }); }
53
+ export function logFile(id) { return path.join(home, 'logs', `${id}.log`); }
54
+ export function log(id, message) {
55
+ const file = logFile(id);
56
+ try { if (fs.statSync(file).size > 2 * 1024 * 1024) fs.renameSync(file, `${file}.1`); } catch (e) { if (e.code !== 'ENOENT') throw e; }
57
+ fs.appendFileSync(file, `[${new Date().toISOString()}] ${message}\n`, { mode: 0o600 });
58
+ }
59
+ export function select(query, projects = config().projects) {
60
+ const exact = projects.filter(p => p.id === query || p.name === query || p.path === path.resolve(query || '.'));
61
+ if (exact.length === 1) return exact[0];
62
+ const partial = projects.filter(p => query && p.id.startsWith(query));
63
+ if (!exact.length && partial.length === 1) return partial[0];
64
+ throw new Error(`Project "${query}" is missing or ambiguous. Run gp list.`);
65
+ }
66
+ export function interval(value) {
67
+ const match = /^(\d+(?:\.\d+)?)(s|m|h)?$/.exec(String(value));
68
+ const ms = match && Number(match[1]) * ({ s: 1000, m: 60000, h: 3600000 }[match[2] || 's']);
69
+ if (!ms || ms < 10000 || ms > 7 * 86400000) throw new Error('Interval must be between 10s and 168h (for example 5m).');
70
+ return ms;
71
+ }
package/src/worker.js ADDED
@@ -0,0 +1,90 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { git, run } from './process.js';
4
+ import { config, home, lock, log, setState, state } from './store.js';
5
+
6
+ export async function inspect(folder) {
7
+ const real = fs.realpathSync(folder);
8
+ const root = await git(real, ['rev-parse', '--show-toplevel']);
9
+ if (fs.realpathSync(root) !== real) throw new Error('Choose the repository root, not a subfolder.');
10
+ let branch, upstream;
11
+ try { branch = await git(real, ['symbolic-ref', '--quiet', '--short', 'HEAD']); }
12
+ catch { throw new Error('Detached HEAD: check out a branch before registering or resuming.'); }
13
+ try { upstream = await git(real, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}']); }
14
+ catch { throw new Error(`No upstream for branch ${branch}. Configure tracking with git branch --set-upstream-to, then gp edit.`); }
15
+ return { path: real, branch, upstream };
16
+ }
17
+ async function clean(p) {
18
+ if (fs.realpathSync(p.path) !== p.path) throw new Error('Repository path changed; add and trust it again.');
19
+ const info = await inspect(p.path);
20
+ if (info.branch !== p.branch || info.upstream !== p.upstream) throw new Error('Branch or upstream changed; review with gp edit.');
21
+ const dir = await git(p.path, ['rev-parse', '--absolute-git-dir']);
22
+ for (const marker of ['MERGE_HEAD', 'CHERRY_PICK_HEAD', 'REVERT_HEAD', 'rebase-merge', 'rebase-apply', 'BISECT_LOG']) {
23
+ if (fs.existsSync(path.join(dir, marker))) throw new Error('An unfinished Git operation needs attention. Finish or abort it in the repository.');
24
+ }
25
+ if (await git(p.path, ['status', '--porcelain', '--untracked-files=normal'])) throw new Error('Local changes found. Commit or stash them, then run gp start.');
26
+ }
27
+ async function hooks(p, revision) {
28
+ setState(p.id, { phase: 'commands', pendingRevision: revision });
29
+ for (const command of p.commands) {
30
+ log(p.id, `$ ${command}`);
31
+ await run(command, [], { cwd: p.path, shell: true, timeout: p.commandTimeout, onOutput: text => log(p.id, text.trimEnd()) });
32
+ }
33
+ setState(p.id, { pendingRevision: null });
34
+ }
35
+ export async function pull(p, { retry = false } = {}) {
36
+ const release = lock(`project-${p.id}`);
37
+ if (!release) return { busy: true };
38
+ try {
39
+ if (!p.trusted) throw new Error('Repository is not trusted.');
40
+ const previous = state(p.id);
41
+ if (previous.pendingRevision && !retry) throw new Error('Post-update commands failed or were interrupted. Review logs and use gp retry, or gp accept to clear the pending run.');
42
+ setState(p.id, { phase: 'checking', checkedAt: new Date().toISOString(), error: null });
43
+ await clean(p);
44
+ const before = await git(p.path, ['rev-parse', 'HEAD']);
45
+ if (retry) {
46
+ if (!previous.pendingRevision) throw new Error('No pending commands to retry.');
47
+ if (before !== previous.pendingRevision) throw new Error('HEAD changed since the failed commands; review and use gp accept.');
48
+ await hooks(p, before);
49
+ } else {
50
+ log(p.id, `Checking ${p.branch} → ${p.upstream}`);
51
+ await git(p.path, ['fetch', '--prune']);
52
+ // Recheck after network I/O in case another process modified the working tree.
53
+ await clean(p);
54
+ if (before !== await git(p.path, ['rev-parse', 'HEAD'])) throw new Error('HEAD changed during fetch. Try again after other Git operations finish.');
55
+ await git(p.path, ['merge', '--ff-only', '--no-edit', '@{upstream}']);
56
+ const after = await git(p.path, ['rev-parse', 'HEAD']);
57
+ if (before !== after) {
58
+ setState(p.id, { revision: after, updatedAt: new Date().toISOString(), pendingRevision: p.commands.length ? after : null });
59
+ log(p.id, `Updated ${before.slice(0, 8)} → ${after.slice(0, 8)}`);
60
+ await hooks(p, after);
61
+ } else log(p.id, 'Already up to date');
62
+ }
63
+ setState(p.id, { phase: 'idle', blocked: false, error: null, nextRun: Date.now() + p.interval });
64
+ return { ok: true };
65
+ } catch (error) {
66
+ log(p.id, `PAUSED: ${error.message}`);
67
+ setState(p.id, { phase: 'paused', blocked: true, error: error.message, nextRun: null });
68
+ return { error: error.message };
69
+ } finally { release(); }
70
+ }
71
+ export async function daemon() {
72
+ const release = lock('daemon');
73
+ if (!release) return;
74
+ let stopping = false;
75
+ const stop = () => { stopping = true; };
76
+ process.on('SIGTERM', stop); process.on('SIGINT', stop);
77
+ try {
78
+ while (!stopping) {
79
+ const projects = config().projects;
80
+ // Sequential jobs bound load and keep shutdown predictable.
81
+ for (const p of projects) {
82
+ if (stopping) break;
83
+ const current = config().projects.find(item => item.id === p.id);
84
+ const s = state(p.id);
85
+ if (current?.enabled && !s.blocked && (!s.nextRun || s.nextRun <= Date.now())) await pull(current);
86
+ }
87
+ if (!stopping) await new Promise(resolve => setTimeout(resolve, 1000));
88
+ }
89
+ } finally { release(); process.off('SIGTERM', stop); process.off('SIGINT', stop); }
90
+ }